diff --git a/CLAUDE.md b/CLAUDE.md index 22acc8f..2e3e661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ # CLAUDE.md — diet_guard A log-to-unlock gate. Every ~30 min `diet-guard-gate.timer` checks whether a -meal slot (08:00, 12:00, 16:00, 20:00) elapsed unlogged; if so it opens a +meal slot (08:00, 12:00, 16:00, 20:00 by default — the schedule is +user-configurable, see below) elapsed unlogged; if so it opens a fullscreen Tk window that blocks the desktop until the user logs what they ate. It also tracks a daily calorie/macro budget, seeded from biometrics at `init` and freely editable afterward on either device. A History tab @@ -166,6 +167,16 @@ changing either hides the entire local food log). is `diet-guard-gate.timer`. - Never run `flutter create --platforms linux` in `app/`. +## The meal schedule + +The slot hours are user-configurable: a first meal, a last meal, and how +many meals fall between them, evenly divided. Default `(8, 20, 4)` is the +old hardcoded 08/12/16/20. Four load-bearing rules (integer-only +arithmetic, whole-hour slots, the count clamp, the `last + 2h` cutoff), the +forward-only history, and the two edit surfaces are documented in +[docs/meal-schedule.md](docs/meal-schedule.md) — read it before touching +`_slots.py` / `slot.dart`. + ## Averages (`_averages.py` / `average_service.dart`) Three load-bearing rules; changing any silently changes what "under budget" @@ -187,9 +198,15 @@ phone show both. ## Do NOT - Don't relax the meal-slot logic without re-reading `docs/design.md`. The - off-hours clamp in `slot_for_log`/`slotForLog` (before 08:00 → 08:00, after - 22:00 → 20:00) must stay byte-identical across Python and Dart; do NOT widen - `elapsed_slots`, which would make every slot fall due at 23:00. + off-hours clamp in `slot_for_log`/`slotForLog` (before the first slot → the + first slot, after the enforcement window → the last slot) must stay + byte-identical across Python and Dart; do NOT widen `elapsed_slots`, which + would make every slot fall due at the end of the day. +- Don't reintroduce floating point into the slot derivation. Python's `round` + is banker's (`round(2.5) == 2`), Dart's is half-away-from-zero + (`2.5.round() == 3`), so a float path silently desyncs the two devices — + and a slot one device offers while the other does not is a checkpoint that + can never be satisfied, i.e. a permanent lock. Integer `//` and `~/` only. - Don't re-add the meal builder, "repeat last meal", the reward prompt, or **meal photos** — all removed deliberately, enforced by `app/test/repo_invariants_test.dart`. Photos also took `image_picker`, the diff --git a/app/pubspec.yaml b/app/pubspec.yaml index de59889..f345671 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.36+1 +version: 1.0.37+1 environment: sdk: ^3.12.2 diff --git a/docs/meal-schedule.md b/docs/meal-schedule.md new file mode 100644 index 0000000..0d62eba --- /dev/null +++ b/docs/meal-schedule.md @@ -0,0 +1,51 @@ +# The meal schedule (`_meal_schedule.py` / `meal_schedule.dart`) + +_Split out of `CLAUDE.md` to keep it under the repo's 250-line cap._ + +The user sets a first meal hour, a last meal hour, and how many meals fall +between them; the intermediate checkpoints are derived by even division. +`MealSchedule(8, 20, 5)` → `08:00, 11:00, 14:00, 17:00, 20:00`. The default +`MealSchedule(8, 20, 4)` reproduces the hours that used to be hardcoded, so an +existing install sees no change until it is edited. + +Four rules, each load-bearing: + +- **Integer arithmetic only** — see the `Do NOT` entry below. The shared + test-vector table plus an identical 0..23 × 0..23 × 2..6 sweep in + `test_meal_schedule.py` and `meal_schedule_test.dart` is what catches a + cross-language divergence before it reaches a device. +- **Slots stay whole hours.** Changing that changes the on-disk log's `slot` + tag and the sync wire format, on both devices. +- **Counts are clamped to the window width** (`last - first + 1`), because a + narrow window would otherwise round two meals onto the same hour, and slot + hours are set members, dict keys *and* notification ids — a duplicate + silently drops a checkpoint. The two edit surfaces reject such input with a + message; `normalized()` clamps it, which is the defence against a peer's + corrupt sync data rather than the input contract. +- **The enforcement cutoff is `last + 2h`, clamped to midnight.** It is a + grace period for a late dinner, deliberately independent of meal count. + Unclamped, a 23:00 last meal gives 25 and `hour < cutoff` is vacuously + true — the window never closes. + +`_slots.py` / `slot.dart` take the schedule as a **required** argument. A +default there would let a call site missed in a refactor keep deriving the old +fixed hours on one device only, which is the split brain that can lock the +user out; making it required lets mypy and `flutter analyze` enumerate the +sites. Callers resolve it at the impure edge through +`_meal_schedule_store.current_schedule()` / `MealScheduleService.current`. + +The history is forward-only (`_meal_schedule_store.py` / +`meal_schedule_history.dart`), so switching from four meals to five does not +retroactively mark past days as having missed a checkpoint. It seeds the +default at `1970-01-01` **before** recording today's value — same ordering +requirement as `_budget_history.py`. It syncs as `sched:` fields +on the existing `budget` CRDT record, so devices predating the feature relay +them untouched. + +Editable on both surfaces: the app's Settings screen +(`settings_meal_schedule.dart`) and the gate's History tab +(`_gatelock_scheduleedit.py`). + +**Notification ids are slot hours.** `syncToSlots` must sweep the whole 0..23 +id space, not the current schedule's slots — otherwise a schedule change +orphans the ids it no longer contains and they nag forever.