mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:47:39 +01:00
9752142c28
43 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 0b658f55f6 |
fix(sync): prune the revision marker too, not just the device directory
Pruning 20 dead devices made the narrow pull *slower* -- ~90ms to ~15s -- which is the opposite of the point, and I caught it only because I measured after running rather than assuming the win. `_device_paths` listed the four data files a device publishes but not its entry under `revs/`. `_sync_refresh._candidate_peers` deliberately enumerates peers from the revision map instead of calling `list_directory` (that call is ~445ms mirrored), so every orphaned marker resurrected a deleted device as a candidate and bought a round trip to discover it was gone. 19 orphans, 19 wasted probes, every tick. The marker is now part of the device's path set, so it is backed up and deleted with everything else, and a regression test pins it by name rather than by count. The 19 already-orphaned markers on the remote were cleaned up separately. Verified after the cleanup: narrow pull ~84ms, full tick 8.9s (was ~27s), gate --check ~105ms, and the log is untouched at 441 entries / 117 foods with peer_revs correctly down to 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz |
|||
| 3a9db8a3ab |
feat(sync): add prune-peers to remove device dirs nothing writes to
Every install mints a uuid and pushes under it, and nothing ever cleans those directories up. This remote holds 25: two live devices and 23 that stopped writing. Each dead one still costs a round trip in the log pull and in each of the three bank syncs -- roughly 400ms per peer per tick -- so they dominate what is left of the background tick. Diagnosis first, since the obvious theory was wrong. These are not the residue of phone reinstalls: seventeen of them are frozen at the *same* instant (2026-08-13T16:45:04) holding near-identical ~400-record copies of one log, which is the signature of repeated fresh-install runs, not organic use. That also means `adb install -r` preserving SharedPreferences was never the issue, so no Android backup change is warranted on this evidence. Dry-run by default, and the safety rails are the point: * `plan_prune` only reads; it is what the default run prints. * A device is prunable only when every record it holds is already held by a device being kept. Against the live remote that is 20 devices and **zero** records lost -- verified before writing the command, not after. * `pc`/`phone`/`desktop` are never proposed: they carry pre-migration history and `SYNC_LEGACY_DEVICE_ID` still names one as this device's own former path. * `--apply` refuses without `--backup-dir`, and every peer's raw bytes are written to disk before a single delete. Nothing has been deleted; this commit only adds the tool. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz |
|||
| 8871408b5b |
perf(cli): publish off-thread so logging a meal returns immediately
`diet-guard log ...` blocked for ~15.5s after every meal. The publish itself has to happen -- until it lands the phone still believes the slot is unlogged and nags for it -- but only ~2.5s of that is the 293KB push the phone needs; the rest is pulling peers' banks, which publishing does not require. The local write is already durable before the publish starts, so the worst case is a late publish, which is the trade `_sync_events` already documents for the event-driven model. Moving it to a worker thread lets the caller print and return at once. Measured: 15.5s -> 2ms before the prompt comes back. The thread is deliberately non-daemon: the CLI's output is already flushed, so the process lingers only as long as the push, and the publish is never killed halfway. A failure still surfaces, via a callback the caller supplies, just after its own output rather than in the middle of it. Deliberately NOT applied to the gate. `diet-guard-gate.service` is `Type=oneshot`, so systemd reaps the unit the moment the main thread exits and a background publish would be killed mid-push. The gate keeps calling `publish_after_log` and waiting, which is correct there -- its publish already runs after the window is gone, where nobody is waiting on it. `conftest._isolate_state` follows the rename to `publish_after_log_detached`; without that every meal-logging test would reach the network through a newly unpatched seam. Verified with `scripts/check_patch_targets.py` and by timing the real command against a temporary data dir, never the live log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz |
|||
| 6258b35f88 |
perf(sync): hoist the device listing and stop the curated-bank 404 storm
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Two independent costs in the background tick, both pure waste.
`list_directory` was called four times per tick -- once by the log pull and
once by each of the three bank syncs. It is the single most expensive call
in a tick at ~445ms mirrored (the GitHub half alone is ~360ms), so that was
~1.3s spent asking the same question four times. It is now listed once in
`run_sync` and passed down.
The curated-bank loop was worse: 17s and 62 requests to fetch *zero* useful
bytes. Firebase returns None for every peer's `food_bank_manual.json`, so
`MirrorSyncClient.get_file_text` treated each miss as "this device has not
migrated yet" and fell through to GitHub, which answers `{}` after ~320ms.
`_primary_only_text` skips that per-peer fallthrough for this one artifact.
Checked before narrowing rather than assumed, because CLAUDE.md requires
both halves of the food bank to sync: reading every peer's curated bank
straight off the mirror found 23 absent, 2 empty, and zero non-empty. No
curated food exists on the mirror alone. The probe is written into the
docstring so it can be re-run before anyone widens this again.
Measured end to end: manual-bank loop 17.0s -> 3.4s, full tick ~27s -> 15.2s,
log/bank/peer state unchanged (441 entries, 117 foods, 22 peers).
`_pull_remote_logs` now returns the revisions it saw instead of mutating a
caller-supplied dict, which keeps it under the argument limit and makes the
data flow visible at the call site.
Deliberately NOT done: revision-skipping the derived food bank. `run_sync`
rebuilds that bank from the merged log immediately before syncing it, so
unlike the log the peer records are not durably held locally -- skipping a
peer would drop its counts from the pushed bank and oscillate under
max-count-wins. Verified empirically: the on-disk bank and a pure log-derived
rebuild are identical (117 foods, 0 differing counts), and the 2 foods peers
know that the local bank lacks are discarded by the existing `if name in
local` filter anyway. The loop is off every interactive path now, so the
remaining cost is background-only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
|
|||
| c7371d7e0d |
perf(gate): unfreeze "Fetch from sync" and bound the interactive timeout
The button ran a full sync tick inline on the Tk thread, so the fullscreen lock was dead for its whole duration -- ~18-27s measured against the live remote, and unbounded in the worst case. Two changes, and the second is what makes the first cheap: * the pull moves to a daemon worker thread, with the result marshalled back through a queue polled on the Tk thread. The worker touches no widget, by construction: Tcl is not thread-safe and that includes `after`. Polling also makes the completion path testable with no thread at all. * the button now runs the *narrow* pull. Its question is identical to the gate's -- "did a peer log this slot?" -- and `_reconcile_after_fetch` reads only `due_slots()`, so the budget and food banks it used to fetch were never consulted here. The full tick still runs after the window closes. Measured 84ms against the live remote. Narrowing also shrinks the write race from tens of seconds to imperceptible: the pull persists its merge, so a meal logged mid-fetch could be overwritten by a snapshot taken before it. A second click while a fetch is in flight is now ignored, which is the reentrancy guard and the race fix in one. That guard started out as a disabled button, which is how it surfaced a latent bug worth recording: the fetch button widget is never stored on `GateWidgets`, so `self._widgets.fetch_button` did not exist and the disable was a silent no-op -- hidden because the suppression tuple included `AttributeError`. Coverage would have reported 100% on a guard that did nothing. Guarding on `_fetch_result` needs no widget at all. Also adds `INTERACTIVE_TIMEOUT_SECONDS = 2.0` for the paths a user waits on. `SYNC_TIMEOUT_SECONDS` only ever reached GitHub: crdt_sync v0.6.0's `firebase_client_for` takes no timeout, so Firebase used its 15s default and one hung session refresh stalled the lock window -- the journal has five such refresh failures in five days. Verified to fail closed to the local decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz |
|||
| 0173f4c56c |
Queue behind stronger lockers; bump gatelock to v0.7.1
Diet_guard was one of the two lockers missing gatelock's wait_for_turn fix -- confirmed live on 2026-08-21, it held the global grab for ~3 minutes against a newly-arriving, higher-ranked screen_locker, which just retried and logged instead of making progress. Now queues behind any stronger claim before arming. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk |
|||
| b53ac92c59 |
perf(gate): narrow the pre-lock pull and defer the heavy imports
The gate's pre-lock refresh ran a *full* sync tick -- peer logs, budget,
both food banks, a rebuild and a push -- in front of the fullscreen lock.
Measured against the live remote: ~18-27s, of which the curated-bank loop
alone was 17s spent fetching nothing (absent on Firebase for every peer, so
each of ~21 peers fell through to a GitHub round trip returning `{}`).
It was also failing: five `sync unavailable (network error trying to refresh
the session)` entries in the last five days, one of them a meal that logged
locally but never published.
The gate has one question -- "has a peer already logged this slot?" -- and
only the peer logs can answer it. `_sync_refresh` does that and nothing
else: one bulk revision-map read, a fetch of only the peers whose revision
moved, merge, re-sign, write. No budget, no banks, no push. The full tick
still runs, after the window closes, so nothing is permanently skipped --
the exhaustive pass just moves off the path the user waits on.
Three state invariants have their own regression tests, because each is
silent when broken: `pushed_rev` survives verbatim (this pass never pushes,
and overwriting it would make the device stop publishing forever),
`peer_revs` is merged rather than replaced (only changed peers are visited),
and the log is written before the state.
Separately, `gate --check` was importing an HTTP stack and a GUI it never
uses. `requests` (~78ms) arrived via `_cli_sync` -> `crdt_sync` and via
`_resolve` -> `_estimator_off`; both are now resolved through module-level
`__getattr__` (PEP 562) and `import_module`, which keeps every existing
`patch.object` target and needs no lint suppression.
Measured: `gate --check` 216ms -> ~110ms; narrow pull 92ms against the live
remote. Accepted trade, documented in `_sync_refresh`: a budget edited on
the phone is not visible in the lock dashboard until the post-window tick.
It cannot affect the lock decision, which is why it is safe to defer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
|
|||
| c0564c05a2 |
Let the PC gate set the eating window and meal count
The second edit surface, so the schedule is editable from either device the way the budget already is. A row on the History tab beside the budget row, with the same Edit/Save toggle: first hour, last hour, meals per day, and the derived checkpoint times underneath so the effect of a change is visible before it is saved. Validated rather than clamped. MealSchedule.normalized() would happily turn "20 to 8" into a two-meal day ending at 09:00, which is a sensible defence against a peer's corrupt sync data and a terrible response to a typo at the keyboard. Each rejection says what is wrong and leaves the row in edit mode so the value can be corrected instead of silently becoming a different schedule. The narrow-window case has its own message, because that one is genuinely non-obvious: six meals between 08:00 and 11:00 would round two checkpoints onto the same hour, and a duplicate slot silently drops one. Three structural notes: - The row's widgets are their own module. _gatelock_calendar_widgets.py was at 216 of the 250-line cap and is now 233; adding the row inline would have breached it. - The five new StringVars are grouped in a ScheduleVars dataclass rather than flattened onto CalendarVars, which pylint flagged at 11 attributes. Grouping two unrelated concerns separately is the better shape anyway. - _gate_fixtures.py's _GATE_TK_MODULES is a hand-listed patch target, so both new modules are registered there. Left out, they build real tk widgets under a fake parent -- which is how it failed the first time, loudly, rather than silently opening a window. 785 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM |
|||
| 24dbffc432 |
Sync the meal schedule as sched: fields on the budget record
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.
The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.
Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
{"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.
Two guards worth naming:
- A device with no history contributes no `sched:` fields at all, so an unset
default can never outrank a peer's real schedule; and an empty merge result
is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
Log and would otherwise drop the schedule fields too. Its budget value
carries an epoch stamp, so it loses every LWW race against a real edit.
sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.
772 Python tests at 100% branch coverage, 619 Dart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
|
|||
| 2bdb7925a8 |
Read the meal schedule from a forward-only history (Python)
Wires the Python side to the configurable schedule. No edit surface yet, so nothing writes a schedule and current_schedule() returns the default -- the derived hours are still 08/12/16/20 and the cutoff is still 22:00. Verified by running it: before any edit the slots are unchanged, after recording 08-20 x5 they become 08:00, 11:00, 14:00, 17:00, 20:00, and a 2020 day still resolves to the four-meal schedule. _slots.py now takes the schedule as a *required* argument rather than reading module constants. Required, not defaulted, on purpose: a default would let a call site missed in this refactor keep deriving the old fixed hours on one device, and a slot one device offers while the other does not is a checkpoint that can never be satisfied. Making it required let mypy enumerate the nine call sites instead of trusting a grep. The history is forward-only and seeds the default at the epoch *before* recording today's value, so switching to five meals leaves past days judged against the four-meal schedule they actually ran under. Same shape and same reasoning as _budget_history.py; it will ride the same CRDT record. Two details worth naming: - MEAL_SCHEDULE_FILE lands in DATA_DIR, so conftest's _isolate_state redirect is patched in this same commit rather than a later one. Confirmed with a canary that the redirect is live and that ~/.local/share/diet_guard/ has no .meal_schedule after a full run. - _mcp_read imports _budget.current_schedule already, so the meal-schedule lookup is aliased rather than shadowing it. _gatelock._pending_slots is spelled `is None` rather than `or` now. The two are in fact equivalent -- if 0 is a slot it is necessarily the first slot, checked exhaustively -- but the intent should not rest on that coincidence. 761 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM |
|||
| 83c3030b55 |
Derive meal slots from an eating window and a meal count
First step of the configurable meal schedule: the pure arithmetic, in both languages, with nothing wired to it yet. MealSchedule(8, 20, 5) gives 08, 11, 14, 17, 20; the default MealSchedule(8, 20, 4) reproduces the hours that are currently hardcoded, so nothing changes behaviour until a user picks something else. Two properties are load-bearing and both are tested rather than asserted in prose: Integer arithmetic only, no floats and no round(). Python's round() is banker's (round(2.5) == 2) and Dart's is half-away-from-zero (2.5.round() == 3), so a floating-point derivation would silently disagree across devices -- and a slot one device offers but the other does not is a checkpoint that can never be satisfied, i.e. a permanent lock. The shared vector table plus an identical 6912-case sweep in both suites is what catches that class of bug before it reaches a device. Counts are clamped to the window width rather than rejected. A narrow window (08-12 across 6 meals) would otherwise repeat an hour -- 8, 9, 10, 10, 11, 12 -- and slot hours are used as set members, map keys and notification ids, so a repeat silently drops a checkpoint. Clamping also means the two languages cannot disagree about which inputs are errors. The enforcement cutoff is now derived as last + 2h (clamped to midnight) rather than sharing the constant that also bounded the slot range. The clamp matters: 23:00 + 2 = 25 would make `hour < cutoff` vacuously true and the window would never close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM |
|||
| 5125a98980 |
Register the _hmac_key fixture; guard the redirect so it fails locally
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
CI's Tests job went red on six signing assertions that pass on this machine.
Root cause predates this sweep: commit
|
|||
| 3e51e614b1 |
Split the last seven Python files; extend the redirect guard to all five paths
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| df5e1272f1 |
Make _FakeTclError subclass the real tkinter.TclError
Found while auditing the `_NO_WIDGETS` exemption added two commits ago: the comment claimed `_gatelock_widgetgroups` binds real `tk` only for its `contextlib.suppress(tk.TclError)` dead-copy guards. That part is true -- it constructs no widgets -- but the consequence was wrong. `_FakeTclError` subclassed `Exception`, not `tkinter.TclError`, so a fake-tk gate test raising it sailed straight through those suppressions. The "a monitor vanished mid-update" paths were therefore only ever exercised against the real error; with the fake they would have raised through the gate. Reproduced directly before fixing: a `_FAKE_TK.TclError` escaped `WidgetGroup.config` both with and without the module patched, so adding the module to the patch set would not have fixed it. `tkinter.TclError` subclasses `Exception`, so inheriting keeps every existing `except _FakeTclError` working and makes both spellings catchable. `test_fake_tclerror_is_catchable_as_the_real_one` pins it and asserts the live copy still gets configured; reverting the base class fails it. Verified: 707 Python tests / 100% branch coverage, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| af461d3157 |
Split _state.py and _tk_fakes.py; make the food-log redirect fail closed
Eighth batch of the 250-line sweep (over-cap count 28 -> 25). _state.py 366 -> 197 (+ _state_today.py, _state_sync.py) test_state.py 400 -> 221 (+ test_state_today.py, test_state_sync.py) _tk_fakes.py 368 -> 167 (+ _tk_fakes_containers.py, _tk_fakes_canvas.py) _state.py is the file conftest patches FOOD_LOG_FILE on, so the split is constrained rather than free: `FOOD_LOG_FILE` and the two functions that open it stay put, and the derived read model (_state_today) and the sync/undo helpers (_state_sync) reach the file only through _state's private _read_raw_log/_write_log. Callers were rewired to import from the new modules directly rather than via a circular re-export. That constraint is now enforced instead of merely documented. Moving a reader of FOOD_LOG_FILE into a sibling would leave conftest's patch pointing at a name nothing reads: check_patch_targets.py still exits 0 (the path resolves) and the runtime is unchanged (a file write, not a network call), so the suite would go green while writing to the real ~/.local/share/diet_guard and the next sync tick would publish it. test_state_redirect.py asserts both halves -- only _constants and _state may name the constant, and the redirect demonstrably moves a real write off the live path. Verified it fails closed by adding a FOOD_LOG_FILE reader to _state_today: it named that module exactly. _tk_fakes.py keeps _FAKE_TK/_FAKE_TTK and gains an explicit __all__, since the re-exports are load-bearing and ruff would otherwise strip them as unused. The identity canary from the previous commit covers the split: breaking one entry in the assembly (Canvas=object) fails 8 gate tests loudly rather than silently building real widgets. Verified: 706 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| 8f2d1011c7 |
Split the three gate test files; assert fake_tk really swaps tkinter
Seventh batch of the 250-line sweep (over-cap count 31 -> 28). The test files mirror the source split from the previous commit one-for-one: test_gatelock_mealflow.py 379 -> 227 (+ test_gatelock_dashboard.py) test_gatelock_fanout.py 335 -> 128 (+ test_gatelock_groups.py) test_gatelock.py 331 -> 224 (+ test_gatelock_form.py) `test_gatelock_dashboard.py` follows `_gatelock_dashboard`/the slot walk and manual sync; `test_gatelock_groups.py` takes the group primitives that `_gatelock_widgetgroups` now owns; `test_gatelock_form.py` takes the form and basis/amount cases. Also closes the remaining half of the fake-tk fail-open. The previous commit's canary proved every widget-building module is *named* in `_GATE_TK_MODULES`; it could not prove the patch installs something that is not real tkinter. If `_FAKE_TK` were imported from the wrong place after the coming `_tk_fakes.py` split, every module would still be "patched" and the suite would still pass while building real widgets. `test_fake_tk_actually_replaces_real_tkinter` now asserts `module.tk is _FAKE_TK` for every entry *inside* the `fake_tk()` context, plus both `ttk` fakes and restoration afterwards. Verified it fails closed by making one module skip the patch: it reported exactly that module. Verified: 704 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical (gate timer paused for the batch). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| e8f0ecaae1 |
Split the six Python gate files; guard the fake-tk patch set
Sixth batch of the 250-line sweep. All six gate sources named for this batch are now under cap, plus _gate_layout_probe.py, which this batch's import rewiring pulled into the changeset (over-cap count 38 -> 31). _gatelock_ui.py 477 -> 174 (palette/leaf-helper hub) _gate_layout_probe.py 346 -> 196 _gatelock_groups.py 308 -> 137 _gatelock_calendar_ui 295 -> 77 _gatelock_calendar.py 286 -> 210 _gatelock.py 263 -> 238 _gatelock_mealflow.py 259 -> 207 New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly), _gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types (bundles/palette), _gatelock_calendar_widgets (History builders), _gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile (the stdlib-only flock), _gate_focus_probe and _gate_measurement. The load-bearing part is the test seam, not the line counts. `_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and that list fails OPEN: a widget builder split into a new sibling that nobody adds to it builds *real* Tk widgets against fake parents. Neither existing detector sees that -- check_patch_targets.py only validates paths that still resolve, and the ~8s runtime only catches network calls. It bit twice during this split (real StringVars from make_calendar_vars returned MagicMocks instead of the asserted text), so test_gate_tk_modules.py now asserts the invariant directly: every module binding `tk` is in the patch set or named in _NO_WIDGETS with a reason. It caught a pre-existing gap too -- _gatelock_support was never in the set. Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time indirection so the keyboard test's patch of `_escape_text_tab_trap` still bites, and the flock pair moved with its conftest patch target updated in the same edit (that one fails closed). build_layout is imported from _gatelock_layout directly rather than re-exported lazily, which avoids both a cycle and an import-outside-toplevel suppression. Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests under xvfb, 555 Flutter tests (unchanged), flutter analyze clean, check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and `gate --demo` renders both tabs with live dashboard data. ~/.local/share/diet_guard byte-identical across the batch (gate timer paused, restored after). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| 38d66a8492 |
Make PC sync event-driven; retire the 15-min timer
The periodic diet-guard-sync.timer paid a network round-trip every ~15 min to approximate the two moments the shared log actually changes on this device. Sync now fires on those events instead: * before a lock -- already present in _cli_gate._should_lock, which pulls only when a lock is otherwise due, so a phone-logged meal clears the lock and a not-due tick never touches the network; * after a meal is logged here -- new _sync_events.publish_after_log, wired into all three write paths (_cli_log, _cli_gate, _mcp). This is what stops the PHONE nagging for a slot already logged on the PC, the mirror image of the rule that keeps the phone's own tick unconditional. The gate's publish sits in cmd_gate AFTER MealGate.run() returns, never in the unlock handler: that runs on the Tk thread with the window still up, so a hanging network call would keep the user locked in behind a successful log. It also covers the "Synced from another device" unlock, which hooking log_meal would miss. Accepted trade: a PC that is on but neither logging nor locking no longer pulls, so an inbound phone edit lands at the next log or lock rather than within 15 min. Bounded by slot spacing -- an unlogged day raises a lock. install.sh now disables and removes the installed timer (guarded, since a fresh machine has no such unit and `disable` would abort under `set -euo pipefail`); the .service stays for manual one-off ticks. Every test that logs a meal would otherwise reach the network through the new call sites, so conftest patches publish_after_log at each of the three call sites -- not on _sync_events, so test_sync_events still exercises the real helper. Suite stays at ~8s, which is the actual leak detector. Two files had to be split to commit at all, since file-length passes filenames and both were already over the 250-line cap: _cli.py 466 -> 207, + _cli_args.py, _cli_init.py, _cli_log.py _mcp.py 370 -> 194, + _mcp_server.py (singleton + annotations), _mcp_read.py Their conftest/test patch targets moved in the same commit; a stale target redirects nothing and fails open. Verified: 701 python tests at 100% branch coverage, 555 flutter tests, `python -m diet_guard sync` -> 402 entries/exit 0, `gate --check` -> exit 0, all five MCP tools still register, live ~/.local/share/diet_guard byte-identical. Over-cap files: 44 -> 42. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| 1e201acbdc |
Split _sync.py and its tests; extract the gate fixtures from conftest
_sync.py 498 -> 165, into siblings that each own one concern:
_sync_client.py picking a backend and building the remote store
_sync_banks.py pull/merge/push for the food bank and the budget
_sync_paths.py the kuhyx/syncs wire layout, shared by both
_sync_errors.py SyncError, so _sync and _sync_client need not import
each other
The patch targets moved with the code they guard, which is the whole risk of
this commit. unittest.mock.patch and monkeypatch.setattr both bind to a
*module object*, so a patch left pointing at _sync after CONFIG_FILE and
SYNC_TOKEN_FILE moved to _sync_client redirects nothing. That is not
hypothetical here: the first run of the suite took 9m18s instead of 7s and
raised 401s, because tests were reaching the real GitHub API with a fake
token. Repointed:
conftest SYNC_TOKEN_FILE, CONFIG_FILE -> _sync_client
test_sync* GitHubSyncClient (30 patch sites) -> _sync_client
test_backend mirror/firebase_client_for -> _sync_client
The repo's own _test_guard caught the same leak independently, which is the
behaviour worth keeping: two guards, both fail-closed.
test_sync.py 482 -> 205, splitting the budget and pull/revision classes into
siblings and the shared fakes into _sync_fakes.py.
conftest.py 284 -> 155, moving the Tk/gatelock fixtures to _gate_fixtures.py.
Importing them back is what registers them (pytest_plugins is an error in a
non-root conftest), and they are named in __all__ because ruff's autofix
otherwise deletes the "unused" import and silently disables Tk blocking --
it did exactly that once here. Verified afterwards with a canary asserting
_gatelock.tk is still a MagicMock, not merely that the suite passes.
Verified: 692 tests / 100% coverage unchanged; ~/.local/share/diet_guard
byte-identical before and after the run; cd /tmp && python3 -m diet_guard sync
-> 402 entries, exit 0. Gate 47 -> 44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
|
|||
| b621ccaed0 |
Use gatelock's shared button and WidgetGroup
Phase 2 consumer migration. diet-guard was the donor for make_button, so it
is one of the two proof consumers the extraction is validated against.
- _gatelock_buttons.py is deleted; its make_button now lives in gatelock,
where the variant API (text colour picked *from* the fill) and the
<Return>/<KP_Enter> bindings serve all four gates instead of one. Call
sites pass _COLORS explicitly, since a shared widget cannot read this
app's module-level palette. Pixel-identical: BODY/LABEL were -16/-14 and
LockConfig.font("body"/"label") yields the same negative (pixel) sizes.
- WidgetGroup now subclasses gatelock's, keeping the list[_W] constructor and
re-narrowing `first` to the widget type. The specialized groups (EntryGroup
over a shared StringVar, TextGroup, ListboxGroup, MacroGroup) are unchanged
behaviour and stay -- gatelock has no equivalent for them.
- bind() keeps *replacing* semantics, inverted from gatelock's additive
default and now documented + tested. _wire_events runs once per
build_surface and again on rebuild, so an additive bind would stack a
second <Return> handler on every surviving widget and log the meal twice.
- conftest swaps gatelock.widgets into the fake-tk module list in the same
edit that removes _gatelock_buttons, since that list fails the whole suite
if it drifts in either direction.
All three gatelock pins move together to v0.5.0 -- pyproject, requirements.txt
and the pylint hook's additional_dependencies. The config comment already
warned that a lagging hook pin lints against a gatelock without the API the
code calls; that is exactly what it did before this bump.
The "<Return> activates a button" test moves to gatelock with the button, so
it runs against real Tk rather than a fake widget that can only report the
binding it was handed.
Verified: 692 tests, 100% branch coverage against the *installed* gatelock
0.5.0 (not a PYTHONPATH shim); the gate builds under xvfb and <Return> and
<KP_Enter> both fire a real button's command, with the accent focus ring
(#B8862E, 2px) instead of Tk's invisible 1px black.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
|
|||
| 372e8a19f6 |
Remove the Snack chip from the phone's slot selector
The chip was the only control that logged a meal against no slot at all (`slot: null`). Removing it leaves the row with just the four hour chips, so every logged meal now satisfies a real slot. Storage keeps its null-slot support on purpose. Entries already on disk and arriving over sync carry no `slot` key, and the `slot != null` filters in log_storage_service.loggedSlotsToday / _state.logged_slots_today are what stop them from retroactively satisfying meal slots -- so both are documented as load-bearing rather than deleted as dead code. Also corrects the MCP tool's docstring, which claimed its slot argument "may be None early in the day"; it falls back to slot_for_log, which clamps to a real hour and never returns None. Adds the widget test the chip row never had -- being untested is why the chip read as unused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPheLM86xUN9TGf34Ygtfz |
|||
| 458f10515b |
Make the phone publish on every tick, and fail closed on Firebase errors
Some checks failed
Meals logged on the phone stopped reaching the PC, so the gate kept locking for slots that were already logged and the only way through was to spam "nothing". The PC's fetch was never broken: it was faithfully pulling a Firebase that did not contain the phone's meals. The phone had no working publish backstop on either transport: - `checkAndNotify` gated its sync on `due.isNotEmpty`. Logging every meal promptly means nothing is ever due, so the ~15-min periodic task never published. It was a notification job that happened to sync, not a sync timer. It now syncs on every tick it is asked to, then computes due slots from the merged result, so a meal pulled from another device suppresses a nag instead of triggering one. - `backgroundSyncPush` never called `initSyncDeviceId()`. A fresh isolate has its own static state, so `currentSyncDeviceId` fell back to the compile-time 'phone' constant and published to `devices/phone/` rather than the persisted uuid -- which then makes the next foreground push see `unchanged` and skip writing the uuid directory entirely. Separately, on the "Fetch from sync" path itself: `FirebaseSyncError` and `FirebaseAuthError` are siblings of `GitHubSyncError` under `RemoteSyncError`, not subclasses, so every per-request failure of the *primary* backend escaped `pull_shared_log`'s catch tuple and raised a traceback out of a helper documented to fail closed with a reason string. `ConfigError` needed the same treatment by a different route: it subclasses `Exception` directly, so it is outside `RemoteSyncError` too, and is now translated at `_client_for_run`. The PAT is no longer mandatory either. Firebase is primary, but `_read_token` ran before the client was ever built, so a Firebase-only machine could not sync at all -- the commits that fixed this touched only the Dart side. A stalled sync was invisible: the config gates returned silently and `openFirebase` swallowed every error. `dart:developer`'s `log()` does not reach logcat on a release build (verified on device), so logging alone would not have surfaced it. `SyncHealth` records each tick's outcome and the log screen shows a banner when publishing has actually stopped. Also make the real-Tk probes bypass the window manager. A tiling WM retiles a managed toplevel instead of honouring the requested geometry, so `winfo_height` reported 0/1px and the layout and focus-ring assertions failed on noise rather than on any regression -- 8 failures on the dev machine that only passed under Xvfb. `overrideredirect` makes the measurement deterministic with or without a WM; the probes still detect a genuine overflow at 1366x300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RT8X32S9yDLW1Xf1PDq8z7 |
|||
| e23f7ac3b4 |
Push under a persisted per-install uuid, not a fixed role id
diet_guard pushed as "pc" (Python), "phone" (Android) and "desktop" (web). A role constant collides the moment a second machine takes the same role, and after a reinstall the new install inherits the previous one's CRDT identity -- the same reasoning home_inventory already records for choosing a uuid. Both halves now mint a uuid once and persist it: the PC in ~/.local/share/diet_guard/.device_id, the app under SharedPreferences 'crdt.nodeId' (the key todo and home_inventory already use). The old constant survives as the legacy id and is passed to sync_log / syncLog, so devices/<legacy>/ is skipped as this device's own. Without that every tick would re-download and re-merge this device's own pre-migration log as though a peer had written it -- idempotent under LWW, but hundreds of KB, 96 times a day. It drops to None once that path is reclaimed; a test covers both states, including a negative control proving the skip is the legacy id's doing. All four documents move together (food_log, food_bank, food_bank_manual, budget), and the hist:<date> fields riding on the budget record are untouched -- merge_record unions field names, so they relay through unchanged. The resolver lives in its own module (_device.py / sync_device_id.dart) because _sync_merge needs it for HLC stamping too and importing _sync there would be a cycle. Dart resolves it asynchronously at startup, before LogStorageService.init(); currentSyncDeviceId falls back to the compile-time role constant until then, so a too-early Hlc stamp gets a valid id rather than crashing. conftest redirects the new id file in the same change that introduces it -- without that a test mints a uuid into the real state dir and this machine's live sync identity is decided by whichever test ran first. Verified by checking ~/.local/share/diet_guard/ before and after: untouched. Verified live, not just against mocks: the sync timer ran mid-change and migrated this machine to f8f46da6. All 358 food_log records and all 100 food_bank records are present under the new path (0 missing vs devices/pc/), and the new log's HLCs carry only its own id -- confirming it did not re-merge the old path as a peer. Python: 687 tests, 100% branch coverage. App: 518 tests, analyzer clean. Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0 (pyproject, requirements.txt, .pre-commit-config.yaml, pubspec), and the new library is installed into /usr/bin/python's user site-packages so the production timer does not ImportError on its next tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP |
|||
| e07284d3e1 |
Show weekly and monthly average intake on both devices
The calendar already said which individual days went over budget, but not whether the week as a whole did. A run of small overages reads as four harmless yellow squares; averaged, it is the thing that actually moves the needle. Three rules are load-bearing, and are documented in CLAUDE.md because getting any of them wrong silently changes what "under budget" means: - The denominator is logged days, not elapsed days. An unlogged day is not a zero-kcal day, and averaging gaps in as zeros would make a badly-tracked week look like the best week of the year. - The yardstick is the mean of the per-day budgets over those same days, resolved through BudgetSchedule -- never today's budget, which would retroactively reclassify past weeks on every budget edit. - Today is excluded. A half-logged today drags the mean far enough to flip the band, so every period ends at yesterday. Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by import rather than by copy, so a period and its days can never disagree about what "over" means; that constant loses its underscore to say so. The MCP get_averages tool returns the average and the band but drops the average budget, keeping the module's existing invariant that the budget number never leaves this machine. The CLI, the gate and the phone all show both, since it is hidden from the network, not from the user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd |
|||
| da0b54d886 |
Sync meals and the food bank through Firebase, mirroring to GitHub
The largest consumer moves last on purpose: diet_guard was 71% of all sync traffic, and both its surfaces (the PC timer and the phone app) share one prefix, so they move together. Firebase becomes the primary backend when it is set up, with GitHub kept as a mirror so a device that has not moved yet still converges. An unconfigured device keeps syncing exactly as before, which is also the rollback. Revision tracking is new here. The PC hand-rolled its own pull-merge-push instead of calling sync_log, so it had none: every tick re-downloaded every peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its own regardless of change. It now reads one small revision map first, skips any peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync (log, budget, food bank, manual bank) takes the client as a parameter, so one swap covers all four. The app could not have reached Firebase at all: nothing called saveAccount(), so loadAccount() always returned null and openFirebase() always returned null. Settings now has a Firebase section that stores the account in the OS keystore and signs in immediately, so a typo surfaces on the screen where it can be fixed rather than as a silently failed background sync hours later. The email and password never touch the repo -- it is public. Platform reaches now degrade instead of failing: an unavailable keystore or application-support directory costs one tick of extra traffic, where before it would have taken sync down entirely. Caught by `on Object` rather than `on Exception`, because an uninitialised binding raises an Error. Verified end to end against the real database, not just in tests: a live PC sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank records into Firebase; a second unchanged tick issued zero pushes; and the Dart client signed in and read back all 339 meals and 97 foods, then completed a push / publish-revision / suppress-no-op round trip under a throwaway prefix. diet_guard: 618 tests at 100% branch coverage. diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the extra one is this device's revision, published after the log so a peer can never cache "seen rev X" against a log it never received. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc |
|||
| 58e6731705 |
Port the MCP server to mcp 2.x, and say what each tool is allowed to do
mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its name, signature and returned shape. Verified against a clean 2.0.0 venv: all four tools list, and the suite passes at 100% coverage, 609 tests. Two things the port buys, neither of which existed in 1.x: * Tool annotations, which matter more here than in the sibling repos because this server has a tool that writes. The three readers are marked read-only, idempotent and closed-world; log_meal is marked not-read-only but explicitly NOT destructive -- it appends and never rewrites or deletes -- not idempotent, since a second confirmed call logs a second meal, and open-world, because it reaches Open Food Facts when macros are not given. A client can now tell the difference without reading the docstring. * Structured output: 2.x derives an output schema from the return types and sends structured content alongside the text. No code change -- it falls out of the type hints that were already there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF |
|||
| 4a5e350baf |
Fit the meal gate on 1366x768 with a full day logged, and stop the auto-scroll
gatelock 0.4.0 (pinned here) stops the viewport following the app's own focus_set(): on 2026-08-03 that moved the screen locker's lock screen to mid-content and back with nobody touching the keyboard, and this gate uses the same viewport. Scrolling now happens only on the user's own keypress, wheel or click. That makes fitting the screen the gate's own responsibility rather than something a scrollbar covers for, and with a day of meals logged this one did not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the title is one step down the scale, and the gaps around the description, suggestions, macros and footer are one step tighter) for 718px, with room for the dashboard to keep growing. Its layout test now requires the gate to *fit* rather than merely be reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no machine runs it, this gate cannot reach it without dropping content, and a target nobody uses is a constraint that only ever costs. The probe presses Tab before moving focus, because a bare focus_set() no longer scrolls -- it would have been testing a path no user can take. The Tk fakes learned pack_forget(), which the viewport calls to take the scrollbar down whenever the content fits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF |
|||
| 1702685afd |
Make the meal gate usable without a pointer on a 1366x768 screen
The Log Meal tab did not fit this laptop, and had not for some time. Measured
under a real 1366x768 X server: it required 866px inside a 739px content pane
with every field EMPTY, and 938px with a day of meals logged.
Because the content frame was place()-centred it was sheared at BOTH edges at
once -- the "Diet Gate" title and the slot header (which meal you are being
asked to log) off the top, the calorie headline and the whole dashboard off the
bottom -- with no scrollbar anywhere in the package and nothing to indicate
content was missing. This is a hard lock: overrideredirect, a global grab, VT
switching disabled. Unreachable content is unrecoverable.
Two independent causes, both needed fixing:
- Every font size was handed to Tk as *points*. Tk encodes the unit in the
SIGN of the size, and _gatelock_typography carried a comment asserting the
opposite ("Tk has no separate px/pt distinction"). At ~100 DPI that made
every string ~30% oversized. Negative sizes now; the docstring records why.
- There was no scroll container. build_layout now uses gatelock's
ScrollableSurface, which top-anchors, scrolls by keyboard, and scrolls to
follow focus.
Font sizes alone were not enough: they take the layout to 702px, still over the
571px pane at 1024x600. Both fixes are load-bearing.
The grams/items selector was mouse-only. tk.OptionMenu's Menubutton defaults to
takefocus=0, so ::tk::FocusOK rejects it and it never entered the tab ring --
verified by walking the real ring, which ran Entry -> Text -> Listbox -> Button
-> TNotebook and skipped it entirely. Switching units rescales the whole entry,
so a pointerless user could not correct "250 grams" to "3 items" inside a lock
they cannot leave without logging a meal. It is now two Radiobuttons, which are
in the ring by default and take both Space and Return -- and which post nothing,
unlike a menu, whose grab-stealing is why screen-locker bans OptionMenu on lock
surfaces outright.
Also: the focus ring was invisible (Tk's default is 1px BLACK on #211D1B) and
_build_desc actively inverted it by setting highlightbackground, the UNfocused
ring, so the box outlined while idle and went black the moment it took focus.
Enter now activates buttons (X11 Tk binds only <space>). Text widgets no longer
trap Tab. notebook.enable_traversal() is called, so Ctrl+Tab and Ctrl+PageDown
work. wraplength drops 900 -> 640, the line-length cap, which also stops a
label forcing a 900px-wide layout onto a 1024px screen.
The Flutter app had two controls with no keyboard route at all. Its desktop
target is the web build in a Chrome --app window, so a touch gesture is not a
fallback there, it is the only interaction:
- Deleting a history entry was bound solely to onLongPress. Long-press has no
keyboard equivalent whatsoever. It now has a focusable IconButton; the
long-press stays as a redundant pointer shortcut.
- Calendar day cells were a bare GestureDetector, so navigating to
date-filtered history was pointer-only and invisible to a screen reader.
Now Semantics(button: true) over an InkWell.
Plus: the filter sheet built a TextEditingController inside build() with the
caret forced to end-of-text, so every keystroke discarded it and silently undid
mid-string Home/End/arrow editing. And nothing capped width, so on the 1366px
desktop window prose ran ~180 characters per line (twice the readable limit)
and a four-digit kcal field spanned ~1334px. AppWidth adds those caps.
Tests: the render check is a failing test, not a script -- ruff runs with
--unsafe-fixes, whose T201 fix DELETES print statements, which would silently
turn a diagnostic into a no-op. test_gate_layout_fits.py was verified red on
the pre-fix code ("content 866 in viewport 768 -> NO SCROLL VIEWPORT ->
UNREACHABLE CONTENT") and green after. The Flutter suite gains its first
keyboard-reachability assertions; it previously had ~109 tester.tap calls and
zero checks that anything was focusable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| f7f06aee53 |
Remove unused features; fix reminder false alarms; share all state
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 84bd550331 |
Migrate diet_guard to gatelock v0.2.0 per-output locking
The gate is laid out once per live output instead of once on the primary, and claims the screen through an Arbiter at RANK_DIET_GUARD so the wake alarm and the workout locker can both outrank it. Three kinds of state needed three different answers. The display fields already fanned out for free -- GateVars holds root-mastered StringVars bound to labels. The typed-in fields did not: amount, per, meal name and the four macro cells were bare tk.Entry widgets, so N copies would have been N independent forms. They are variable-backed now, which is what makes logging work from whichever screen the user is actually looking at. The description box and the suggestion list cannot be shared at all -- tk.Text and tk.Listbox have no variable -- so those genuinely diverge and reads answer from the copy the user touched. The History tab is per-surface too: its grid, budget field and edit button are driven across every monitor, with the budget likewise variable-backed so the value cannot differ between screens mid-edit. Laying the gate out on the primary monitor only was considered and rejected: not having to hunt for the screen the form landed on is the entire point of per-output locking. Tests: the suite hung rather than failed on first run -- diet_guard patches its own `tk`, but LockWindow builds Toplevels through gatelock's, and real tkinter over a MagicMock root recurses without bound. `_hermetic_gatelock` closes that and redirects the runtime dir so no test can stand a production locker down. Every other test runs single-output, where fan-out is unobservable, so test_gatelock_fanout runs on two; each assertion was checked by cropping the fan-out loops to the first surface and confirming it goes red. _gatelock_ui and _gatelock_calendar were each split in two to stay under the 500-line limit, and GateVars' typed-in fields moved into a nested GateEntryVars rather than raising pylint's attribute cap. 470 tests, 100% branch coverage, pre-commit clean, no lint suppressions. |
|||
| 957769ff0e |
Add budget-adherence calendar, streaks, and freely-editable synced budget
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged day coloring, logging/adherence streaks, year-to-date tally), and tapping a day drills into that day's logged entries. This requires the daily budget to become a normal, freely-editable value synced between devices like the food log — replaces the previous chattr +i seal (deliberately, per user decision) with plain last-edit-wins sync via a new budget.json alongside food_log.json. The budget field defaults to 2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on both the PC gate's History tab and the phone app's Calendar screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA |
|||
| 8a9c7713b9 |
Review fixes: specific sync exceptions, clearer names
Address code-review feedback on the sync-before-lock change: - pull_shared_log now catches (SyncError, GitHubSyncError, OSError) instead of a blanket `except Exception`. crdt_sync wraps every requests transport error in GitHubSyncError, so those three are the whole realistic failure surface; the gate still fails closed on all of them, and a genuine bug now surfaces instead of being hidden as "sync unavailable". Drops the broad-exception pylint disable and the per-file BLE001 ignore in pyproject. - Rename _cli_gate._is_due -> _should_lock: too easily confused with the pure local predicate gate_is_due it wraps. The new name says what it decides. - Rename the list `satisfied` -> `satisfied_slots` in _reconcile_after_fetch; it's a list of slots, not a boolean. No behaviour change; 100% coverage retained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd |
|||
| be94b4e147 |
Sync before locking the gate + manual "Fetch from sync" button
The PC gate forced a re-log when a meal was already logged on the phone but the 15-min sync had not yet propagated it. Two ways to close that gap: - The gate now pulls the shared log right before it would lock -- and only when a lock is otherwise due, so the network cost is paid on the rare tick that matters. A meal already pushed by the phone then unlocks without re-entry. Fails closed: any sync error (no token, network, disk write-back) falls through to the local lock decision, never skips it. - A "Fetch from sync" button on the lock screen runs the same pull on manual request: it auto-unlocks when the pull satisfies every pending slot, advances to the next meal on a partial pull, or reports no change. Disabled in --demo so the sandbox never triggers a real sync/push. - Both paths share one fail-closed wrapper, _sync.pull_shared_log(), which also keeps the single broad-except in one place. - _write_log is now atomic (temp file + Path.replace): the gate and the 15-min sync timer can now both write the log, so a torn read must be impossible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd |
|||
| 59d1c29dd2 |
Migrate sync to shared crdt_sync library (Python + Dart)
Replace the bespoke GitHub-Gist sync with the shared crdt-sync / crdt_sync_dart libraries so diet-guard uses the same CRDT transport as the other apps. - Python: delete _sync_github.py (+ its test); rewrite _sync.py, _cli_sync.py, _sync_merge.py against crdt-sync. Add the crdt-sync git dependency (pyproject + pylint deps) and S107 to the test ignore list. - Dart: rewrite app sync_service.dart / sync_merge.dart against crdt_sync_dart (new git dep + crypto); update log_meal and settings screens and their tests. Verification: pytest 347 passed at 100% coverage; flutter analyze clean; flutter test 306 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM |
|||
| 06999274d7 |
Add MCP server exposing diet status + a gated log_meal
Expose diet_guard's read surface and one gated write action as typed MCP tools (diet_guard._mcp) so Claude Code and its subagents can query today's intake and -- with explicit confirmation -- log a meal without shelling out to the interactive gate CLI. - _mcp.py: FastMCP stdio server. Read tools get_status, list_today, get_slots expose today's CONSUMED kcal/macros and the qualitative consumption band only -- never the raw daily budget, remaining number, body weight, or protein target (those would back out the deliberately hidden budget). Gated write log_meal previews unless confirm=True and degrades gracefully on write failure. Nutrition is grouped into a Macros model (kcal + optional protein/carbs/fat) to keep the tool under the arg-count limits with no lint suppression, mirroring the CLI's grouping. Logs to stderr; no secret (HMAC key / sync token) is ever returned. - .mcp.json + scripts/setup_mcp.sh: registration via a dedicated ~/.venvs/diet-guard-mcp interpreter; mcp is an optional-dep, kept out of the systemd/CLI system-python path. - 100% coverage of the new module; verified end-to-end over stdio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v |
|||
| a121da211d |
Point sync at the consolidated utils/syncs monorepos
gatelock and diet-guard-sync were folded into shared monorepos: gatelock -> github.com/kuhyx/utils (subdirectory), diet-guard-sync -> github.com/kuhyx/syncs (subdirectory). Update the gatelock pip URL, SYNC_REPO_NAME, the devices-dir path prefix (PC and phone), and add a persisted-value migration so an already-configured phone picks up 'syncs' on next load. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| e5b80fd610 |
Add cross-device log sync (Python half of Milestone 3)
Pulls every other device's pushed log from GitHub-backed dumb storage, merges it with the local log, and pushes this device's own merged copy back -- the PC half of the diet-guard-app sync plan. - _sync_merge.py: pure union-by-id merge, tombstone always wins, legacy (time, desc) dedup for pre-id entries. Commutative and idempotent. - _sync_github.py: minimal GitHub Contents API client (list/get/put), distinguishing a 404 on an unused path from the repo itself being unreachable. - _sync.py: orchestration -- pull, merge, re-sign every persisted entry regardless of origin, write, rebuild the food bank, push. Re-signing unconditionally is load-bearing: an unsigned phone-origin entry would otherwise be silently dropped on the very next read once a machine holds the shared HMAC key. - _foodbank.rebuild_food_bank(): the "replay a full log into a fresh bank" entrypoint the Python side was missing (the Dart port already had its equivalent). Backs sync's bank-rebuild step. - New diet-guard-sync.service/.timer (15-minute cadence, headless, a separate unit from the gate so a held lock can't stall sync) and a new install.sh step to install them. - Created the private kuhyx/diet-guard-sync GitHub repo for storage. Incidental to this feature: adding the `sync` subcommand pushed _cli.py past the repo's 500-line cap, so `gate`'s CLI glue moved out alongside sync's into _cli_gate.py/_cli_sync.py -- same split pattern already used for the gate window logic itself, not a sync-specific design choice. 338 tests, 100% branch coverage. Verified importing and running cleanly under /usr/bin/python (the production interpreter), not just the dev venv -- the gap that caused the earlier 3-day outage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF |
|||
| 888c877048 |
Add sync-ready log schema: stable id, tombstone undo, composite components
Lays the Python-side groundwork for the phone companion app's eventual log sync (Milestone 0 of the diet-app-as-wise-balloon plan): - log_meal() stamps every entry with a UUID id for union-by-id merging across devices. - undo_last_today() now tombstones (deleted: true, re-signed) instead of popping the entry, so a sync merge can't resurrect a stale copy of something already undone on another device. - log_meal() accepts components, the full per-item macros for a composite meal, so a food bank rebuilt purely by replaying the log can recover every component's standalone nutrition. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF |
|||
| 843f5e0221 |
Extract diet_guard from testsAndMisc as a standalone repo
Rewrites python_pkg.diet_guard imports to diet_guard, vendors the shared as_float coercion helper, drops the monorepo PYTHONPATH from install.sh and the systemd unit (package is now pip-installed), and scaffolds standalone lint/test config matching testsAndMisc's real enforced bar (pylint --fail-under=10 with tests excluded and the use-implicit-booleaness/consider-using-with disables, mypy's actual disabled-error-code set, ruff ALL, bandit, 100% branch coverage). |
|||
| 2de8f5d122 |
Fix non-hermetic diet_guard gate tests depending on a real X display
test_demo_opens_window and test_bare_gate_due_opens_window never mocked wait_for_display(), so they silently relied on a real, reachable X display being present. On a dev machine this is always true, so they passed; on a true headless CI runner (no X server at all) each test blocked for the real 60s timeout before failing, explaining the CI run's 131s duration. Mocking it to True (matching the existing test_gate_due_but_display_not_ready_defers pattern for the False case) restores hermeticity with no production code change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky |
|||
| 205b214d78 |
Migrate diet_guard to the shared gatelock backend
MealGate now composes gatelock.GateRoot + gatelock.LockWindow instead of inheriting the deleted _GateWindow/_GateRoot, and its HMAC signing goes through gatelock.log_integrity. This is the first of three migrations (diet_guard -> screen-locker -> wake_alarm) extracting the lock-window mechanics that diet_guard's own _GateWindow proved out into a shared, reusable package. Window-mechanics tests moved with the code; diet_guard's suite now only tests its own wiring (LockConfig choice, hook delegation). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCdT46zV8hESDvbgYMGDLt |
|||
| 84898c0272 |
feat: split oversized modules for 500-line limit, fix kasa coverage gap
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the usage_report.py/_usage_report_parsing.py pair into focused sub-modules so every Python file is <= 500 lines, satisfying test_file_length.py. Install python-kasa into .venv (declared in requirements but missing after the 3.13->3.14 venv upgrade), fixing 8 failing smart_plug tests and restoring 100% coverage. Also includes prior in-progress work from the working tree: the wake_alarm Progress/View/Hardware field-grouping refactor, brother_printer query module + tests, diet_guard foodbank/state/cli updates, new shared coerce/logging_setup helpers, morning_routine orchestrator tweaks, dwm window-manager config, gaming scripts, and misc maintenance/digital-wellbeing script updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
|||
| 400f89b469 |
feat(diet_guard): add meal-logging screen-lock gate with trigger fix
Add the diet_guard package: a screen-locking meal-logging gate that fires on 4-hour slots (08/12/16/20) and records calories/macros, persisting an autocompleting food bank. - Trigger fix: the systemd timer fires at session start (Persistent=true) before lightdm has written ~/.Xauthority, so the gate crashed with a TclError instead of locking the screen. Add wait_for_display() / _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the gate retries on the next tick instead of crashing; add Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders. - Food-bank hardening: a transiently corrupt food_bank.json was warned about on every keystroke and then silently overwritten (data loss). _read_bank now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped backup) before starting fresh. - Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE), remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in the gate that logs both the individual items and the composite meal. - Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to stay within the argument-count limit. diet_guard at 100% branch coverage; full pre-commit suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |