Document the configurable meal schedule

The rules that are expensive to rediscover: integer-only arithmetic and why
(Python and Dart round differently), the count clamp and why a duplicate slot
hour is worse than it looks, the last+2h cutoff and its midnight clamp, and
the required-argument decision in _slots.py / slot.dart.

Split into docs/meal-schedule.md rather than inlined -- CLAUDE.md hit 276
lines against the 250 cap, the same split the Flutter rules already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 15:03:59 +02:00
parent c0564c05a2
commit ed6d283ff8
3 changed files with 73 additions and 5 deletions

View File

@ -1,7 +1,8 @@
# CLAUDE.md — diet_guard
A log-to-unlock gate. Every ~30 min `diet-guard-gate.timer` checks whether a
meal slot (08:00, 12:00, 16:00, 20:00) elapsed unlogged; if so it opens a
meal slot (08:00, 12:00, 16:00, 20:00 by default — the schedule is
user-configurable, see below) elapsed unlogged; if so it opens a
fullscreen Tk window that blocks the desktop until the user logs what they
ate. It also tracks a daily calorie/macro budget, seeded from biometrics at
`init` and freely editable afterward on either device. A History tab
@ -166,6 +167,16 @@ changing either hides the entire local food log).
is `diet-guard-gate.timer`.
- Never run `flutter create --platforms linux` in `app/`.
## The meal schedule
The slot hours are user-configurable: a first meal, a last meal, and how
many meals fall between them, evenly divided. Default `(8, 20, 4)` is the
old hardcoded 08/12/16/20. Four load-bearing rules (integer-only
arithmetic, whole-hour slots, the count clamp, the `last + 2h` cutoff), the
forward-only history, and the two edit surfaces are documented in
[docs/meal-schedule.md](docs/meal-schedule.md) — read it before touching
`_slots.py` / `slot.dart`.
## Averages (`_averages.py` / `average_service.dart`)
Three load-bearing rules; changing any silently changes what "under budget"
@ -187,9 +198,15 @@ phone show both.
## Do NOT
- Don't relax the meal-slot logic without re-reading `docs/design.md`. The
off-hours clamp in `slot_for_log`/`slotForLog` (before 08:00 → 08:00, after
22:00 → 20:00) must stay byte-identical across Python and Dart; do NOT widen
`elapsed_slots`, which would make every slot fall due at 23:00.
off-hours clamp in `slot_for_log`/`slotForLog` (before the first slot → the
first slot, after the enforcement window → the last slot) must stay
byte-identical across Python and Dart; do NOT widen `elapsed_slots`, which
would make every slot fall due at the end of the day.
- Don't reintroduce floating point into the slot derivation. Python's `round`
is banker's (`round(2.5) == 2`), Dart's is half-away-from-zero
(`2.5.round() == 3`), so a float path silently desyncs the two devices —
and a slot one device offers while the other does not is a checkpoint that
can never be satisfied, i.e. a permanent lock. Integer `//` and `~/` only.
- Don't re-add the meal builder, "repeat last meal", the reward prompt, or
**meal photos** — all removed deliberately, enforced by
`app/test/repo_invariants_test.dart`. Photos also took `image_picker`, the

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.36+1
version: 1.0.37+1
environment:
sdk: ^3.12.2

51
docs/meal-schedule.md Normal file
View File

@ -0,0 +1,51 @@
# The meal schedule (`_meal_schedule.py` / `meal_schedule.dart`)
_Split out of `CLAUDE.md` to keep it under the repo's 250-line cap._
The user sets a first meal hour, a last meal hour, and how many meals fall
between them; the intermediate checkpoints are derived by even division.
`MealSchedule(8, 20, 5)` → `08:00, 11:00, 14:00, 17:00, 20:00`. The default
`MealSchedule(8, 20, 4)` reproduces the hours that used to be hardcoded, so an
existing install sees no change until it is edited.
Four rules, each load-bearing:
- **Integer arithmetic only** — see the `Do NOT` entry below. The shared
test-vector table plus an identical 0..23 × 0..23 × 2..6 sweep in
`test_meal_schedule.py` and `meal_schedule_test.dart` is what catches a
cross-language divergence before it reaches a device.
- **Slots stay whole hours.** Changing that changes the on-disk log's `slot`
tag and the sync wire format, on both devices.
- **Counts are clamped to the window width** (`last - first + 1`), because a
narrow window would otherwise round two meals onto the same hour, and slot
hours 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 + 2h`, clamped to midnight.** It is a
grace period for a late dinner, deliberately independent of meal count.
Unclamped, a 23:00 last meal gives 25 and `hour < cutoff` is vacuously
true — the window never closes.
`_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 hours 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`.
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`).
**Notification ids are slot hours.** `syncToSlots` must sweep the whole 0..23
id space, not the current schedule's slots — otherwise a schedule change
orphans the ids it no longer contains and they nag forever.