From e9ff5301988a6ca87ce1a1aaab0d4d7e6979d755 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 17 Sep 2026 09:41:05 +0200 Subject: [PATCH] Refresh the budget's stored weight from the phone's weigh-in after each sync Co-Authored-By: Claude Opus 5 (1M context) --- app/pubspec.yaml | 2 +- diet_guard/_cli_sync.py | 7 ++ diet_guard/_phone_weight.py | 97 ++++++++++++++++++ diet_guard/tests/test_cli_sync.py | 16 ++- diet_guard/tests/test_phone_weight.py | 141 ++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 diet_guard/_phone_weight.py create mode 100644 diet_guard/tests/test_phone_weight.py diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 55a2fc6..0cf48ae 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/diet_guard/_cli_sync.py b/diet_guard/_cli_sync.py index e28f387..6b496f6 100644 --- a/diet_guard/_cli_sync.py +++ b/diet_guard/_cli_sync.py @@ -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 diff --git a/diet_guard/_phone_weight.py b/diet_guard/_phone_weight.py new file mode 100644 index 0000000..7e875f4 --- /dev/null +++ b/diet_guard/_phone_weight.py @@ -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}).") diff --git a/diet_guard/tests/test_cli_sync.py b/diet_guard/tests/test_cli_sync.py index 38a11d8..4126914 100644 --- a/diet_guard/tests/test_cli_sync.py +++ b/diet_guard/tests/test_cli_sync.py @@ -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] = [] diff --git a/diet_guard/tests/test_phone_weight.py b/diet_guard/tests/test_phone_weight.py new file mode 100644 index 0000000..918b6f7 --- /dev/null +++ b/diet_guard/tests/test_phone_weight.py @@ -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