mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 17:33:55 +01:00
cc78dd383b
68 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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 |
|||
| 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> |
|||
| a106970b4f |
theme.dart: define secondary/tertiary ColorScheme roles
Found via testing todo's app, which shares this theme.dart template and has a SegmentedButton: neither ColorScheme.dark() constructor call defined secondary/tertiary (or their container/on- variants), so any M3 widget reaching for secondaryContainer silently falls back to Flutter's stock Material teal instead of erroring. Confirmed this app has the same live bug — ChoiceChip (slot selector) uses secondaryContainer implicitly, and FilterChip's "Any"/"With photo"/"Without photo" chips in the History filter sheet were rendering in stock teal in production right now. The shared palette has one accent, not a separate secondary hue, so these mirror primary; secondaryContainer/tertiaryContainer use line-dark as a distinguishable-but-subtle selected-state fill. Verified: flutter analyze clean, 436/436 tests, re-verified identical in an isolated worktree, and confirmed live: rebuilt debug + release APKs, installed via adb install -r (no data wipe), screenshotted the real device before/after (the History filter chips went from teal to warm gold/tan), then rebuilt and redeployed the desktop package (install_arch.sh -> pacman -U) and confirmed the running desktop window renders identically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| c935a202cc |
Make AppStatusColors lookup null-safe; fix tests that never had it
Theme.of(context).extension<AppStatusColors>()! crashed in any context that doesn't build its theme from buildAppTheme() -- which is most widget tests, since they wrap in a bare MaterialApp(home: ...) rather than the real app theme. That crash was latent since AppStatusColors was introduced but only became reachable once main.dart actually started using buildAppTheme() (previous commit), which is when 27-47 tests started failing. Fixed at both call sites (slot_selector_row.dart, day_status_calendar.dart) by falling back to AppStatusColors.dark instead of force-unwrapping -- production code should never trust every context to fully replicate the real theme. Also fixed day_status_calendar_test.dart, which asserted against stale hardcoded Colors.* values from before the theme rewrite; it now wraps in the real buildAppTheme() and asserts against the actual tokens, so the assertions mean something again. Verified: flutter analyze clean, 436/436 tests pass (up from 389/47). |
|||
| 0e9acb9e18 |
Wire theme.dart into the app; replace remaining raw Colors.* literals
main.dart now builds MaterialApp's theme from buildAppTheme() instead of ColorScheme.fromSeed(Colors.teal) -- theme.dart existed but was never actually used until this commit. photo_viewer_screen.dart, settings_screen.dart, and day_status_calendar.dart's raw Colors.* literals (Colors.black, Colors.red, Colors.white38, Colors.grey.shade800, ...) now read from Theme.of(context) instead. Also rewrites CLAUDE.md's Visual Design & Theming section, which previously contradicted the unified-design-system skill (noise texture, multi-layered shadows, ColorScheme.fromSeed -- all things that skill explicitly forbids). This was the other prerequisite the on-fill/warm-palette fixes actually needed: theme.dart existing but unwired meant the app was still running Flutter's default seeded theme. |
|||
| 26f95d42f5 |
Fix stale adaptive_icon_background left over from the warm-palette icon regen
pubspec.yaml's adaptive_icon_background is a separate literal from python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated every generated PNG but missed this one, so the actual on-device home screen icon (Android's adaptive icon, not the legacy mipmap) stayed on the old cool charcoal. Caught on the physical device, not by file checks. testsAndMisc's generator now syncs this automatically going forward. |
|||
| 2a772d624f |
app: warm-tint the shared palette, fix Snack chip + on-fill contrast
- theme.dart: warm unified-design-system palette, on-fill for onPrimary/onError, and inputDecorationTheme/dividerTheme (previously unset -> every TextField/Divider fell back to Material defaults). - slot_selector_row.dart: the Snack ChoiceChip had no explicit backgroundColor/side/labelStyle like the hour chips, so it fell through to Flutter's own default -- a literal pure-white border. - Regenerated launcher icon assets (warm charcoal + warm gold accent) via python_pkg/app_icons. - Bump gatelock pin to the released v0.1.1 carrying this same palette. Verified: flutter analyze clean, diet_guard pytest 443/443 @ 100% coverage, phone-installed APK pixel-checked (Snack chip border, on-fill contrast). |
|||
| 50f311b6b8 |
Gate the companion app in the push hook, and document it properly
`ci_mirror.sh` -- the pre-push gate -- ran only the Python side: a clean-venv pytest and `pre-commit --all-files`. Nothing ran the app's suite, and `.pre-commit-config.yaml` has no Dart hooks at all, so a red `app/` could be pushed with a fully green gate. It now runs `flutter analyze` and `flutter test` too, and *fails* rather than skips when `app/` exists but flutter is missing: a gate that quietly passes without its toolchain is the same as no gate. Verified by breaking a Dart file on purpose and watching the push gate refuse it. app/README.md was still the stock "A new Flutter project" template. It now says what the two targets are, why there is no `linux/`, what the wrapper owns, why the port and Chrome profile are fixed, and which behaviours the desktop genuinely gives up. Drops MIGRATION_PROMPT.md, whose content now lives in CLAUDE.md and the migration commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
|||
| 2231850bfc |
app: test the IndexedDB stores instead of shipping them unverified
The IndexedDB stores are the desktop device's primary copy of the food log and the *only* copy of its photos -- photos never sync -- and their wrapper-mirror fallback is what makes a wiped Chrome profile recoverable. All of that was invisible to the suite: the files import `idb_browser.dart`, which cannot be imported outside a web compile, so they were simply absent from the VM test run rather than covered. Splitting the store classes from the browser-factory `open…()` functions lets them run against `idb_shim`'s in-memory backend on the plain VM, which is what the flutter-desktop-to-chrome skill recommends the shim for. Twelve tests now pin the round-trip, the mirror write, the recover-from-mirror path, and the no-wrapper-running case (a plain browser tab, where the app must still work). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
|||
| c12faa3920 |
app: package the desktop app as the web build, and delete the Linux embedder
Completes the move: `linux/` is gone, replaced by the web build served by the
wrapper in a Chrome `--app` window. The icons and the .desktop installer move
out of `linux/` first -- they are not Flutter scaffolding and are still
needed; the entry now points at the packaged launcher and matches the
`--class=diet_guard_app` the wrapper passes, so the taskbar shows the app's
icon rather than a browser's.
install_arch.sh carries over the three traps each of which produced an
install that looked fine until launched: `dart build cli` rather than
`dart compile exe` (which refuses any package whose dependency graph has
native build hooks), `options=('!strip')` (makepkg would otherwise discard
the AOT snapshot, leaving a binary that runs as the bare Dart VM printing a
usage message), and no browser in `depends` (this machine's policy uninstalls
`chromium` seconds later, failing dependency resolution).
Also adds the frame-cadence instrument the performance claim is measured
with, armed by `--dart-define=DIET_GUARD_FRAME_STATS=1` and read with
`String.fromEnvironment` -- `bool.fromEnvironment` only accepts the literal
`true`, so `=1` would evaluate to false and a whole run would silently
produce no data. It reports frame-to-frame cadence rather than build/raster
durations, since work that fits the budget says nothing about the frame rate
actually achieved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|||
| aeb9376391 |
app: add the desktop wrapper that serves the web build and fronts GitHub
A browser cannot touch the filesystem, so the desktop app needs a second
half. This local process serves the built web assets, owns an on-disk copy of
every document and photo blob the app stores, and performs the GitHub I/O the
page cannot.
Both GitHub responsibilities are load-bearing, not convenience:
- the device-flow endpoints (github.com/login/device/*) send no CORS headers,
so a page cannot call them at all;
- proxying api.github.com server-side is what keeps the token out of the
browser -- the page is told `{"status":"ok"}`, never the token itself.
The wrapper falls back to the PC gate's existing
`~/.config/diet_guard/sync_token`, so this machine needs no second setup.
Details learned the hard way in the two apps migrated before this one, kept
here deliberately: loopback-only binding (the endpoints overwrite files in
$HOME with no auth), `application/wasm` for .wasm or CanvasKit renders
nothing at all, a fixed port and Chrome profile because IndexedDB is keyed by
origin, and treating a browser exit inside ~5s as a handoff to an existing
window rather than a reason to stop serving. Mirror names are rejected unless
they are plain filenames -- a `..` segment would turn a mirror write into an
arbitrary-file overwrite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|