mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
24dbffc432
79 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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
|
|||
| d2e62bf210 |
Read the meal schedule from a forward-only history (Dart)
The phone/desktop half of the previous commit, so both devices now derive their slots the same way. Still no edit surface, so `MealScheduleService` returns the default and the derived hours are unchanged at 08/12/16/20. `slot.dart` takes the schedule as a required argument, mirroring `_slots.py`, and `slot_test.dart` gains the same every-hour x several-schedules sweep the Python suite runs. That sweep is the contract: the two must agree on which checkpoint a meal satisfies for every cell, not merely on the endpoints. Also fixes a real bug this feature would otherwise have shipped on top of. `syncToSlots` used the slot hour as the notification id but only cancelled ids in the *current* schedule, so changing 08/12/16/20 -> 08/11/14/17/20 would leave ids 12 and 16 posted with nothing that could ever cancel them: permanent reminders for checkpoints that no longer exist. It now sweeps the whole 0..23 id space. The regression test was confirmed to fail against the old loop (4 cancels vs 24) rather than passing vacuously. MealScheduleService.current degrades to the default when the singleton is absent, so `due_slot_check` deliberately does not init() it -- that would reach path_provider from widget tests and from the WorkManager isolate before main() runs. main.dart owns the real init, and background_sync_service.dart does its own, since a fresh isolate has its own static state and would otherwise nag against the default four meals. 609 Dart tests, 761 Python, both suites green. 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 |
|||
| cbbebee79b |
Pin meal pills to one row; drop the "Logged" restatement
Three independent cleanups ahead of the configurable meal schedule.
Pills: the row was a `Wrap`, so pills dropped onto a second line as soon as
they outgrew the width -- which they do even at four. Measured: a logged chip
is ~124px at Material defaults, so four need 522px against ~330px of phone
width. Now a `FittedBox(scaleDown)` over a `Row`, which scales the row down
uniformly, plus compact chip density so there is less to scale away.
A `Row` of `Expanded` children was the obvious alternative and is wrong: it
imposes a tight width, and a Chip narrower than avatar+padding+label clips its
label rather than shrinking -- measured at 10px per label, about one character,
while throwing no overflow error. The new regression test therefore asserts
both a shared baseline and a legible label width; it was confirmed to fail on
the old `Wrap` ("chip wrapped to a second line at 320.0") rather than passing
vacuously.
Card: the `Logged "<meal>".` line restated the description the user had just
typed. Removed, along with `TodayProgress.justLogged`, which nothing else read.
The kcal position, macros and streak stay.
refactor_claude_todo.md: the 250-line file-splitting refactor it tracked is
done -- `scripts/check_file_length.sh --all` exits 0 and an independent sweep
finds nothing over the cap. DESIGN_AUDIT_TODO.md is a separate, still-open
effort and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
|
|||
| cb23f3c09d |
Fix check_patch_targets' own fail-open: put the repo root on sys.path
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
The pre-commit CI job reported all 19 patch targets stale, including ones no commit in this sweep touched -- the tell that the checker, not the targets, was broken. Running it as `python3 scripts/check_patch_targets.py` puts *scripts/* on sys.path[0], not the repo root. `diet_guard` is not installed in the runner's environment (CI installs requirements.txt only), so every `import_module` hit the resolver's `except ImportError: continue` and every target reported unresolvable. It passed locally because the dev venv has the package on the path. That is precisely the fail-open shape this script exists to prevent, in the script itself: a green-or-red signal that reflects the environment rather than the code. Two fixes: - put the repo root on sys.path explicitly, so the checker resolves the same way whatever cwd or interpreter invokes it; - import the package once up front and fail with a distinct message if that fails, so "the package will not import" can never again masquerade as "every patch target moved". Reproduced in a clean venv built from requirements.txt: 19 stale before, exit 0 after, and still exactly 1 stale when a target is genuinely broken (verified by renaming FOOD_LOG_FILE's target). Verified: 708 Python tests / 100% branch coverage, pre-commit clean, checker exit 0 under both the dev venv and the clean venv. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| 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
|
|||
| 499882b1de |
Sort plugin_platform_interface into dev_dependencies alphabetically
Caught by the pre-push CI mirror, which runs `flutter analyze` over the whole project; the per-batch `flutter analyze lib/ test/` used during the sweep does not cover pubspec.yaml, so `sort_pub_dependencies` never fired there. The entry was appended after a blank line by `flutter pub` during the mirror's own clean-venv resolve, landing out of order and outside the dev_dependencies block it belongs to. Verified: flutter analyze (whole project) clean, flutter test 555. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| c77e7d9acf |
Split the last four Dart lib files; file-length gate now reports zero
Final batch of the 250-line sweep. `check_file_length.sh --all` exits 0: every file in the repo is at or under 250 lines. github_mirror_screen.dart 391 -> 205 (+ actions, client_id_dialog parts) foodbank_service.dart 314 -> 209 (+ foodbank_service_manual part) settings_screen.dart 286 -> 237 (+ settings_kcal_goal part) firebase_backend.dart 282 -> 100 (+ firebase_client.dart) Three of the four are `part` files rather than separate libraries, and that is forced rather than stylistic: the moved code drives private state (`_busy`, `_status`, the text controllers) and calls private helpers (`_readBank`, `_normalize`), none of which are visible across a library boundary in Dart. Widening them to public just to split a file would leak internals the rest of the app has no business calling. Within those parts, mixins rather than extensions, for a concrete reason: an extension is not a subclass of `State`, so calling `setState` from one is an `invalid_use_of_protected_member`. Fields the State must own (the controllers it creates and disposes) stay declared there and are reached through abstract getters. `firebase_client.dart` is a real library split -- opening an authenticated client has no private coupling to the keystore-backed account record, only to its public API. The one exception, dropping the account marker without setting the opt-out flag, became `forgetAccountMarker()` rather than exposing the secure store. Verified: flutter test 555 (unchanged), flutter analyze clean, 707 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, check_file_length.sh --all exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| 3c67cf8c9c |
Split log_meal_screen into mixins and widget helpers
Twelfth batch of the 250-line sweep (over-cap count 5 -> 4). log_meal_screen.dart 431 -> 250 Widget/behaviour extraction only, no route changes: the four app-bar buttons push exactly the MaterialPageRoutes they did before. log_meal_sync_mixin.dart background sync + app lifecycle observation log_meal_nav_mixin.dart the four app-bar destinations log_meal_actions.dart the app-bar IconButton list log_meal_progress.dart buildTodayProgress + parseMacroField (both pure) The sync and nav halves are mixins rather than helper classes because the behaviour is inseparable from the State it drives -- it observes the widget lifecycle, calls setState, and reads `mounted`. The screen's fields stay declared on the State; `syncHttpClient` is an abstract getter because `widget` is typed `T` inside a mixin, so `widget.httpClient` does not resolve there. Names crossing the new file boundaries became public with doc comments, as `public_member_api_docs` requires. The macro-filling block moved onto `MacroControllers.fillFrom`, next to the existing `clear()`, which is where it belonged anyway. Verified: flutter test 555 (unchanged), flutter analyze clean, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| c0ed6154ac |
Split the remaining seven Dart test files
Eleventh batch of the 250-line sweep (over-cap count 12 -> 5). Every test file in the repo, Python and Dart, is now under cap; the five left are all in app/lib/. wrapper_server_test.dart 349 -> 145 (+ proxy, account siblings) app_settings_service_test.dart 321 -> 162 (+ sync sibling) average_service_test.dart 299 -> 158 (+ periods sibling) settings_screen_test.dart 274 -> 189 (+ battery sibling) day_status_service_test.dart 270 -> 161 (+ streaks sibling) edit_entry_screen_test.dart 269 -> 159 (+ foodbank sibling) day_status_calendar_test.dart 256 -> 237 (+ _test_support sibling) day_status_calendar_test is a single group whose tests close over group-local state, so it could not be split by block. Its ~25 lines of file-level fixtures moved to `day_status_calendar_test_support.dart` instead, which required the same publicisation as the sync support file (`_theme` -> `appTheme`, `_wrap` -> `wrapInApp`, `_colorOfDay` -> `colorOfDay`) with doc comments. Block counts were compared against HEAD per family rather than trusting the suite total, since the duplication bug in the previous batch passed green at an inflated count. All seven families match exactly (29/26/34/11/34/8/14). Verified: flutter test 555 (unchanged), flutter analyze clean, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC |
|||
| cc78dd383b |
Split six Dart test files; share the sync fixtures via a support sibling
Tenth batch of the 250-line sweep (over-cap count 18 -> 12; all Python is
already clear, so the remainder is Dart).
sync_merge_test.dart 694 -> 232 (+ 4 siblings)
github_mirror_screen_test.dart 512 -> 250 (+ 3 siblings)
foodbank_service_test.dart 395 -> 190 (+ manual sibling)
sync_service_test.dart 394 -> 203 (+ budget sibling + support)
log_storage_service_test.dart 385 -> 182 (+ edit sibling)
log_meal_screen_test.dart 362 -> 243 (+ nav sibling)
sync_service_test's ~100-line fixture block is shared through
`sync_service_test_support.dart` rather than duplicated: copying it would have
put both siblings back over the cap on their own. The helpers had to become
public (`_FakeGitHub` -> `FakeGitHub`, `_manual` -> `manualNutrition`,
`_remoteBudgetJson` -> `remoteBudgetJson`) with doc comments, since a leading
underscore is library-private in Dart and the siblings could not otherwise see
them.
The block-boundary matcher earned two fixes, both caught by comparing block
counts against HEAD rather than by the suite:
- A test name wrapped onto its own line (dart format does this past 80 cols)
was invisible to a same-line-only pattern, so the neighbouring span sliced
through it -- the file stopped compiling. Spans now end at the matching
close paren of the call, not at a guess past the closure brace.
- A double-quoted name containing an apostrophe ("skips its own device id
('phone')") was excluded by a character class that banned both quote
characters. That block fell into the shared prologue and was duplicated into
every output: the suite went GREEN at 559 tests instead of 555. Reverted and
redone; text between two blocks is now treated as a shared helper.
Verified: flutter test 555 (unchanged, and each split family's block count
matches HEAD exactly), flutter analyze clean, pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
|
|||
| 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 |
|||
| 7cb09a5b85 |
Split food_bank_screen; share the filter widgets with history
food_bank_screen.dart (712) carried a near-clone of the filter sheet batch 2
had just factored out of history: the same name-search field, the same slider
captions, and four more copies of the same min/max macro range block. Splitting
it in isolation would have baked that duplication in permanently, so the shared
widgets moved to a neutral `lib/widgets/filters/` first and both screens now
import them.
food_bank_screen.dart 712 -> 238
lib/widgets/food_bank/
fb_filter.dart 156 FbFilter, FbSortField, applyFbFilter
fb_filter_sheet.dart 244 the sheet itself
fb_add_entry_dialog.dart 103 the manual-entry dialog
lib/widgets/history/history_filter_fields.dart
-> lib/widgets/filters/filter_fields.dart
FilterRangeRow gained `showEndpointLabels`, defaulting to true. History showed
endpoint captions and a selected-range readout; the food-bank sheet never did,
and switching them on there would have been a UI change smuggled in under a
refactor. The food-bank call sites pass false, so both screens render exactly
what they rendered before.
FbSortField and HistorySortField stay separate on purpose -- they are not the
same enum (`name` vs `date`/`description`), so the sort dropdown is not shared.
The test file mirrors the source split, three files under test/screens/
food_bank/ plus fb_test_support.dart for the setUp/tearDown/settle prologue,
same shape as batch 2's history_test_support.dart.
Verified: 555 flutter tests (unchanged count -- no test lost, and food bank's
own RangeSlider/sort-toggle tests still pass against the shared widget),
flutter analyze clean over lib/ and test/, live ~/.local/share/diet_guard
byte-identical. Over-cap files: 40 -> 38.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
|
|||
| 895a87cf86 |
Split history_screen and its test into widget siblings
history_screen.dart (1052) and history_screen_test.dart (1017) were a third of
the remaining over-cap work. Widget extraction only -- no route changes, since
routes are user-visible and repo_invariants_test.dart pins the navigation
surface.
history_screen.dart 1052 -> 211
lib/widgets/history/
history_filter.dart 102 filter criteria + sort enum (pure data)
history_grouping.dart 206 HistoryItem tree, apply/group/format
history_list.dart 194 GroupedList, DayHeaderTile, EntryTile
history_filter_sheet.dart 242 the sheet itself
history_filter_fields.dart 168 search field, slider labels, range row
history_filter_sort.dart 140 source chips + sort controls
Names that now cross a file boundary became public (HistoryItem, DayHeader,
EntryRow, buildGroupedItems, formatDay, dateRangeLabel, the tile widgets), each
with the doc comment public_member_api_docs requires -- 49 of them, written
rather than suppressed.
The four macro range sliders (kcal/protein/carbs/fat) were four near-identical
30-line blocks differing only in label, key, unit and which HistoryFilter pair
they read/write. They are now one FilterRangeRow taking min/max plus an
onChanged, which is most of how the sheet got under the cap.
The test file mirrors the source split one-for-one, six files under
test/screens/history/. Its setUp/tearDown and the settle() helper (needed by
every part, because HistoryScreen loads via an untracked initState future) moved
to history_test_support.dart rather than being copy-pasted six times.
Verified: 555 flutter tests (same count as before -- no test lost in the split),
flutter analyze clean over all of lib/ and test/, 701 python tests at 100%
branch coverage, `python -m diet_guard sync` -> 402 entries/exit 0, live
~/.local/share/diet_guard byte-identical. Over-cap files: 42 -> 40.
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
|
|||
| fef140d970 |
Split wrapper_server and sync_merge into siblings
Two Dart files whose contents were already cleanly separable. wrapper_server.dart (254 -> 228): contentTypeFor is a pure function of the path with no server state, so it moves to content_types.dart as a plain library. WrapperServer.contentTypeFor stays as a static forwarder because the test reaches for it by that name. sync_merge.dart (475 -> 190): three near-independent blocks -- daylog, banks, budget -- split along the seams the tests already use. The banks and budget adapters move to siblings and are re-exported, so sync_service.dart and both test files import exactly what they did before and needed no edits. Flutter 555 tests unchanged; analyze clean; gate 52 -> 49. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve |
|||
| 17a2c62f26 |
Automate CLAUDE.md's structural rules, then cut it to 189 lines
CLAUDE.md was 1101 lines and loaded into every session in this repo. Three
passes, in order: automate what a gate can check, rewrite what is left as
instructions rather than rationale, extract what still does not fit.
Automated. Five prohibitions were enforced by nothing but a paragraph asking a
future reader not to do something; they are now repo_invariants_test.dart:
- no app/linux/ (a GTK embedder target manages ~20fps at 4K)
- dart:io only in *_io.dart seams and lib/desktop/ -- this one is why the
test exists at all, since dart:io does not fail a web *compile*. It throws
at runtime, so a bad import is a blank white window that the widget tests
happily pass through.
- no meal builder / repeat-last-meal / reward prompt
- no image_picker, no CAMERA permission
- wrapper port 8732 and the diet-guard-desktop Chrome profile, both pinned
because IndexedDB is keyed by origin and moving either hides the whole
local food log
Each guard was canary-tested by breaking the invariant and confirming the
suite goes red -- a guard that cannot fail is decoration.
Rules whose behaviour was already covered (budget epoch seeding, sync merge,
food bank) shrink to a pointer at the test that owns them.
Where a rule's entire value was its rationale ("the removal is the feature"),
the why moved into the name and reason string of the gate that now enforces
it, so it lives somewhere executable instead of somewhere quotable.
Extracted. The vendored flutter/flutter rules were 789 of the 1101 lines --
72% of the file, none of it about this repo. Split into five docs/ topic files
under the cap and linked. Verified content-complete: 664 non-blank lines in,
664 out, zero missing.
Python 692 tests / 100% coverage unchanged; Flutter 548 -> 555 (+7 new).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
|
|||
| c7538f691b |
Enforce the shared 250-line file cap
Replace this repo's stale file-length hook with the shared gate every other
repo will use. The old hook capped at 500 lines, called a repo-local
scripts/check_file_length.py, and filtered to types_or: [python, shell] --
so it never looked at the Flutter app in app/ or at any prose. Two caps that
disagree is worse than one, so the old one is deleted rather than kept.
The new hook checks only the files in the commit and exits 1; CI runs --all
as the backstop, since a fresh clone that never ran `pre-commit install` has
no hooks at all. The exemption list lives with the checker in
~/utils/file_length/config.py so the gate and the cross-repo survey cannot
disagree about what "too long" means.
Two things this repo has that ~/todo (the reference implementation) did not:
- A pre-commit CI job running --all-files, where $HOME/utils does not exist.
The shared gate is checked out to .utils and reached via UTILS_ROOT, or the
hook fails on a missing checker rather than on anything in this repo.
- Tests that isolate live user state with dotted-string patches
(`patch("diet_guard._state.FOOD_LOG_FILE", ...)`). unittest.mock.patch fails
OPEN: once a refactor moves a name, the old path still imports, the patch
redirects nothing, the test still passes -- and the suite writes to the real
~/.local/share/diet_guard, which diet-guard-sync.timer publishes within 15
minutes. check_patch_targets.py resolves all 30 patch targets statically so
that failure is loud instead of silent. It is landed before any split for
exactly that reason.
The gate is red at 52 files; the splits that follow drive it to zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
|
|||
| 7a491d9d91 |
app: use the shared github_device_auth package
The inline `_DeviceCodeDialog` in `github_mirror_screen.dart` (88 lines) was byte-for-byte identical to wake-alarm's, and `services/github_device_auth.dart` was a fourth copy of the auth client. Both now come from `github_device_auth` in the kuhyx/utils monorepo. This app's copy was the one that mattered most to get right: it took the device-code and token URLs as parameters because GitHub's device-flow endpoints send no CORS headers, so the desktop **web** build has to route both through the local wrapper (`github_client_factory_web.dart` passes them). Extracting todo's simpler hardcoded version would have broken that build outright. The package carries the configurable endpoints instead, defaulting to GitHub's own URLs so the other three consumers are unaffected -- and `flutter build web` here is verified green, which is the check that would have caught it. The dialog also gains a real fix: `on Object` rather than `on Exception`, so an `ArgumentError` shows an error instead of leaving the dialog spinning on "Waiting for authorization…" forever. The unused `token_vault` import in the deleted client confirmed it had no app-specific behaviour left worth keeping. 548 tests pass, analyze clean, web build green, and the release APK is installed on the phone with meal history intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno |
|||
| 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 |
|||
| 4e3778823f |
Drop five redundant unawaited() wrappers around Navigator.push
Navigator.push is annotated @awaitNotRequired, so these now trip unnecessary_unawaited and failed the CI-mirror's flutter analyze -- which blocked every push to this repo regardless of what the commit changed. flutter analyze: no issues. flutter test: 553 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ |
|||
| e80ae41e1c |
Persist the CI-mirror's analyzer excludes and dependency upgrades
The ci-mirror hook regenerates these on every run and then leaves them dirty. Pre-commit stashes the dirty tree, the hook rewrites the same files, and restoring the stash conflicts -- which rolled back the fixes and failed every push regardless of whether the checks themselves passed. Committing the hook's own output makes the tree clean, so there is nothing to stash and the collision cannot recur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ |
|||
| b5704b2228 |
Update refactor_claude_todo.md counts after gate exemption fixes
Git-ignored files, vendored agent-skill bundles and the CI checkout dir are now exempt, since no commit could ever fix them. Repo counts and the ranked violation lists are regenerated accordingly; the global baseline drops from 657 to 597. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ |
|||
| a573cc11e0 |
Add refactor_claude_todo.md: 250-line file-length cap
A ready-to-use prompt describing the goal (every file at most 250 lines, code and prose alike), this repo's actual violations ranked by Fowler ROI (lines x commits/yr), how to split each kind, and the requirement to wire in the shared pre-commit gate so files stay small permanently. Generated from a measured survey of every repo; the gate itself lives in ~/utils (file_length/ + scripts/check_file_length.sh). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ |
|||
| 45471546f7 |
Migrate Sync settings to the shared sync_settings_ui package
Splits the settings screen: Nutrition (daily kcal goal) and Notifications (battery exemption) stay in place, Firebase+GitHub content is replaced by a Sync settings link using the shared sync_settings_ui package. GitHub stays app-local as its own GitHubMirrorScreen (device-flow connect, _ClientIdSetupDialog, the wrapperManagedToken desktop-wrapper path) since connecting it also triggers a real cross-device sync via runSync(), not just a settings save. BackupSlot left null: diet-guard has no local export/import feature. 553/553 tests passing, 100% coverage on settings_screen.dart, flutter analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1 |
|||
| 546041584f |
Add Google sign-in support for the Firebase sync account
Some checks failed
One-tap sign-in for a fresh install, matching the pattern already shipped in todo and crdt_sync_dart: openFirebaseWithGoogle() (google_platform.dart abstracts the plugin's platform-channel dependence), isFirebaseConfigured() that reads the stored session rather than trusting the account marker alone (a marker with no live session is what made a synced phone read as "not connected" for a full session), and storedAccount() for read-back without the desktop-wrapper fallback that throws on Android. Also generalizes crdt_sync_dart's ref pin to v0.10.0 and vendors the override-guard script for the pre-commit hook already wired to it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1 |
|||
| 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 |
|||
| fc9822b516 |
packaging/aur: track the current release tag
Routine follow-up to the per-commit version bump: the clean vX.Y.Z tag moved, so the template's pkgver follows. sha256sums stays 'SKIP' -- the tarball for a tag contains this file, so the real hash can only be filled in by updpkgsums in the AUR checkout after the tag exists. Note this line is inherently a lagging indicator: publishing runs updpkgsums against whatever tag is newest at that moment, so the AUR may be a release ahead of what is written here. ~/utils/aur-publish/publish.sh resolves the tag itself and does not read this value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL |
|||
| 4cd8e368b8 |
packaging/aur: sharpen metadata against the AUR submission rules
The AUR does not review uploads, but packages that violate the rules "may be
deleted without warning", so these were audited against the submission
guidelines rather than assumed compliant.
- pkgdesc now says what the app actually does, in terms a stranger can judge.
"Usefulness" is the one rule these packages are genuinely exposed to, and
the old wording led with the implementation detail.
- optdepends lists every browser the wrapper actually probes for. It searched
Thorium and Brave but only advertised Chrome and Chromium; all four names
were checked to resolve in the official repos or the AUR.
Verified compliant on the rules that can be checked mechanically: not in an
official repo, name free in the AUR, x86_64 declared, no replaces= (the AUR
reserves it for renames), Maintainer line present, license installed. The
conflicts=('todo') on todo-flutter is justified, not defensive: AUR 'todo'
2.4-1 installs its own /usr/bin/todo, confirmed by reading its PKGBUILD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
|
|||
| 97959e892d |
Note that pre-commit install is required after cloning
This repo had only the pre-push hook wired into .git/hooks, never pre-commit, so every pre-commit-stage hook here silently never ran locally -- including the version bump added in the previous commit, which is why that commit did not bump its own version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL |
|||
| 688bd7b1de |
Finish the three sync bugs: gate, SIGKILL flag, and silent catches
Auditing the whole fleet for the bugs found on the desktop turned up three that were still live here. 1. `--virtual-time-budget=45000` was still in the provisioning args. The previous commit added the comment explaining why it must not be there but left the flag itself, so headless Chrome kept SIGKILLing itself (exit -9) and could still truncate a sync. Actually removed now; a provisioning run exits 0. 2. `backgroundSyncPush` gated on `settings.isConfigured` -- "has a GitHub token" -- so the WorkManager background push was skipped entirely on a device connected only to Firebase. Same defect as _autoSync's, one layer down. Either backend now counts. 3. Three swallowed exceptions, all in the sync path: the in-isolate config read, the background push itself, and `loadAccount()`. The last one is the worst -- it hid *why* provisioning failed, which is indistinguishable from "no account set" until it says so, and cost real debugging time this session. All log at warning/error with the stack trace and stay non-fatal, per the repo's no-silent-failures rule. Verified after the change, not just compiled: provisioning exits 0 and the desktop (c891b813) still holds 359 food_log records, identical to the PC daemon's, 0 missing. 522 tests, analyzer and pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 |
|||
| d388f90ed4 |
Stop SIGKILLing the provisioning browser, and say why auto-sync failed
Two fixes found while verifying the desktop actually reaches Firebase rather
than trusting an exit code.
`--virtual-time-budget` makes headless Chrome SIGKILL itself when the budget
expires: the run reported exit -9 and could cut a sync short mid-flight. It
now gets a real wall-clock window and is closed cleanly, so the exit code
means what it says.
`_autoSync` caught `on Exception` and ignored it entirely — the exact
silent-failure shape screen-locker's CLAUDE.md bans ("an invisible log is a
silent failure with extra steps"). It now catches broadly and logs at error
level with the stack trace, so the next failure is diagnosable instead of
looking identical to "nothing to sync". `--enable-logging=stderr` on the
provisioning run surfaces that in the terminal that started it.
Verified end to end, which is what settled it: the desktop appears in the
RTDB as c891b813 with all four documents and a revs entry, and its food_log
holds 359 records — identical to the PC daemon's, 0 missing either way. An
earlier check read as "not pushing" because it ran before the push landed.
522 tests, analyzer and pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
|
|||
| c093c22ae0 |
Auto-sync on a Firebase-only device, and provision with no window
Two bugs, found because headless provisioning silently did nothing here while
it worked in todo.
`_autoSync` returned early unless `settings.isConfigured`, which means "has a
GitHub token". A desktop connected only to Firebase therefore never synced at
all -- not on this run, not ever -- and since the provisioning fallback is
reached through openFirebase(), it could never provision either. This is the
same defect workout_app's push() already documents ("Gating on the GitHub
token alone silently dropped every push from a device connected only to
Firebase -- and once the mirror is retired that would be every device").
Either backend now counts.
--provision-sync-account then does the setup with no window: the account
lives in origin-keyed localStorage so the page must run, but --headless=new
runs it without ever mapping a window. Startup auto-sync calls loadAccount(),
the wrapper fallback provisions, the process exits.
Verified after the fix, having watched it fail twice before it:
FlutterSecureStorage.firebase.account now holds a real AES payload in the
desktop profile's localStorage. The time budget is 45s rather than todo's
15s -- this log is an order of magnitude larger.
Usage: CRDT_SYNC_SERVE_ACCOUNT=1 diet-guard-app --provision-sync-account
522 tests, analyzer and pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
|
|||
| 19991f3600 |
Make the sync-account route testable, and cover every branch
Same change todo just took: the route read its own configuration from Platform.environment and $HOME, so only the "disabled" branch was reachable from a test. Both are now constructor parameters defaulting to the environment -- production behaviour unchanged -- and the four outcomes are covered: 404 disabled, 200 with the account, 404 when ~/.config/crdt-sync/ is incomplete, 404 when firebase.json has no usable email. 522 tests, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 |
|||
| 55fade1e15 |
Let the desktop provision its sync account from the wrapper
Same change todo just took, for the same reason: app/'s desktop target is a
Flutter web build in a Chrome --app window, so FlutterSecureStorage resolves
to the web backend (AES-encrypted localStorage, keyed inside the browser
profile). Nothing outside the page can write that store — `secret-tool`
targets libsecret, which only a Linux embedder build would read, and a Dart
CLI cannot even import flutter_secure_storage, since it is a Flutter plugin
that fails to resolve on the VM. Both verified rather than assumed.
The wrapper is the one process that can read the filesystem and serve the
page, which is exactly why it already holds the GitHub token instead of the
browser (see github_proxy.dart). So the sync account now arrives the same
way: loadAccount() falls back to GET /sync-account when the store is empty
and persists the result, making the route a one-shot provisioning step
rather than a standing dependency.
It reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
`diet_guard sync` already uses — so the machine keeps one source of truth.
Off unless CRDT_SYNC_SERVE_ACCOUNT is set, and 404 (not 403) when disabled so
a probe cannot tell the route exists. On Android there is no wrapper: the
request simply fails and loadAccount() returns null exactly as before.
clearAccount() writes an opt-out marker the fallback checks, so Disconnect
cannot be silently undone by the next launch re-adopting the account.
518 tests, analyzer clean, pre-commit clean. Pinned to crdt_sync_dart-v0.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
|
|||
| 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 |
|||
| 1328bece05 |
Use the platform sync device id for HLC stamps, not a hardcoded 'phone'
sync_merge.dart declared its own `const _syncDeviceId = 'phone'`, which was unconditional -- so the desktop web build wrote records under `diet-guard-sync/devices/desktop/` while every HLC string inside them ended in `-phone`. The path segment came from the conditional-export constant, the HLC did not. Import `syncDeviceId` from github_client_factory.dart instead, so both halves come from the same source: 'phone' on Android, 'desktop' on web, matching the Python side's 'pc' in _constants.py. Preventive, not a repair: there is no devices/desktop/ in the live RTDB yet, so no already-written record has the mismatch. HLC tie-break is nodeId.compareTo, so desktop stamps now sort below phone's rather than tying; entryHlc is deterministic from entry.time and the bodies are identical for a given entry id, so this changes which identical copy wins, never what data survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP |
|||
| fe28c8a395 |
Step Dart dates by the calendar, not by 24 hours
A Duration is an absolute span, so stepping a local DateTime by
Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
the wrong wall-clock day. Under Europe/Warsaw, spring-forward 2026-03-29:
lastCompleteDay(2026-03-30) -> 2026-03-28 23:00, the day before
yesterday, not yesterday
weeksAgo: 1 from 2026-04-01 -> 2026-03-24 23:00, a Tuesday, so the
wrong ISO week entirely
a 03-27..03-31 period -> emits four day keys, silently dropping
2026-03-31
The Python half computes all three correctly, because timedelta on a date
has no clock to drift against. So for about two weeks a year the phone and
the PC would report different averages for the same log -- exactly the kind
of device-local divergence the shared-source-of-truth rule exists to stop.
Passing out-of-range components to DateTime's constructor normalizes on the
calendar (day 0 is the previous month's last day), which is what timedelta
already does. The same fix goes to day_status_service's streak walker: it
had the identical pattern and predates this feature, so a streak that
spanned the clock change could break when it should have held.
None of the existing tests could see any of this -- every fixture date sits
in a DST-free span -- so the regression tests use dates that straddle
2026-03-29 and assert on the calendar day rather than the DateTime, which
keeps them meaningful whatever zone the runner is in. Verified they fail
against the old arithmetic before fixing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
|
|||
| 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 |
|||
| 4d803203ab |
Make Firebase the visible sync path, and GitHub the mirror it now is
Four things about the sync settings read wrong on the phone, all of them left over from when GitHub was the only backend: - "Connect GitHub" sat on the primary path as a peer of "Connect Firebase", implying a choice. It is not one: GitHub is the cutover mirror. Its whole configuration -- device-flow OAuth, owner, repo, client id, PAT fallback -- now lives under "Advanced (GitHub mirror)". - "Test connection" builds a GitHubClient and calls canAccessRepo(). It has never said anything about Firebase, so it is now "Test GitHub connection", and its result messages name GitHub too. - Once connected, the account still rendered as an editable email box beside an empty password box, which reads as "you still have to enter this" -- a connected device looked unconfigured. It now shows the account as text with a Disconnect button, and the form appears only when disconnected. Also pins crdt-sync v0.5.1 (from v0.3.0), the shared-config release the Firebase cutover needs; verified importable under /usr/bin/python3, which is what diet-guard-gate.service actually runs. Adds coverage for both Firebase states, which had none: the disconnected form and the connected read-only display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa |
|||
| 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 |
|||
| 665b0bf935 |
Stop two keyboard tests hanging, and make the CI mirror mirror CI
Two failures, both pre-existing and both invisible as failures — each reported only as "did not complete" after stalling the suite. app/test/keyboard_operability_test.dart awaited writeLog() straight from the test body. LogStorageService is backed by a FileDocumentStore there, so that awaits real dart:io, and the fake-async zone never completes it: the test hangs rather than fails. Seeding now happens inside runAsync alongside the pumping, which is the only zone where that write can resolve. The screen's own initState load needs the same real clock, and pumpAndSettle() cannot be used to wait for it — inside runAsync it waits on a frame scheduler this screen never reports as idle — so the helper pumps a bounded number of times instead. 473 app tests now pass in 14s. scripts/ci_mirror.sh ran bare pytest while python-tests.yml runs it under xvfb-run. Without a display the gate's real-Tk tests collapse: the focus ring has nothing to walk, so three TestRealFocusRing tests fail locally and pass in CI. The mirror was reporting red on a green tree, which is worse than not mirroring at all. It now uses the workflow's exact xvfb-run invocation and fails loudly if xvfb-run is missing. Neither change touches production code: the delete control and its id-gate were already correct, and the Python suite already passed under a display (609 passed, 100% coverage). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj |
|||
| 5f34ba6139 |
Build and publish a signed APK on every push to main
diet-guard had no APK build at all; releases were not produced. Adds a standalone release-apk.yml plus the release signing config the app was missing. Signing follows the pattern proven in home_inventory: gradle reads android/key.properties when present and falls back to the debug key when absent, so local `flutter run --release` is unaffected. CI writes that file from repository secrets and shreds it afterwards, and apksigner fails the build closed if a debug-signed APK ever appears. Only the signingConfig line changes here: isMinifyEnabled, isShrinkResources and proguardFiles are left exactly as they were. This repo has no Flutter analyze/test job, so the release job is not gated on tests — a broken commit still publishes, with the build failing as the only backstop. Add `needs:` if a Flutter CI job is added later. versionCode is github.run_number, which is per-workflow-file — this file must never be renamed, or the counter resets and updates stop installing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj |