diet-guard/docs/DOCS-meal-schedule.md
Krzysztof kuhy Rudnicki 2d468bf5f8 gate: HH:MM spinboxes instead of comboboxes for the meal schedule
An inert dropdown arrow looked broken. ttk.Spinbox posts nothing; every
spin path (arrow press, Up/Down, wheel) generates <<Increment>>/
<<Decrement>>, bound here to step_time from the typed value (07:23 -> up
07:30, down 07:15) with "break" so ttk's values/wrap logic never runs.
First clamps to 00:00-23:45, last to 00:15-23:45. Popdown-blocking code
removed; FakeSpinbox replaces FakeCombobox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 20:13:03 +02:00

6.3 KiB
Raw Permalink Blame History

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:<date> 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:<YYYY-MM-DD> 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.Spinboxes: 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). <<Increment>>/<<Decrement>> 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.