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
6.3 KiB
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 NOTentry inCLAUDE.md. Python'sroundis banker's and Dart's is half-away-from-zero, and Python's//floors where Dart's~/truncates, so every division runs on a valuenormalized()has already pulled into the day. The shared vectors intests/fixtures/meal_schedule_vectors.json(regenerated byscripts/build_meal_schedule_fixture.py) are asserted bytest_meal_schedule_vectors.pyandmeal_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 - firstandd = count - 1, interior slot i israw = first + (i*span + d//2) // d, snapped by(raw + 7) // 15 * 15. The first and last meal are exactly what the user typed, even07:23. - Counts are clamped to the window, in 15-minute steps:
count <= min(6, (last - first) // 15 + 1), andlast >= 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 + 120minutes, 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 andminute < cutoffis 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 // 60and addsslot_min = minuteonly whenminute % 60 != 0(_slot_wire.slot_fields/slotFields). Readers prefer an intslot_min, elseslot * 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 addsslot_minbefore signing: the HMAC covers the whole entry. - A schedule history entry (
sched:<date>on thebudgetrecord, and the.meal_schedulefile) carriesf/las hours andn, plusfm/lmminutes only for an off-hour endpoint (schedule_to_wire/scheduleToWire).fm/lmwin when present. boolis never an int, on either side: Python'sboolsubclassesint, Dart's does not, and a value one device accepts while the other ignores is a split brain.
- A log entry always carries
- 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 under07:00–19:00 × 5still cover 07:15 and 10:15 after a move to07: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.