mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 18:53:54 +01:00
Refresh the budget's stored weight from the phone's weigh-in after each sync
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f6a9b0ebf
commit
e9ff530198
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.81+1
|
||||
version: 1.0.82+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -27,6 +27,10 @@ _LAZY_ATTRS = {
|
||||
"run_sync": ("diet_guard._sync", "run_sync"),
|
||||
"SyncError": ("diet_guard._sync", "SyncError"),
|
||||
"RemoteSyncError": ("crdt_sync", "RemoteSyncError"),
|
||||
"refresh_weight_from_phone": (
|
||||
"diet_guard._phone_weight",
|
||||
"refresh_weight_from_phone",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -78,4 +82,7 @@ def cmd_sync(emit: Callable[[str], None]) -> int:
|
||||
return 1
|
||||
total_entries = sum(len(entries) for entries in merged.values())
|
||||
emit(f"synced: {total_entries} entries across {len(merged)} day(s).")
|
||||
# After the merge, never before: the local record is then already the
|
||||
# merged winner, so refreshing its ``t`` re-asserts nothing stale.
|
||||
module.refresh_weight_from_phone(emit)
|
||||
return 0
|
||||
|
||||
97
diet_guard/_phone_weight.py
Normal file
97
diet_guard/_phone_weight.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Carry the phone's morning weigh-in into the budget's stored weight.
|
||||
|
||||
The wake-alarm phone app ends every morning session with a typed weight,
|
||||
published as ``latest_weight_kg`` on its synced record; ``wake_alarm._weight``
|
||||
reads it on the PC. That weight is what the protein target here is derived
|
||||
from (``_budget_derived.protein_target_g``), so after each successful
|
||||
``sync`` the stored ``w`` is refreshed from it.
|
||||
|
||||
Only ``w`` moves. The kcal budget is not recomputed -- biometrics are
|
||||
discarded by design on this side (see ``_budget_biometrics``), so there is
|
||||
nothing to recompute it *from*. ``t`` is refreshed with ``w`` because both
|
||||
ride the same edit timestamp into the per-field last-write-wins merge
|
||||
(``sync_merge._budget``); a new weight under an old ``t`` would lose to
|
||||
every other device on the next merge.
|
||||
|
||||
``wake_alarm`` is an optional neighbour, imported lazily: a machine without
|
||||
it (or with a broken install) syncs exactly as before and simply never
|
||||
refreshes the weight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._budget import _now_local, read_raw_record, write_raw_record
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
__all__ = ["refresh_weight_from_phone", "update_weight"]
|
||||
|
||||
# Below this the scale and the record agree; a rewrite would only churn ``t``.
|
||||
_SAME_WEIGHT_KG = 0.05
|
||||
|
||||
|
||||
def update_weight(kg: float) -> bool:
|
||||
"""Set the budget record's stored weight to ``kg``; False when no budget.
|
||||
|
||||
Writes through the raw accessors rather than :func:`_budget.write_budget`
|
||||
on purpose: that path stamps a budget change into the effective-from
|
||||
history, and the budget has not changed.
|
||||
"""
|
||||
record = read_raw_record()
|
||||
if record is None:
|
||||
return False
|
||||
record["w"] = round(float(kg), 1)
|
||||
record["t"] = _now_local().isoformat(timespec="seconds")
|
||||
write_raw_record(record)
|
||||
return True
|
||||
|
||||
|
||||
def _budget_edit_day(record: dict[str, object]) -> str:
|
||||
"""The local ``yyyy-mm-dd`` of the record's last edit, or '' when unknown."""
|
||||
return str(record.get("t", ""))[:10]
|
||||
|
||||
|
||||
def refresh_weight_from_phone(emit: Callable[[str], None]) -> None:
|
||||
"""Apply the phone's newest weigh-in to the stored weight, if it is newer.
|
||||
|
||||
Skipped, silently, when: ``wake_alarm`` is not installed; nothing has
|
||||
been published (or the backend is unreadable -- ``latest_weight`` never
|
||||
raises); no budget exists; the weigh-in is not from a *later* day than
|
||||
the budget's last edit; or it matches the stored weight within
|
||||
:data:`_SAME_WEIGHT_KG`.
|
||||
|
||||
The day comparison is strict. A weigh-in happens in the morning and a
|
||||
manual budget edit at any time of day, and ``t`` carries only the day's
|
||||
granularity worth trusting here, so on a shared day the budget edit is
|
||||
taken as the more recent human action and kept. The cost is one day of
|
||||
lag in that one case; the alternative overwrites an edit made hours
|
||||
after the weigh-in.
|
||||
|
||||
Args:
|
||||
emit: One-line output sink, given a line only when a write happened.
|
||||
"""
|
||||
try:
|
||||
weight_module = import_module("wake_alarm._weight")
|
||||
except ImportError:
|
||||
return
|
||||
weight = weight_module.latest_weight()
|
||||
if weight is None:
|
||||
return
|
||||
record = read_raw_record()
|
||||
if record is None:
|
||||
return
|
||||
if weight.date <= _budget_edit_day(record):
|
||||
return
|
||||
stored = record.get("w")
|
||||
if (
|
||||
isinstance(stored, (int, float))
|
||||
and not isinstance(stored, bool)
|
||||
and abs(float(stored) - weight.kg) < _SAME_WEIGHT_KG
|
||||
):
|
||||
return
|
||||
update_weight(weight.kg)
|
||||
emit(f"weight: {weight.kg} kg from the phone ({weight.date}).")
|
||||
@ -19,9 +19,23 @@ class TestCmdSync:
|
||||
"2026-06-21": [{"id": "c"}],
|
||||
}
|
||||
lines: list[str] = []
|
||||
with patch.object(_cli_sync, "run_sync", return_value=merged):
|
||||
with (
|
||||
patch.object(_cli_sync, "run_sync", return_value=merged),
|
||||
patch.object(_cli_sync, "refresh_weight_from_phone") as refresh,
|
||||
):
|
||||
assert _cli_sync.cmd_sync(lines.append) == 0
|
||||
assert lines == ["synced: 3 entries across 2 day(s)."]
|
||||
# The phone's weigh-in is applied after the merge, through the same
|
||||
# sink, so its one line lands under the sync summary.
|
||||
refresh.assert_called_once_with(lines.append)
|
||||
|
||||
def test_a_failed_sync_does_not_touch_the_weight(self) -> None:
|
||||
with (
|
||||
patch.object(_cli_sync, "run_sync", side_effect=SyncError("no token")),
|
||||
patch.object(_cli_sync, "refresh_weight_from_phone") as refresh,
|
||||
):
|
||||
assert _cli_sync.cmd_sync(lambda _line: None) == 1
|
||||
refresh.assert_not_called()
|
||||
|
||||
def test_reports_sync_error_as_not_configured(self) -> None:
|
||||
lines: list[str] = []
|
||||
|
||||
141
diet_guard/tests/test_phone_weight.py
Normal file
141
diet_guard/tests/test_phone_weight.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for _phone_weight.py -- the phone's weigh-in into the stored weight.
|
||||
|
||||
The on-disk budget is redirected by conftest's ``_isolate_state``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard import _phone_weight
|
||||
from diet_guard._budget import read_raw_record, write_raw_record
|
||||
from diet_guard._budget_derived import budget_weight
|
||||
from diet_guard._phone_weight import refresh_weight_from_phone, update_weight
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeWeight:
|
||||
"""Shape of ``wake_alarm._weight.Weight``, without the dependency."""
|
||||
|
||||
date: str
|
||||
kg: float
|
||||
device_id: str = "phone"
|
||||
|
||||
|
||||
class FakeWeightModule:
|
||||
"""Stand-in for ``wake_alarm._weight`` with a canned ``latest_weight``."""
|
||||
|
||||
def __init__(self, weight: FakeWeight | None) -> None:
|
||||
self.weight = weight
|
||||
self.calls = 0
|
||||
|
||||
def latest_weight(self) -> FakeWeight | None:
|
||||
self.calls += 1
|
||||
return self.weight
|
||||
|
||||
|
||||
def _seed(weight: float | None, edited: str) -> None:
|
||||
record: dict[str, object] = {"v": 2, "b": 2000, "t": edited}
|
||||
if weight is not None:
|
||||
record["w"] = weight
|
||||
write_raw_record(record)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def phone(request: pytest.FixtureRequest) -> FakeWeightModule:
|
||||
module = FakeWeightModule(getattr(request, "param", None))
|
||||
with patch.object(_phone_weight, "import_module", return_value=module):
|
||||
yield module
|
||||
|
||||
|
||||
def _lines_after(module: FakeWeightModule, weight: FakeWeight | None) -> list[str]:
|
||||
module.weight = weight
|
||||
lines: list[str] = []
|
||||
refresh_weight_from_phone(lines.append)
|
||||
return lines
|
||||
|
||||
|
||||
class TestUpdateWeight:
|
||||
def test_sets_weight_and_refreshes_the_edit_stamp(self) -> None:
|
||||
_seed(78.5, "2026-08-15T18:38:45+02:00")
|
||||
assert update_weight(72.44) is True
|
||||
record = read_raw_record()
|
||||
assert record is not None
|
||||
assert record["w"] == 72.4
|
||||
assert record["b"] == 2000
|
||||
assert str(record["t"]).startswith("20")
|
||||
assert record["t"] != "2026-08-15T18:38:45+02:00"
|
||||
assert budget_weight() == 72.4
|
||||
|
||||
def test_without_a_budget_writes_nothing(self) -> None:
|
||||
assert update_weight(72.4) is False
|
||||
assert read_raw_record() is None
|
||||
|
||||
|
||||
class TestRefreshWeightFromPhone:
|
||||
def test_applies_a_newer_weigh_in_and_reports_it(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
_seed(78.5, "2026-08-15T18:38:45+02:00")
|
||||
lines = _lines_after(phone, FakeWeight("2026-09-17", 72.4))
|
||||
assert lines == ["weight: 72.4 kg from the phone (2026-09-17)."]
|
||||
assert budget_weight() == 72.4
|
||||
|
||||
def test_applies_when_the_record_has_no_weight_yet(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
_seed(None, "2026-08-15T18:38:45+02:00")
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.4))
|
||||
assert budget_weight() == 72.4
|
||||
|
||||
def test_applies_when_the_record_has_no_edit_stamp(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
write_raw_record({"v": 1, "b": 2000})
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.4))
|
||||
assert budget_weight() == 72.4
|
||||
|
||||
def test_skips_a_weigh_in_from_the_budget_edit_day_or_earlier(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
_seed(78.5, "2026-09-17T21:30:00+02:00")
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) == []
|
||||
assert _lines_after(phone, FakeWeight("2026-09-16", 72.4)) == []
|
||||
assert budget_weight() == 78.5
|
||||
|
||||
def test_skips_a_weight_that_matches_within_tolerance(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
_seed(72.4, "2026-08-15T18:38:45+02:00")
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.44)) == []
|
||||
record = read_raw_record()
|
||||
assert record is not None
|
||||
assert record["t"] == "2026-08-15T18:38:45+02:00"
|
||||
|
||||
def test_a_bool_weight_in_the_record_is_not_a_weight(
|
||||
self, phone: FakeWeightModule
|
||||
) -> None:
|
||||
write_raw_record({"v": 2, "b": 2000, "t": "2026-08-15", "w": True})
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.4))
|
||||
assert budget_weight() == 72.4
|
||||
|
||||
def test_skips_when_nothing_is_published(self, phone: FakeWeightModule) -> None:
|
||||
_seed(78.5, "2026-08-15T18:38:45+02:00")
|
||||
assert _lines_after(phone, None) == []
|
||||
assert budget_weight() == 78.5
|
||||
|
||||
def test_skips_when_there_is_no_budget(self, phone: FakeWeightModule) -> None:
|
||||
assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) == []
|
||||
assert read_raw_record() is None
|
||||
|
||||
def test_without_wake_alarm_installed_does_nothing(self) -> None:
|
||||
_seed(78.5, "2026-08-15T18:38:45+02:00")
|
||||
lines: list[str] = []
|
||||
with patch.dict(sys.modules, {"wake_alarm._weight": None}):
|
||||
refresh_weight_from_phone(lines.append)
|
||||
assert lines == []
|
||||
assert budget_weight() == 78.5
|
||||
Loading…
Reference in New Issue
Block a user