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:
Krzysztof Rudnicki 2026-09-17 09:41:05 +02:00
parent 3f6a9b0ebf
commit e9ff530198
5 changed files with 261 additions and 2 deletions

View File

@ -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

View File

@ -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

View 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}).")

View File

@ -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] = []

View 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