mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
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
105 lines
6.3 KiB
Markdown
105 lines
6.3 KiB
Markdown
# 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.
|