mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 18:13:55 +01:00
4e5c6b453e
66 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8bc1812147 |
slots: minute-of-day contract (phase 1 of 3: pure model + shared fixture)
Meal-slot checkpoints move from whole hours to minute-of-day ints
(0..1439) in the pure model and slot arithmetic of both languages:
MealSchedule(first_minute, last_minute, count) / (firstMinute:,
lastMinute:, count:), interior slots snapped to the 15-minute grid,
endpoints exact, integer-only. New: nearest_slot, satisfied_slots,
slot_fields / entry_slot_minute (additive "slot_min" wire field) and
schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour,
so whole-hour data stays byte-identical), parse_hhmm.
tests/fixtures/meal_schedule_vectors.json (scripts/
build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json
slot values regenerated as minutes.
Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications)
are intentionally broken until phase 2. patch-targets skipped: it fails
only because diet_guard._gatelock no longer imports.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
|
|||
| 5cd89a4e99 |
Update app/diet_guard (17 files)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
|||
| 11173c7088 |
kuchnia: walk every active order, not just the first
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
|||
| e761926df4 |
Body tab: weight/body-fat logs, BMI, ideal weight, goal-based kcal target, workouts + Health Connect steps, week planner
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 4b1de83e27 |
Pre-log meals for a future date and slot (CLI --date/--hour, app 'Log for later')
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| d2df4c51eb |
Record why the gate's delivery prefill must set the dish's grams as its basis
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| b3aeb4f4b7 |
Re-point paths for the ~ reorganisation (home-tidy)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9, mcp 2.2.0 and ruff 0.16.7 so the freshness gate lets this through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF |
|||
| 77468b1efd |
Bring the Flutter app's dependencies up to latest stable
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Split out from the free-day change only by sequence -- diet-guard's pre-push CI mirror runs the freshness gate over --all-files, so these were already blocking a push before that work started. permission_handler 13.0.1 -> 13.0.2 and workmanager 0.10.9 -> 0.10.10 are patch bumps. very_good_analysis ^10.3.0 -> ^11.0.0 is the major one, and it newly flags 8 async_return_with_no_await sites across the desktop wrapper and the sync/Firebase services. Each is now `return await`, not a suppression. The rule is right in every one of these cases: without the await the future escapes the enclosing async function, so a rejection surfaces at the caller with a stack trace that no longer points at the code that made the call. background_check_service.dart:33 is the clearest example -- its own comment says the push "is retried with backoff rather than silently dropped", which is only true if the failure happens inside the function. Nothing in CI or pre-commit runs `flutter analyze`, so this would not have failed anything. Leaving a newly-red analyzer for the next person to find is how a repo stops being analyzable at all. flutter analyze clean, 706 Flutter tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| c897279c8b |
fix: clear every macro field before a prefill, add the phone's delivery button
`fillControllersFromDish` set the dish's name, four macros and `grams` but left `perGrams` untouched. A `100` left there by an earlier food-bank pick made `nutritionForPortion` read the dish's per-portion macros as per-100g and rescale them: one real entry on 2026-08-23 logged 472 kcal as 1217.8 (258/100). The PC never had this -- `_gatelock_delivery.py` calls `_clear_inputs()` first -- so the fix is that same discipline, as one `MacroControllers.clear()` call rather than a `perGrams.clear()` line a seventh field could forget. `fillFrom` now clears too, and `edit_entry_screen` routes through it instead of six hand-written assignments; its `initState` fill had the same shape. The old tests asserted controller *text* and passed throughout the bug, so the new ones assert the computed `Nutrition` -- verified failing (400 -> 1200.0) before the fix. `clear()` gets an enumerated test so a new field fails until it is cleared too. Also adds the log screen's "Today's delivery" button, matching the PC lock screen's affordance: explicit taps are unguarded per the Triggers table, still skip dishes already logged, and show the queue count. The whole action row moved to its own widget, which put `log_meal_screen.dart` back under the cap. The label is a FittedBox in an Expanded -- measured, since a Row clips an oversized label without ever throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| 8836453f8c |
fix: initialise the catering queue at startup
`KuchniaQueueService` had no `init()` call in `main()`, so on a real device the whole feature would have done nothing: every entry point checks `isInitialized` first and returns quietly, which reads as "no delivery today" rather than as an error. All 694 tests passed throughout, because widget tests initialise the service directly. Caught by grepping main.dart before the phone deploy, not by the suite. So it is now a repo invariant: `repo_invariants_test.dart` asserts main() names both catering `init()` calls, and the assertion was checked against a main.dart with the line deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| af35eb20c8 |
feat: offer delivered dishes to the log form, one tap each
The phone now prefills the log form with today's delivered dishes, in the caterer's own meal order. It still never logs anything itself -- a delivered meal is not an eaten meal, so the user taps for each dish. Two guards carried over from the PC, both of which fail silently if dropped: - **The queue survives a submit.** `prefillNextDish` has two callers: the initial load and the post-submit advance. On the PC the second briefly did not exist, which made "(N more to go)" a dead letter -- every dish after the first waited behind another tap. The test asserts the *form controller* holds dish 2 after dish 1 is logged, not that a dish is "available"; the latter passes throughout that bug. - **The automatic refresh is guarded** by a device-local last-fetched date, mirroring `refresh_delivery_once`. The log-flow trigger can fire after every meal and each unguarded walk is a login plus three requests. The settings button deliberately stays unguarded: an explicit ask always goes and looks, matching `diet-guard kuchnia`. Only a clean fetch records the day, so an outage retries. Dishes are ordered against `MealScheduleService.current`, never a hardcoded four slots -- offering a dish for a slot the user's schedule does not have is the "checkpoint that can never be satisfied" failure in a new costume. Editing the description before submitting leaves the dish queued: what was logged was not the delivered dish, and marking it eaten would lose a real delivery silently. Two bugs the tests caught: - `dishesStillQueued` excluded the offered dish, so the status line read "0 more" while a real dish sat in the form. The offered dish is prefilled, not logged; it counts. - An end-to-end test through the real `LogMealScreen` never terminated -- that screen's `initState` fans out into sync, notifications and the food bank, and pumping it to quiescence hangs. Rather than keep fighting it, the behaviour is tested at the mixin that owns it, through a minimal host. The guarantee asserted is the same one: form contents after a submit. `log_meal_screen.dart` is back under the 250-line cap by moving the nutrition build into `log_meal_actions.dart` and inlining a one-line alias. 694 Dart tests, 1040 Python tests, 100% Python branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| 5c9c1ff160 |
feat: add the catering login to settings, with an Android-only fetch
The credential **fields** appear on every platform; the **fetch button** only where it can work. That split is the point, not an oversight: the panel sends no CORS headers so a browser cannot call it, but the desktop app is where a password is most likely to be typed, and the credential syncs — so hiding the fields on web would mean the phone could only ever be set up by typing on the phone. On web the button is replaced by a line saying why, and noting the login still reaches the phone from there. The section says the password is stored and synced in plain text rather than implying otherwise. The button disables itself while a fetch is in flight, so an impatient double-tap cannot pay a second login plus three requests against a third party. The result line says dishes were added to the **food bank** and that the user still logs them when eaten — the wording carries the delivered-is-not-eaten rule to the one place a user actually reads. `settings_screen.dart` was already at 247 of its 250-line budget, so the section owns its own leading divider and the fetch handler lives beside it; the screen spends one line on it. 678 Dart tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| 3d82ddefd4 |
feat: let the phone fetch and bank the catering delivery itself
The phone now walks the panel on its own -- login, active order, delivery, menu -- parses the dishes and banks them, so the feature works with the PC switched off. Read-only: nothing here logs a meal. `package:http` has no cookie jar, so the `SESSION` cookie is read off the login response and replayed by hand. `Response.headers` folds duplicate `set-cookie` values into one comma-joined string, so the parser splits on both `,` and `;` and looks for the named cookie rather than assuming the header holds only it -- tested against both orderings and against a panel that sets no session cookie at all. Android-only, as decided: the panel sends no CORS headers, so a browser cannot call it. `refreshDelivery` checks `kIsWeb` first and returns the unsupported reason without touching the network. No `dart:io` anywhere -- `package:http` compiles for web, it is the panel that refuses, not the toolchain, so this needs no `_io`/`_web` seam. The idempotency guard is ported with its teeth: `addManualEntry` restamps `editedAt` unconditionally, so re-banking an unchanged dish republishes the whole curated bank to every peer. `bankDishes` compares the nutritional fields first and returns a write *count*, which is what the tests assert on -- "the entry exists" passes while this misbehaves. The comparison is numeric, not string, so `270 == 270.0` is a match exactly as in Python's `_matches`. Every failure becomes a reason string, never a throw, so a catering outage cannot break the caller: a rejected login, a delivery whose dishes all fail the unit check, and missing credentials each have a test. One test bug worth noting: the assertion that nothing is logged was missing an `await` and so was comparing a Future against `isEmpty` -- the single most important guarantee in this file was passing vacuously until the split forced it to run. Test files split three ways for the 250-line cap; block counts checked against the pre-split file (16 either side), not just a green suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| 8905d072d0 |
feat: carry the catering credential to the phone
The Dart half of the credential sync: a `KuchniaCredentialService` that owns the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The phone can now receive the panel password, which is the prerequisite for it fetching anything itself. One source of truth, per the answered question: the value lives in the synced document store and nowhere else. Deliberately *not* mirrored into `flutter_secure_storage` -- two homes for one value is how a stale password survives a change made on the other device, and the credential has to be readable synchronously at merge time anyway. Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`, asserted by both suites. Two cases carry the weight: - an **unparsable** edit time must fall back to the same clock on both sides (Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns null). Verified they agree on `wall_time_ms == 0`; mutating that expectation fails both suites. - a **blank half** contributes nothing, so a half-filled settings form cannot push an empty-string password that wins LWW against a real one. `KuchniaCredentialService.init()` is called in the WorkManager isolate as well as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential` no-ops when the singleton is missing, so without it a background tick would neither publish this device's credential nor receive a peer's -- and on the phone the background tick is most of the syncing there is. Same class of trap as the `initSyncDeviceId()` one already documented there. `.toDouble()` came off the bank record: the fields are already `double`, so it was a no-op the analyzer flags. The field *types* are what keep the encoding byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that. 654 Dart tests, 1040 Python tests, 100% Python branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| 24be05b9c3 |
feat: mirror the catering parser and slot spread in Dart, gated by one fixture
The phone is getting its own Kuchnia Wikinga importer so the feature works with the PC switched off. That turns a risk `_kuchnia_spread.py` called "theoretical" into a live one: two implementations must now agree exactly on which dishes are dropped, which slot each lands on, and the JSON a banked record encodes to. Disagreement means each device re-adds what the other dropped, and since `add_manual_entry` restamps `t` unconditionally, the whole curated bank republishes to every peer on every refresh. A slot mismatch is worse -- a checkpoint one device offers and the other does not can never be satisfied. So the port lands with its gate rather than after it. One shared fixture, `tests/fixtures/kuchnia_day.json`, is asserted by both `pytest` and `flutter test`: same input, same expected dishes, drops, slots, bank keys and records. Two suites written independently from the same prose is not a gate. The fixture is five real dishes (a scrubbed 2026-08-22 capture) plus synthetic meals that each pin one drop or fallback path -- a per-100 g mix-up, an absurd portion, a stringly-typed number, a non-dict entry, a missing `nutrition`, a missing `mealPriority`, and a twin-dish pair. Without those, "the same meals are dropped" is a vacuous assertion against an all-happy-path capture. Both halves were mutation-checked: flipping an expected slot, drop count or record value fails each suite, and flipping `435.0` to `435` fails the byte-identity test while the value comparison still passes, which is why both exist. Three traps the port had to survive, each caught before it shipped: - `_ENERGY_TOLERANCE` is **0.35**. The prose said "~1%", which describes how closely the captured day agreed, not the threshold; porting 0.01 would drop dishes the PC keeps. That paragraph now says so. - `jsonEncode(435)` emits `435` where Python emits `435.0`, so every Dart macro is forced to `double` and a canonical-encoding test pins it. - Dart's `List.sort` is unstable where Python's `sorted` is stable, so the Dart comparator carries the payload index as a final tiebreak. The real dishes live in their own file rather than as a literal in the generator: transcribing them by hand corrupted three of the five, and the error was invisible until the generated fixture was diffed against the capture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX |
|||
| d02f6aa6e8 |
Rebuild the slot row when settings changes the schedule
Found on the phone, not in the suite. Setting five meals updated the settings
preview and fired a reminder for the new 14:00 checkpoint, while the slot row
behind them kept showing the old 08/12/16/20 until the app was restarted:
onOpenSettings pushed the route without awaiting the pop, so the log screen
never rebuilt against the schedule the user had just chosen.
It now awaits and calls onScheduleMayHaveChanged, which re-reads the logged
slots and re-derives the selected slot -- the hour it pointed at may not be a
checkpoint at all any more. The other three destinations still fire and
forget, since none of them can change what this screen renders.
The regression test was confirmed to fail against the pre-fix behaviour
("Found 0 widgets with text 11:00") rather than passing vacuously.
Both files crossed the 250-line cap with the addition, so the hook moved to
log_meal_sync_mixin.dart -- which already owns refreshSlots and loggedSlots
and is the better home regardless -- and the new test into its own file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
|
|||
| da8822006f |
Let the phone set the eating window and meal count
The first edit surface, landing after the sync layer so a schedule set here reaches the PC rather than leaving the two devices deriving different slots. Three dropdowns -- first meal, last meal, meals per day -- and a live preview of the checkpoints they derive. Setting 08:00, 20:00 and 5 shows 08:00 · 11:00 · 14:00 · 17:00 · 20:00, which is asserted in the screen test rather than only in the model's. Dropdowns rather than showTimePicker: that returns a TimeOfDay with minutes, and slots are whole hours on both devices by design. They also make the invalid states unrepresentable instead of validated -- "last meal" offers only hours after "first", and "meals per day" only counts the window has whole hours for. MealSchedule.normalized() still clamps, but that is the defence against corrupt synced data, not the input contract. Saving re-runs the due-slot check, because reminder ids are slot hours: a schedule change orphans the ids it no longer contains, and waiting for the next background tick would leave a stale nag on screen for up to 15 minutes. The UI is a `part` mixin like settings_kcal_goal.dart -- settings_screen.dart was at 238 of the 250-line cap and is now at 247. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM |
|||
| 24dbffc432 |
Sync the meal schedule as sched: fields on the budget record
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.
The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.
Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
{"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.
Two guards worth naming:
- A device with no history contributes no `sched:` fields at all, so an unset
default can never outrank a peer's real schedule; and an empty merge result
is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
Log and would otherwise drop the schedule fields too. Its budget value
carries an epoch stamp, so it loses every LWW race against a real edit.
sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.
772 Python tests at 100% branch coverage, 619 Dart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
|
|||
| 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 |
|||
| 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
|
|||
| 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 |
|||
| 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
|
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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. |
|||
| 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). |
|||
| 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> |