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

105 lines
6.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.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). `<<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.