# The meal schedule (`_meal_schedule.py` / `meal_schedule.dart`) _Split out of `CLAUDE.md` to keep it under the repo's 250-line cap._ Every slot is a **minute of day**, an int in `0..1439`. The user sets a first meal and a last meal (any `HH:MM`) and how many meals fall between them; the intermediate checkpoints are derived by even division and rounded to the 15-minute grid. `MealSchedule(480, 1200, 5)` → `08:00, 11:00, 14:00, 17:00, 20:00`; `MealSchedule(435, 1140, 5)` → `07:15, 10:15, 13:15, 16:00, 19:00`. The default `MealSchedule(480, 1200, 4)` reproduces the hours that used to be hardcoded, so an existing install sees no change until it is edited. The fields are `first_minute`/`last_minute` (`firstMinute`/`lastMinute`), deliberately renamed from the old hour-valued `first`/`last` so the type checker enumerated every caller still thinking in hours. ## The rules, each load-bearing - **Integer arithmetic only** — see the `Do NOT` entry in `CLAUDE.md`. Python's `round` is banker's and Dart's is half-away-from-zero, and Python's `//` floors where Dart's `~/` truncates, so every division runs on a value `normalized()` has already pulled into the day. The shared vectors in `tests/fixtures/meal_schedule_vectors.json` (regenerated by `scripts/build_meal_schedule_fixture.py`) are asserted by `test_meal_schedule_vectors.py` *and* `meal_schedule_vectors_test.dart`; that is what catches a cross-language divergence before it reaches a device. - **Interior slots round to the 15-minute grid; the endpoints never do.** With `span = last - first` and `d = count - 1`, interior slot *i* is `raw = first + (i*span + d//2) // d`, snapped by `(raw + 7) // 15 * 15`. The first and last meal are exactly what the user typed, even `07:23`. - **Counts are clamped to the window, in 15-minute steps:** `count <= min(6, (last - first) // 15 + 1)`, and `last >= first + 15`. Two checkpoints closer than one grid step would snap onto the same mark, and slot minutes 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 + 120` minutes, clamped to 1440.** It is a grace period for a late dinner, deliberately independent of meal count. Unclamped, a 23:00 last meal gives 1500 and `minute < cutoff` is vacuously true — the window never closes and the gate can never stop firing. - **The wire is additive; whole hours are byte-identical to the old format.** - A log entry always carries `slot = minute // 60` and adds `slot_min = minute` only when `minute % 60 != 0` (`_slot_wire.slot_fields` / `slotFields`). Readers prefer an int `slot_min`, else `slot * 60` (`entry_slot_minute` / `entrySlotMinute`). A whole-hour entry is exactly what an old build wrote — no re-signing storm — and an old build reading an off-hour entry sees the hour it starts in. The writer adds `slot_min` **before** signing: the HMAC covers the whole entry. - A schedule history entry (`sched:` on the `budget` record, and the `.meal_schedule` file) carries `f`/`l` as hours and `n`, plus `fm`/`lm` minutes only for an off-hour endpoint (`schedule_to_wire` / `scheduleToWire`). `fm`/`lm` win when present. - `bool` is never an int, on either side: Python's `bool` subclasses `int`, Dart's does not, and a value one device accepts while the other ignores is a split brain. - **A logged meal satisfies the slot nearest its recorded minute** (`satisfied_slots` / `satisfiedSlots`; an exact tie goes to the *earlier* slot). Nothing on disk is rewritten when the schedule changes: meals logged at 07:00 and 10:00 under `07:00–19:00 × 5` still cover 07:15 and 10:15 after a move to `07:15–19:00 × 5`, so nothing re-opens. Every "is this slot logged?" site goes through it — the gate (`logged_slots_today`), the CLI status, MCP, and the catering import's occupancy and dedup (`_slot_wire.satisfied_by_entries`). Many-to-one is intended: two meals that snap onto one slot satisfy only that slot. `_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 slots 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`. ## History and editing 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`, widgets in `_gatelock_calendar_schedule.py`). The gate's first/last fields are `ttk.Spinbox`es: any typed `HH:MM`, and the arrow buttons, Up/Down and the wheel step to the neighbouring quarter hour from the typed value (07:23 → 07:30 / 07:15), clamped to 00:00–23:45 (the last meal to 00:15–23:45). `<>`/`<>` are bound and answered with `"break"`, so ttk's own values/wrap logic never runs. **No popups on the lock:** a `ttk.Combobox` was tried first, and its popdown mapped *beneath* the lock surface (gatelock keeps it raised) while still holding ttk's grab — invisible, it swallowed the next click and stole keystrokes. `diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on every grid slot exactly; an off-grid first/last meal is caught by the next tick. **Notification ids are slot minutes.** The app's `syncToSlots` must sweep the whole id space — every minute id *and* the legacy `0..23` hour ids an older build scheduled — not just the current schedule's slots. Otherwise a schedule change (or the upgrade itself) orphans ids it no longer contains, and they nag forever.