diff --git a/AGENTS.md b/AGENTS.md index 22fe61b..a2ccec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # CLAUDE.md — diet_guard -A log-to-unlock gate. Every ~30 min `diet-guard-gate.timer` checks whether a +A log-to-unlock gate. Every ~15 min `diet-guard-gate.timer` checks whether 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 @@ -29,7 +29,7 @@ catch-up rule). ## Scheduling -`diet-guard-gate.timer` is wall-clock (`OnCalendar=*-*-* *:00/30:00`, +`diet-guard-gate.timer` is wall-clock (`OnCalendar=*-*-* *:00/15:00`, `Persistent=true`), not boot-relative — a boot-relative timer collided with fullscreen games grabbing input. `diet-guard-gate.service` is `Type=oneshot` and exits 0 immediately when no lock is due. It needs `DISPLAY`/`XAUTHORITY`; @@ -178,13 +178,14 @@ changing either hides the entire local food log). ## 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/DOCS-meal-schedule.md](docs/DOCS-meal-schedule.md) — read it before touching -`_slots.py` / `slot.dart`. +Slots are **minutes of day**: a first meal and a last meal (any `HH:MM`) and +how many meals fall between them; interior slots round to the 15-min grid. +Default `(480, 1200, 4)` is the old hardcoded 08/12/16/20. The load-bearing +rules (integer-only arithmetic, the additive `slot`+`slot_min` / +`f`/`l`/`n`+`fm`/`lm` wire, the count clamp, the `last + 120 min` cutoff, +nearest-slot satisfaction), the forward-only history, and the two edit +surfaces are in [docs/DOCS-meal-schedule.md](docs/DOCS-meal-schedule.md) — +read it before touching `_slots.py` / `_slot_wire.py` / `slot.dart`. ## Averages (`_averages.py` / `average_service.dart`) diff --git a/README.md b/README.md index 914beaa..406ab7b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The band boundaries are the calendar's own (at or under budget / up to 20% over / more than 20% over), so a period and its day cells can never disagree about what "over" means. -The timer runs the gate automatically every ~30 minutes; no manual +The timer runs the gate automatically every ~15 minutes; no manual invocation is needed once installed. ## MCP server diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c33caf7..463443c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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.106+1 +version: 1.0.107+1 environment: sdk: ^3.12.2 diff --git a/diet-guard-gate.service b/diet-guard-gate.service index ee29192..75389c2 100644 --- a/diet-guard-gate.service +++ b/diet-guard-gate.service @@ -7,7 +7,7 @@ Type=oneshot # DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock # is due, so without DISPLAY it would crash with "no display name and no # $DISPLAY" before it could even check. The command self-checks gate_is_due() -# and exits 0 when no lock is needed, so running it every ~30 min is cheap. +# and exits 0 when no lock is needed, so running it every ~15 min is cheap. # No PYTHONPATH needed: diet_guard is pip-installed (see install.sh / README), # so /usr/bin/python finds it via user site-packages. # diff --git a/diet-guard-gate.timer b/diet-guard-gate.timer index f73a29d..df39290 100644 --- a/diet-guard-gate.timer +++ b/diet-guard-gate.timer @@ -2,10 +2,13 @@ Description=Periodically run the Diet Guard gate check [Timer] -# Check on a WALL-CLOCK schedule (every 30 min, on the hour and half-hour). -# The 5-hour "log a meal" threshold and the 08:00-22:00 eating window are -# enforced inside gate_is_due(), so the timer only needs to fire often enough to -# notice the boundary; 30 min is plenty. +# Check on a WALL-CLOCK schedule (every 15 min: :00, :15, :30, :45). +# The meal slots and the enforcement window are enforced inside gate_is_due(), +# so the timer only needs to fire often enough to notice a slot opening. Slots +# can sit on any quarter hour (interior slots round to the 15-minute grid, and +# a 07:15 first meal is common), so a 30-min tick would let a slot go +# unenforced for up to half an hour; 15 min lands on every grid slot exactly. +# A first/last meal typed off the grid (07:23) is caught by the next tick. # # Why OnCalendar and not OnBootSec/OnUnitActiveSec: the gate is a *blocking* # oneshot -- it stays up until a meal is logged -- and a monotonic schedule @@ -13,7 +16,7 @@ Description=Periodically run the Diet Guard gate check # reboot), leaving the timer dormant (NextElapse=infinity). OnCalendar re-arms # every period regardless of how long the gate stayed open, and Persistent # catches a run missed while the machine was suspended. -OnCalendar=*-*-* *:00/30:00 +OnCalendar=*-*-* *:00/15:00 Persistent=true [Install] diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index ff01e73..a9379cd 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -116,10 +116,11 @@ def _print_entry_line(entry: dict[str, object]) -> None: def _print_slot_status() -> None: """Print each meal slot as logged / DUE / upcoming for today.""" - logged = logged_slots_today() + schedule = current_schedule() + logged = logged_slots_today(schedule) due = set(due_slots()) parts: list[str] = [] - for slot in day_slots(current_schedule()): + for slot in day_slots(schedule): if slot in logged: mark = "logged" elif slot in due: diff --git a/diet_guard/_cli_args.py b/diet_guard/_cli_args.py index 4d06b5a..0016143 100644 --- a/diet_guard/_cli_args.py +++ b/diet_guard/_cli_args.py @@ -87,9 +87,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace: ) ate.add_argument( "--hour", - type=int, + type=str, default=None, - help="Slot hour on --date this meal satisfies (e.g. 20); requires --date.", + help="Meal slot on --date this meal satisfies, as HH:MM or HH (e.g." + " 07:15 or 20); must be one of that day's slots. Requires --date.", ) sub.add_parser("status", help="Show today's calories and budget band.") diff --git a/diet_guard/_cli_kuchnia.py b/diet_guard/_cli_kuchnia.py index 18a9467..69935a2 100644 --- a/diet_guard/_cli_kuchnia.py +++ b/diet_guard/_cli_kuchnia.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING from diet_guard._kuchnia_spread import assign_slots from diet_guard._meal_schedule_store import current_schedule -from diet_guard._slots import day_slots +from diet_guard._slots import day_slots, slot_label from diet_guard._state import now_local if TYPE_CHECKING: # pragma: no cover - typing only @@ -99,7 +99,7 @@ def cmd_kuchnia( emit(f"{len(dishes)} dish(es) delivered today, banked:") for item in slotted: emit( - f" {item.slot:02d}:00 {item.dish.name} " + f" {slot_label(item.slot)} {item.dish.name} " f"({item.dish.kcal:g} kcal, {item.dish.grams:g} g)", ) total = sum(dish.kcal for dish in dishes) diff --git a/diet_guard/_cli_log.py b/diet_guard/_cli_log.py index 8339987..2d113e2 100644 --- a/diet_guard/_cli_log.py +++ b/diet_guard/_cli_log.py @@ -22,7 +22,8 @@ from diet_guard._kuchnia_import import refresh_delivery_once from diet_guard._meal_schedule_store import current_schedule from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams from diet_guard._resolve import ManualMacros, resolve_nutrition -from diet_guard._slots import resolve_future_when, slot_for_log +from diet_guard._slot_wire import parse_hhmm +from diet_guard._slots import resolve_future_when, slot_for_log, slot_label from diet_guard._state import log_meal, now_local from diet_guard._sync_events import publish_after_log_detached @@ -66,15 +67,17 @@ class Portion: for a per-100 g label), or None to treat the macros as totals. date: Log against this future date (``YYYY-MM-DD``) instead of now. Must be given together with ``hour``. None means "now". - hour: The slot hour on ``date`` this meal satisfies. Required - together with ``date``. + hour: The slot on ``date`` this meal satisfies, as the user typed it + (``HH:MM`` or ``HH``; parsed by + :func:`diet_guard._slot_wire.parse_hhmm`). Required together with + ``date``. """ grams: float | None count: float | None per_grams: float | None date: str | None = None - hour: int | None = None + hour: str | None = None def eaten_grams( @@ -116,13 +119,15 @@ def _resolve_target( limit. Raises: - ValueError: ``portion.date``/``portion.hour`` are an invalid future + ValueError: ``portion.hour`` is not a clock time, or + ``portion.date``/``portion.hour`` are an invalid future combination -- see :func:`diet_guard._slots.resolve_future_when`. """ if portion.date is None or portion.hour is None: return None, slot_for_log(now_local(), schedule) - when = resolve_future_when(portion.date, portion.hour, schedule, now_local()) - return when, slot_for_log(when, schedule) + slot = parse_hhmm(portion.hour) + when = resolve_future_when(portion.date, slot, schedule, now_local()) + return when, slot def cmd_ate( @@ -195,7 +200,7 @@ def cmd_ate( emit( f"logged: {description} {nutrition.kcal:g} kcal " f"({macro_str}) [{nutrition.source}, {portion_str}]" - f"{f' for {portion.date} {portion.hour:02d}:00' if when else ''}", + f"{f' for {portion.date} {slot_label(slot)}' if when else ''}", ) # Publish straight away rather than waiting for a periodic tick: until this # lands, the phone still believes this slot is unlogged and will nag for it. diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index 25b6c7f..82320ec 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -76,18 +76,11 @@ OFF_USER_AGENT: str = "diet_guard/1.0 (personal diet tracker)" DEFAULT_PORTION_GRAMS: float = 100.0 # --- Gate (log-to-unlock) --------------------------------------------------- -# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the -# day-start hour, a slot opens every interval; once a slot's hour has passed, -# that slot must carry a logged meal or the screen locks until it does. This -# makes tracking fully automatic (you are prompted on a schedule rather than -# trusted to log voluntarily) and nudges regular eating. Coming home late -# naturally produces several unlogged elapsed slots at once -> one lock that -# backfills the whole day, which is the "requirement to access the PC" behavior. -GATE_DAY_START_HOUR: int = 8 # first slot (08:00); also the "beginning of day" -GATE_SLOT_INTERVAL_HOURS: int = 4 # slots at 08:00, 12:00, 16:00, 20:00 -# Past this hour the gate never fires, so an unlogged late slot lapses quietly -# instead of locking you out overnight. (A new day resets all slots at 00:00.) -GATE_EATING_END_HOUR: int = 22 # exclusive (22:00) +# The gate is driven by MEAL SLOTS, not by a gap timer: once a slot's minute has +# passed, that slot must carry a logged meal or the screen locks until it does. +# Coming home late naturally produces several unlogged elapsed slots at once -> +# one lock that backfills the whole day. The slot minutes and the overnight +# cutoff come from the user's schedule (``_meal_schedule``), not constants here. # flock single-instance guard: stops a timer from stacking lock windows. GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock" diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index e119ca6..eae0223 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -39,7 +39,7 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]: now: Reference time (defaults to the current local time); injectable. Returns: - The slot hours that still need a meal logged (empty == nothing due). + The slot minutes that still need a meal logged (empty == nothing due). Empty on a shared free day, whatever the schedule says. The pool is read from a single local file and never the network, so this cannot hang the @@ -49,7 +49,11 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]: reference = now if now is not None else now_local() if freedays.is_free_day(reference.date()): return () - return missing_slots(reference, logged_slots_today(), current_schedule()) + # One schedule for both halves: snapping the log onto a different schedule + # than the one deriving the due slots would leave a logged meal covering a + # checkpoint that is not on the list -- and the real one nagging. + schedule = current_schedule() + return missing_slots(reference, logged_slots_today(schedule), schedule) def gate_is_due(now: datetime | None = None) -> bool: diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 0c79236..743b4e2 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -97,7 +97,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]: demo_mode: Whether the window is a safe sandbox. Returns: - The slot hours to collect, ascending. + The slot minutes to collect, ascending. """ pending = list(due_slots()) if pending: diff --git a/diet_guard/_gatelock_calendar_schedule.py b/diet_guard/_gatelock_calendar_schedule.py index 5dfa476..eb52426 100644 --- a/diet_guard/_gatelock_calendar_schedule.py +++ b/diet_guard/_gatelock_calendar_schedule.py @@ -4,11 +4,24 @@ Split out of :mod:`._gatelock_calendar_widgets` (216 lines against the repo's 250-line cap) rather than added to it. The behaviour behind these widgets lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's widgets and its edit/save logic are split. + +The first and last meal are editable ``ttk.Combobox`` fields: the dropdown +offers every quarter hour, and the user may also type any ``HH:MM``. The +``ttk`` import is load-bearing for the tests, which patch this module's +``ttk`` with a fake -- keep binding it at module level. + +**The dropdown is a popup over a grabbed lock.** gatelock's grab watch treats +a ``ttk.Combobox`` popdown as "grab lost" and re-takes the grab within about a +second, which closes the list. So the list is a convenience, never the only +path: typing works, and Up/Down step the value by 15 minutes *without* posting +the list (:func:`step_time`), which keeps the row fully usable from the +keyboard on the real lock. """ from __future__ import annotations import tkinter as tk +from tkinter import ttk from typing import TYPE_CHECKING from gatelock import make_button @@ -17,19 +30,92 @@ from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_typography import BODY, LABEL from diet_guard._gatelock_ui import BG, FG +from diet_guard._meal_schedule import MINUTES_PER_DAY, SLOT_GRID_MINUTES +from diet_guard._slot_wire import parse_hhmm +from diet_guard._slots import slot_label if TYPE_CHECKING: from collections.abc import Callable from diet_guard._gatelock_calendar_types import CalendarVars -__all__ = ["build_schedule_row"] +__all__ = ["TIME_CHOICES", "build_schedule_row", "step_time"] _ENTRY_WIDTH = 4 +# "HH:MM" plus room for the caret, so a typed time is never clipped. +_TIME_WIDTH = 6 + +#: Every quarter hour of the day, the grid interior slots are rounded onto. +TIME_CHOICES = tuple( + slot_label(minute) for minute in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES) +) + + +def step_time(text: str, direction: int) -> str: + """Return ``text`` moved to the next quarter hour in ``direction``. + + An off-grid time (07:23) steps to the neighbouring grid mark (07:30 up, + 07:15 down) rather than by a flat 15, so stepping always lands on a value + the dropdown offers. Clamped to the day; unparsable text is returned + unchanged so a half-typed value is not clobbered. + + Args: + text: The field's current value. + direction: ``+1`` for later, ``-1`` for earlier. + + Returns: + The stepped ``HH:MM`` label, or ``text`` if it does not parse. + """ + try: + minute = parse_hhmm(text) + except ValueError: + return text + if direction > 0: + stepped = (minute // SLOT_GRID_MINUTES + 1) * SLOT_GRID_MINUTES + else: + stepped = (minute - 1) // SLOT_GRID_MINUTES * SLOT_GRID_MINUTES + last_mark = MINUTES_PER_DAY - SLOT_GRID_MINUTES + return slot_label(max(0, min(last_mark, stepped))) + + +def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None: + """Make Up/Down step the time instead of posting the dropdown list.""" + + def _step(direction: int) -> str: + if str(combo.cget("state")) == "normal": + variable.set(step_time(variable.get(), direction)) + # "break" stops ttk's own binding, which would post the popdown. + return "break" + + combo.bind("", lambda _event: _step(1)) + combo.bind("", lambda _event: _step(-1)) + + +def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox: + """Return one locked-by-default ``HH:MM`` combobox in the schedule row. + + Locked means ``disabled``, not ``readonly``: a read-only ttk combobox still + lets the user pick from the list, which would edit a "locked" row. Its + colours come from the ``TCombobox`` style (see + :func:`diet_guard._gatelock_calendar_ui._style_notebook`) -- ttk widgets + reject ``bg``/``fg``. + """ + combo = ttk.Combobox( + row, + textvariable=variable, + values=TIME_CHOICES, + font=(_COLORS.typography.font_family, BODY), + width=_TIME_WIDTH, + justify="center", + state="disabled", + ) + _bind_steps(combo, variable) + combo.pack(side="left", padx=(XS, SM), ipady=XS) + return combo def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry: - """Return one read-only-by-default numeric entry in the schedule row.""" + """Return the read-only-by-default meal-count entry in the schedule row.""" entry = tk.Entry( row, textvariable=variable, @@ -46,45 +132,38 @@ def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry: return entry +def _caption(row: tk.Frame, text: str) -> None: + """Pack one static label between the row's fields.""" + tk.Label( + row, + text=text, + font=(_COLORS.typography.font_family, LABEL), + bg=BG, + fg=FG, + ).pack(side="left") + + def build_schedule_row( parent: tk.Frame, vars_: CalendarVars, on_edit_or_save_schedule: Callable[[], None], -) -> tuple[tk.Entry, tk.Entry, tk.Entry, tk.Button, tk.Label]: +) -> tuple[ttk.Combobox, ttk.Combobox, tk.Entry, tk.Button, tk.Label]: """Build the meal-schedule row. - Returns the three hour/count entries, the edit button, and the status - label, in that order. Like the budget row the entries start read-only: - the schedule is displayed but not directly typeable until "Edit". + Returns the two time comboboxes, the count entry, the edit button, and the + status label, in that order. Like the budget row the fields start locked: + the schedule is displayed but not directly editable until "Edit". Returns: - ``(first_entry, last_entry, count_entry, edit_button, status_label)``. + ``(first_combo, last_combo, count_entry, edit_button, status_label)``. """ row = tk.Frame(parent, bg=BG) row.pack(pady=(SM, XS)) - tk.Label( - row, - text="Meals:", - font=(_COLORS.typography.font_family, LABEL), - bg=BG, - fg=FG, - ).pack(side="left") - first_entry = _spin_entry(row, vars_.schedule.first) - tk.Label( - row, - text="to", - font=(_COLORS.typography.font_family, LABEL), - bg=BG, - fg=FG, - ).pack(side="left") - last_entry = _spin_entry(row, vars_.schedule.last) - tk.Label( - row, - text="x", - font=(_COLORS.typography.font_family, LABEL), - bg=BG, - fg=FG, - ).pack(side="left") + _caption(row, "Meals:") + first_combo = _time_combo(row, vars_.schedule.first) + _caption(row, "to") + last_combo = _time_combo(row, vars_.schedule.last) + _caption(row, "x") count_entry = _spin_entry(row, vars_.schedule.count) edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule) edit_button.pack(side="left") @@ -106,4 +185,4 @@ def build_schedule_row( fg=FG, ) status_label.pack(pady=(0, XS)) - return first_entry, last_entry, count_entry, edit_button, status_label + return first_combo, last_combo, count_entry, edit_button, status_label diff --git a/diet_guard/_gatelock_calendar_types.py b/diet_guard/_gatelock_calendar_types.py index d5b6ac9..e459878 100644 --- a/diet_guard/_gatelock_calendar_types.py +++ b/diet_guard/_gatelock_calendar_types.py @@ -19,6 +19,7 @@ from gatelock import LockConfig if TYPE_CHECKING: from collections.abc import Callable + from tkinter import ttk # BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from # LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette @@ -60,7 +61,9 @@ class ScheduleVars: status: tk.StringVar first: tk.StringVar + """The first meal as ``HH:MM`` text, parsed only on save.""" last: tk.StringVar + """The last meal as ``HH:MM`` text, parsed only on save.""" count: tk.StringVar times: tk.StringVar """The derived checkpoint times, e.g. ``"08:00 11:00 14:00"``.""" @@ -89,8 +92,8 @@ class CalendarWidgets: budget_entry: tk.Entry budget_edit_button: tk.Button budget_status_label: tk.Label - schedule_first_entry: tk.Entry - schedule_last_entry: tk.Entry + schedule_first_entry: ttk.Combobox + schedule_last_entry: ttk.Combobox schedule_count_entry: tk.Entry schedule_edit_button: tk.Button schedule_status_label: tk.Label diff --git a/diet_guard/_gatelock_calendar_ui.py b/diet_guard/_gatelock_calendar_ui.py index 55ecef1..ad8599c 100644 --- a/diet_guard/_gatelock_calendar_ui.py +++ b/diet_guard/_gatelock_calendar_ui.py @@ -52,7 +52,10 @@ __all__ = [ def _style_notebook(root: tk.Misc) -> None: - """Theme the ``ttk.Notebook`` tab strip to match the gate's dark palette. + """Theme the History tab's ttk widgets to match the gate's dark palette. + + Covers the ``ttk.Notebook`` tab strip and the schedule row's + ``ttk.Combobox`` fields. ``ttk`` widgets ignore plain ``bg=``/``fg=`` -- unlike every other widget here, they only take color from a named ``ttk.Style``. Without this the @@ -77,3 +80,21 @@ def _style_notebook(root: tk.Misc) -> None: background=[("selected", _ACCENT)], foreground=[("selected", _COLORS.palette.on_fill)], ) + # The meal-schedule row's HH:MM comboboxes. Locked they are ``disabled``, + # which clam greys out; mapping the disabled state back to the field + # colours keeps the stored schedule legible, matching the read-only + # ``tk.Entry`` beside them. + style.configure( + "TCombobox", + fieldbackground=_FIELD_BG, + background=_FIELD_BG, + foreground=FG, + arrowcolor=FG, + insertcolor=FG, + ) + style.map( + "TCombobox", + fieldbackground=[("disabled", _FIELD_BG), ("readonly", _FIELD_BG)], + foreground=[("disabled", FG)], + arrowcolor=[("disabled", _COLORS.palette.muted)], + ) diff --git a/diet_guard/_gatelock_fillall.py b/diet_guard/_gatelock_fillall.py index 0d354b9..bfc9c23 100644 --- a/diet_guard/_gatelock_fillall.py +++ b/diet_guard/_gatelock_fillall.py @@ -28,7 +28,7 @@ from diet_guard._gatelock_kuchnia import start_delivery_fetch from diet_guard._kuchnia_log import fill_plan, log_dishes from diet_guard._kuchnia_spread import assign_slots from diet_guard._meal_schedule_store import current_schedule -from diet_guard._slots import day_slots +from diet_guard._slots import day_slots, slot_label from diet_guard._state import now_local if TYPE_CHECKING: # pragma: no cover - typing only @@ -144,7 +144,9 @@ class FillAllFlow: self._plan = plan self._label.set(f"✓ Confirm ({len(plan)})") total = sum(item.dish.kcal for item in plan) - listing = ", ".join(f"{item.slot:02d}:00 {item.dish.name}" for item in plan) + listing = ", ".join( + f"{slot_label(item.slot)} {item.dish.name}" for item in plan + ) self._set_status( f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm." ) diff --git a/diet_guard/_gatelock_scheduleedit.py b/diet_guard/_gatelock_scheduleedit.py index f9076dd..2550e88 100644 --- a/diet_guard/_gatelock_scheduleedit.py +++ b/diet_guard/_gatelock_scheduleedit.py @@ -11,6 +11,10 @@ before it is written rather than relying on :meth:`~diet_guard._meal_schedule.MealSchedule.normalized` to clamp it. The clamp is the defence against a peer's corrupt sync data; a typo at the keyboard should say so instead of silently becoming a different schedule. + +The first and last meal are ``HH:MM`` times (any minute, not just the +quarter hours the dropdown offers); the rules mirror ``normalized()`` exactly +so that a schedule accepted here is stored unchanged. """ from __future__ import annotations @@ -22,16 +26,20 @@ import tkinter as tk from diet_guard._gatelock_budgetedit import _GateBudgetEdit from diet_guard._gatelock_ui import ERR, FG from diet_guard._meal_schedule import ( - FIRST_HOUR, - LAST_HOUR, MAX_MEAL_COUNT, MIN_MEAL_COUNT, + SLOT_GRID_MINUTES, MealSchedule, ) from diet_guard._meal_schedule_store import current_schedule, record_schedule_change +from diet_guard._slot_wire import parse_hhmm from diet_guard._slots import day_slots, slot_label -__all__ = ["_GateScheduleEdit", "schedule_summary"] +__all__ = ["_GateScheduleEdit", "schedule_problem", "schedule_summary"] + +# ttk.Combobox and tk.Entry spell "locked" differently: a ``readonly`` +# combobox still lets the user pick from its list, so locked is ``disabled``. +_COMBO_STATE = {"normal": "normal", "readonly": "disabled"} def schedule_summary(schedule: MealSchedule) -> str: @@ -39,6 +47,27 @@ def schedule_summary(schedule: MealSchedule) -> str: return " ".join(slot_label(slot) for slot in day_slots(schedule)) +def schedule_problem(schedule: MealSchedule) -> str | None: + """Return why ``schedule`` cannot be saved as typed, or None if it can. + + Each rule is one ``normalized()`` would otherwise apply silently: the + 15-minute minimum window, the meal-count range, and the capacity of the + window -- one checkpoint per grid step, because two meals closer than that + would snap onto the same quarter hour and a duplicate slot silently drops + a checkpoint. + """ + first, last = schedule.first_minute, schedule.last_minute + if last < first + SLOT_GRID_MINUTES: + return ( + f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first." + ) + if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT: + return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}." + if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1: + return "Too many meals for that window -- widen it or eat less often." + return None + + class _GateScheduleEdit(_GateBudgetEdit): """The History tab's meal-schedule row: display, edit, validate, persist. @@ -55,11 +84,16 @@ class _GateScheduleEdit(_GateBudgetEdit): """Repaint the History tab; implemented by ``_GateCalendar``.""" def _set_schedule_entry_state(self, state: str) -> None: - """Lock or unlock the three schedule entries on every monitor.""" + """Lock (``"readonly"``) or unlock (``"normal"``) the row's fields. + + One suppress per widget: a monitor that vanished mid-update must not + stop the remaining fields from changing state. + """ for surface in self._cal_surfaces: + for combo in (surface.schedule_first_entry, surface.schedule_last_entry): + with contextlib.suppress(tk.TclError): + combo.config(state=_COMBO_STATE[state]) with contextlib.suppress(tk.TclError): - surface.schedule_first_entry.config(state=state) - surface.schedule_last_entry.config(state=state) surface.schedule_count_entry.config(state=state) def _set_schedule_button_text(self, text: str) -> None: @@ -78,8 +112,8 @@ class _GateScheduleEdit(_GateBudgetEdit): def _show_schedule(self, schedule: MealSchedule) -> None: """Populate the row's fields and derived-times label.""" - self._cal_vars.schedule.first.set(str(schedule.first)) - self._cal_vars.schedule.last.set(str(schedule.last)) + self._cal_vars.schedule.first.set(slot_label(schedule.first_minute)) + self._cal_vars.schedule.last.set(slot_label(schedule.last_minute)) self._cal_vars.schedule.count.set(str(schedule.count)) self._cal_vars.schedule.times.set(schedule_summary(schedule)) @@ -104,16 +138,19 @@ class _GateScheduleEdit(_GateBudgetEdit): self._refresh_calendar() def _read_schedule_fields(self) -> MealSchedule | None: - """Parse the three entries, or None (with a status set) if unusable.""" - raw = ( - self._cal_vars.schedule.first.get().strip(), - self._cal_vars.schedule.last.get().strip(), - self._cal_vars.schedule.count.get().strip(), - ) + """Parse the three fields, or None (with a status set) if unusable.""" try: - first, last, count = (int(value) for value in raw) + first = parse_hhmm(self._cal_vars.schedule.first.get()) + last = parse_hhmm(self._cal_vars.schedule.last.get()) except ValueError: - self._set_schedule_status("Enter whole hours, e.g. 8 20 5.", error=True) + self._set_schedule_status( + "Enter meal times as HH:MM, e.g. 07:15.", error=True + ) + return None + try: + count = int(self._cal_vars.schedule.count.get().strip()) + except ValueError: + self._set_schedule_status("Enter meals per day as a number.", error=True) return None return MealSchedule(first, last, count) @@ -126,25 +163,9 @@ class _GateScheduleEdit(_GateBudgetEdit): schedule = self._read_schedule_fields() if schedule is None: return False - if not FIRST_HOUR <= schedule.first < schedule.last <= LAST_HOUR: - self._set_schedule_status( - f"First meal must be before the last, both {FIRST_HOUR}-{LAST_HOUR}.", - error=True, - ) - return False - if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT: - self._set_schedule_status( - f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}.", - error=True, - ) - return False - # A window of N hours holds at most N+1 whole-hour checkpoints; asking - # for more would round two meals onto the same hour. - if schedule.count > schedule.last - schedule.first + 1: - self._set_schedule_status( - "Too many meals for that window -- widen it or eat less often.", - error=True, - ) + problem = schedule_problem(schedule) + if problem is not None: + self._set_schedule_status(problem, error=True) return False record_schedule_change(schedule) self._show_schedule(current_schedule()) diff --git a/diet_guard/_kuchnia_log.py b/diet_guard/_kuchnia_log.py index 2c2eb8c..fd57d40 100644 --- a/diet_guard/_kuchnia_log.py +++ b/diet_guard/_kuchnia_log.py @@ -18,6 +18,8 @@ from __future__ import annotations from typing import TYPE_CHECKING from diet_guard._estimator import Nutrition +from diet_guard._meal_schedule_store import current_schedule +from diet_guard._slot_wire import satisfied_by_entries, slot_fields from diet_guard._state import log_meal from diet_guard._state_today import today_entries @@ -26,18 +28,30 @@ if TYPE_CHECKING: # pragma: no cover - typing only from diet_guard._kuchnia_parse import Dish from diet_guard._kuchnia_spread import SlottedDish + from diet_guard._meal_schedule import MealSchedule #: Marks entries this importer created, so the provenance is visible in the log #: and in the food bank's ``source`` column. SOURCE = "kuchnia wikinga" -def _already_logged(entries: Sequence[dict[str, object]], name: str, slot: int) -> bool: - """Return True when today's log already holds this dish in this slot.""" +def _already_logged( + entries: Sequence[dict[str, object]], + name: str, + slot: int, + schedule: MealSchedule, +) -> bool: + """Return True when today's log already holds this dish in this slot. + + "In this slot" means the entry *snaps* to ``slot`` (its nearest slot under + today's schedule), the same rule the gate uses to call a slot satisfied. + An exact-minute comparison would re-log a dish the phone wrote under a + schedule that has since moved by a few minutes -- a duplicate on every peer. + """ wanted = name.strip().casefold() return any( str(entry.get("desc", "")).strip().casefold() == wanted - and entry.get("slot") == slot + and slot in satisfied_by_entries((entry,), schedule) for entry in entries ) @@ -56,7 +70,7 @@ def fill_plan(slotted: Sequence[SlottedDish]) -> list[SlottedDish]: Returns: The dishes to log, in the order given. """ - taken = {entry.get("slot") for entry in today_entries()} + taken = satisfied_by_entries(today_entries(), current_schedule()) return [item for item in slotted if item.slot not in taken] @@ -87,14 +101,15 @@ def log_dishes(chosen: Sequence[SlottedDish]) -> list[str]: The descriptions actually written, in the order they were logged. """ entries = today_entries() + schedule = current_schedule() written: list[str] = [] for item in chosen: dish = item.dish - if _already_logged(entries, dish.name, item.slot): + if _already_logged(entries, dish.name, item.slot, schedule): continue log_meal(dish.name, dish_nutrition(dish), item.slot) # Reflect the write locally so two identical dishes in one batch do not # both land: today_entries() was read once, before the loop. - entries = [*entries, {"desc": dish.name, "slot": item.slot}] + entries = [*entries, {"desc": dish.name, **slot_fields(item.slot)}] written.append(dish.name) return written diff --git a/diet_guard/_kuchnia_spread.py b/diet_guard/_kuchnia_spread.py index 4b0f113..017ad17 100644 --- a/diet_guard/_kuchnia_spread.py +++ b/diet_guard/_kuchnia_spread.py @@ -12,7 +12,7 @@ non-decreasing, keeps the first dish on the first slot and the last on the last, and doubles up the earliest slots rather than dropping anything. Integer ``//`` only, never ``round()``. That is repo convention for slot -arithmetic (``docs/meal-schedule.md``): Python's banker's rounding and Dart's +arithmetic (``docs/DOCS-meal-schedule.md``): Python's banker's rounding and Dart's half-away-from-zero disagree on ``.5``, and a slot one device offers while the other does not is a checkpoint that can never be satisfied. @@ -44,18 +44,19 @@ if TYPE_CHECKING: class SlottedDish(NamedTuple): - """A dish paired with the meal-slot hour it was assigned to.""" + """A dish paired with the meal-slot minute it was assigned to.""" dish: Dish slot: int def assign_slots(dishes: Sequence[Dish], slots: Sequence[int]) -> list[SlottedDish]: - """Pair each dish with a slot hour, following the provider's meal order. + """Pair each dish with a slot minute, following the provider's meal order. Args: dishes: The day's dishes, in any order (sorted here by ``priority``). - slots: The configured slot hours, ascending, e.g. ``(8, 12, 16, 20)``. + slots: The configured slot minutes, ascending, e.g. + ``(480, 720, 960, 1200)``. Returns: One :class:`SlottedDish` per dish, ordered by the provider's priority. diff --git a/diet_guard/_mcp.py b/diet_guard/_mcp.py index b9243c9..ec97047 100644 --- a/diet_guard/_mcp.py +++ b/diet_guard/_mcp.py @@ -44,15 +44,19 @@ from pydantic import BaseModel # the names are genuinely used -- drop this and the server starts write-only. from diet_guard._mcp_read import get_averages, get_slots, get_status, list_today from diet_guard._mcp_server import APPENDS, logger, mcp +from diet_guard._meal_schedule import is_wire_int from diet_guard._meal_schedule_store import current_schedule from diet_guard._resolve import ManualMacros, resolve_nutrition -from diet_guard._slots import slot_for_log +from diet_guard._slot_wire import parse_hhmm +from diet_guard._slots import day_slots, slot_for_log, slot_label from diet_guard._state import ( log_meal as record_meal, ) from diet_guard._state import now_local from diet_guard._sync_events import publish_after_log +_HOURS_PER_DAY = 24 + # ────────────────────────────────────────────────────────────── # Gated write tool (preview unless confirm=True; NEVER allowlist this) # ────────────────────────────────────────────────────────────── @@ -74,12 +78,43 @@ class Macros(BaseModel): fat: float = 0.0 +def _resolve_slot_arg(slot: int | str | None) -> int: + """Return the slot minute ``log_meal``'s ``slot`` argument names. + + An int is the legacy whole-hour form (``12`` -> 720), kept so an existing + caller's prompt does not silently start logging at 00:12; a string is + ``"HH:MM"``. The result must be one of today's slots: an entry tagged + with a minute the schedule never offers would still snap to *some* slot, + but which one would be a surprise to the caller. + + Raises: + ValueError: ``slot`` is not a clock time, or not one of today's slots. + """ + schedule = current_schedule() + if slot is None: + return slot_for_log(now_local(), schedule) + if isinstance(slot, str): + minute = parse_hhmm(slot) + elif is_wire_int(slot) and 0 <= slot < _HOURS_PER_DAY: + # ``is_wire_int`` rejects ``True``: as an int it is 1, i.e. 01:00. + minute = slot * 60 + else: + msg = f"slot {slot} is not an hour 0-23; pass a time like '07:15'" + raise ValueError(msg) + slots = day_slots(schedule) + if minute not in slots: + labels = ", ".join(slot_label(each) for each in slots) + msg = f"{slot_label(minute)} is not a meal slot today; choose one of {labels}" + raise ValueError(msg) + return minute + + @mcp.tool(title="Log a meal (gated write)", annotations=APPENDS) def log_meal( description: str, grams: float | None = None, macros: Macros | None = None, - slot: int | None = None, + slot: int | str | None = None, *, confirm: bool = False, ) -> dict[str, Any]: @@ -104,15 +139,22 @@ def log_meal( macros: Manually-entered nutrition (``kcal`` plus optional protein/carbs/fat). When given, lookups are skipped and the meal is logged exactly as specified. - slot: The meal-slot hour to satisfy; when omitted (or None) it falls - back to ``slot_for_log``, which clamps to a real hour rather than - returning None, so the entry always satisfies some slot. + slot: The meal slot to satisfy, as ``"HH:MM"`` (e.g. ``"07:15"``) or + a legacy int hour (e.g. ``12`` for 12:00); it must be one of + today's slots (see ``get_slots``). When omitted (or None) it + falls back to ``slot_for_log``, which clamps to a real slot rather + than returning None, so the entry always satisfies some slot. + ``target_slot`` in the result is the slot's minute of day. confirm: Set ``True`` to actually append the entry; otherwise preview. Returns: A preview or applied result dict; ``{"ok": false, "reason": ...}`` when the food cannot be resolved or the log cannot be written. """ + try: + target_slot = _resolve_slot_arg(slot) + except ValueError as exc: + return {"ok": False, "reason": str(exc)} manual_macros = ( ManualMacros( kcal=macros.kcal, @@ -132,9 +174,6 @@ def log_meal( "or Open Food Facts. Pass kcal= to log it manually." ), } - target_slot = ( - slot if slot is not None else slot_for_log(now_local(), current_schedule()) - ) resolved = { "kcal": nutrition.kcal, "protein_g": nutrition.protein_g, diff --git a/diet_guard/_mcp_read.py b/diet_guard/_mcp_read.py index f05539e..4d4762e 100644 --- a/diet_guard/_mcp_read.py +++ b/diet_guard/_mcp_read.py @@ -21,6 +21,7 @@ from diet_guard._budget import BudgetError, current_schedule, daily_budget from diet_guard._gate import due_slots from diet_guard._mcp_server import READS_ONLY, mcp from diet_guard._meal_schedule_store import current_schedule as current_meal_schedule +from diet_guard._slot_wire import entry_slot_minute from diet_guard._slots import current_slot, day_slots, slot_label from diet_guard._state import ( load_log, @@ -62,7 +63,9 @@ def _entry_view(entry: dict[str, object]) -> dict[str, Any]: "fat_g": entry.get("fat_g"), "grams": entry.get("grams"), "source": entry.get("source"), - "slot": entry.get("slot"), + # The recorded slot minute (``slot_min``, else the legacy hour x 60), + # in the same unit as every other slot this server reports. + "slot": entry_slot_minute(entry), } @@ -78,8 +81,9 @@ def get_status() -> dict[str, Any]: Reports the calories and macros *consumed* so far, the qualitative :func:`consumption_band` (``"on track"`` / ``"approaching limit"`` / ``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the - meal-slot picture (which slots are due, which are already logged, and the - current slot). The raw budget number is intentionally withheld -- only the + meal-slot picture (which slots are due, as ``HH:MM`` labels; which are + already logged and the current slot, as minutes of day -- e.g. 435 for + 07:15). The raw budget number is intentionally withheld -- only the band is exposed, mirroring how the CLI status shows a label to an automated caller rather than the anchor number. """ @@ -88,14 +92,15 @@ def get_status() -> dict[str, Any]: except BudgetError: # No budget set (or a corrupt file): surface the absence, not a number. band = None + schedule = current_meal_schedule() return { "consumed_kcal": today_total_kcal(), "consumed_macros_g": _macros_dict(today_total_macros()), "consumption_band": band, "budget_initialized": band is not None, "due_slots": [slot_label(slot) for slot in due_slots()], - "logged_slots": sorted(logged_slots_today()), - "current_slot": current_slot(now_local(), current_meal_schedule()), + "logged_slots": sorted(logged_slots_today(schedule)), + "current_slot": current_slot(now_local(), schedule), } @@ -104,8 +109,8 @@ def list_today() -> dict[str, Any]: """List today's logged meals (valid entries only), newest last. Returns the per-entry description, calories, macros, portion, source, and - slot -- the same data the CLI ``status`` listing renders, minus the internal - HMAC signature. + recorded slot minute -- the same data the CLI ``status`` listing renders, + minus the internal HMAC signature. """ entries = today_entries() return { @@ -172,15 +177,18 @@ def get_averages() -> dict[str, Any]: @mcp.tool(title="Meal slots for the day", annotations=READS_ONLY) def get_slots() -> dict[str, Any]: - """Return the day's fixed meal slots and which one is current. + """Return today's meal slots and which one is current. Pure schedule information (08:00 / 12:00 / 16:00 / 20:00 by default) with no - budget or intake data attached. + budget or intake data attached. Each slot is ``{"minute": m, "label": + "HH:MM"}`` with ``m`` the minute of day (e.g. 435 for 07:15); the label is + what ``log_meal``'s ``slot`` argument accepts. ``current_slot`` is a + minute too, or None before the first slot. """ schedule = current_meal_schedule() return { "day_slots": [ - {"hour": slot, "label": slot_label(slot)} for slot in day_slots(schedule) + {"minute": slot, "label": slot_label(slot)} for slot in day_slots(schedule) ], "current_slot": current_slot(now_local(), schedule), } diff --git a/diet_guard/_meal_schedule_store.py b/diet_guard/_meal_schedule_store.py index 5ec59e2..6ecc1f6 100644 --- a/diet_guard/_meal_schedule_store.py +++ b/diet_guard/_meal_schedule_store.py @@ -34,7 +34,12 @@ import json import logging from diet_guard._constants import MEAL_SCHEDULE_FILE -from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule +from diet_guard._meal_schedule import ( + DEFAULT_SCHEDULE, + MealSchedule, + schedule_from_wire, + schedule_to_wire, +) _logger = logging.getLogger(__name__) @@ -65,33 +70,27 @@ class ScheduleEntry: def entry_to_json(entry: ScheduleEntry) -> dict[str, object]: """Return the wire/disk form of one entry's value.""" - return { - "f": entry.schedule.first, - "l": entry.schedule.last, - "n": entry.schedule.count, - "t": entry.edited_at, - } + # ``schedule_to_wire`` keeps a whole-hour schedule byte-identical to the + # pre-minute ``{"f", "l", "n"}`` form, so an older build reading this file + # still sees the hours it understands. + return {**schedule_to_wire(entry.schedule), "t": entry.edited_at} def entry_from_json(effective_from: str, raw: object) -> ScheduleEntry | None: """Return an entry parsed from its stored value, or None if unusable. Never raises: a malformed entry is skipped so one bad field from a peer - cannot take out the whole history. + cannot take out the whole history. ``schedule_from_wire`` normalises on + the way in, so a peer running a future version with a wider range cannot + hand us a schedule we would derive differently. """ - if not isinstance(raw, dict): - return None - first, last, count = raw.get("f"), raw.get("l"), raw.get("n") - if not ( - isinstance(first, int) and isinstance(last, int) and isinstance(count, int) - ): + schedule = schedule_from_wire(raw) + if schedule is None or not isinstance(raw, dict): return None edited_at = raw.get("t") return ScheduleEntry( effective_from=effective_from, - # Normalising on the way in means a peer running a future version with - # a wider range cannot hand us a schedule we would derive differently. - schedule=MealSchedule(first, last, count).normalized(), + schedule=schedule, edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO, ) diff --git a/diet_guard/_slot_wire.py b/diet_guard/_slot_wire.py index 839bcab..bde1d3b 100644 --- a/diet_guard/_slot_wire.py +++ b/diet_guard/_slot_wire.py @@ -26,9 +26,12 @@ import re from typing import TYPE_CHECKING from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int +from diet_guard._slots import satisfied_slots if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping + + from diet_guard._meal_schedule import MealSchedule # ASCII digits only: ``\d`` would also accept Arabic-Indic and other Unicode # digits, which ``int()`` happily parses. @@ -81,6 +84,30 @@ def entry_slot_minute(entry: Mapping[str, object]) -> int | None: return None +def satisfied_by_entries( + entries: Iterable[Mapping[str, object]], schedule: MealSchedule +) -> set[int]: + """Return the slots of ``schedule`` that ``entries`` satisfy. + + The one composition every "is this slot logged?" site goes through: read + each entry's recorded minute, then snap it to its nearest slot. Routing + every site through here is what keeps the gate, the CLI, MCP and the + catering import from disagreeing about one entry after a schedule edit -- + an exact-match site would re-nag for a meal another site counts. + + Args: + entries: Decoded log entries; ones recording no slot are ignored. + schedule: The schedule in force on the entries' day. + + Returns: + The satisfied slot minutes (possibly empty). + """ + minutes = (entry_slot_minute(entry) for entry in entries) + return satisfied_slots( + (minute for minute in minutes if minute is not None), schedule + ) + + def parse_hhmm(text: str) -> int: """Parse a user-typed clock time into a minute of day. diff --git a/diet_guard/_state.py b/diet_guard/_state.py index b99331d..1152694 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -24,6 +24,7 @@ from gatelock.log_integrity import ( from diet_guard._coerce import as_float from diet_guard._constants import FOOD_LOG_FILE +from diet_guard._slot_wire import slot_fields if TYPE_CHECKING: from diet_guard._estimator import Nutrition @@ -137,9 +138,12 @@ def log_meal( Args: description: The user's free-text meal description. nutrition: Estimated nutrition for the portion eaten. - slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the - 12:00 checkpoint). When None the entry still counts toward the - day's calories but does not mark any slot as logged. + slot: The meal-slot *minute of day* this entry satisfies (e.g. + ``720`` for the 12:00 checkpoint, ``435`` for 07:15). Encoded + through :func:`diet_guard._slot_wire.slot_fields`, so a whole-hour + slot is written exactly as before the move to minutes. When None + the entry still counts toward the day's calories but does not mark + any slot as logged. components: For a composite (multi-item) meal, each component's own name and macros. Carried on the log entry itself -- not just the food bank -- so a bank rebuilt purely by replaying the log (the @@ -173,7 +177,9 @@ def log_meal( "source": nutrition.source, } if slot is not None: - entry["slot"] = slot + # Before signing: the HMAC covers the whole dict, so a ``slot_min`` + # added afterwards would make the entry fail verification and vanish. + entry.update(slot_fields(slot)) if components is not None: entry["components"] = list(components) signature = compute_entry_hmac(entry) diff --git a/diet_guard/_state_today.py b/diet_guard/_state_today.py index 31dbfbf..487473f 100644 --- a/diet_guard/_state_today.py +++ b/diet_guard/_state_today.py @@ -18,8 +18,12 @@ already import them from. from __future__ import annotations +from typing import TYPE_CHECKING + from diet_guard._budget import daily_budget from diet_guard._constants import BUDGET_WARN_FRACTION +from diet_guard._meal_schedule_store import current_schedule +from diet_guard._slot_wire import satisfied_by_entries from diet_guard._state import ( _entry_float, _today, @@ -27,6 +31,9 @@ from diet_guard._state import ( load_log, ) +if TYPE_CHECKING: + from diet_guard._meal_schedule import MealSchedule + __all__ = [ "consumption_band", "logged_slots_today", @@ -65,8 +72,14 @@ def today_total_macros() -> tuple[float, float, float]: return round(protein, 1), round(carbs, 1), round(fat, 1) -def logged_slots_today() -> set[int]: - """Return the set of meal-slot hours already covered by today's log. +def logged_slots_today(schedule: MealSchedule | None = None) -> set[int]: + """Return the set of today's slot minutes already covered by today's log. + + Each entry satisfies the slot *nearest* its recorded minute + (:func:`diet_guard._slot_wire.satisfied_by_entries`), so a meal logged at + 07:00 still covers a checkpoint the user has since moved to 07:15. + Without the snap, every schedule edit would re-open the slots already + eaten that day and lock the user out for meals they did log. Only valid (HMAC-verified) entries count, so stripping entries to dodge a checkpoint makes that slot reappear as unsatisfied -- the fail-closed @@ -74,23 +87,22 @@ def logged_slots_today() -> set[int]: checkpoint) contributes calories but satisfies no slot. Nothing produces a slot-less entry any more: the phone's "Snack" chip was - removed on 2026-08-14, and every remaining writer resolves a concrete hour + removed on 2026-08-14, and every remaining writer resolves a concrete slot through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate (:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back - to it whenever its ``slot`` argument is omitted or None). The check below - is still load-bearing for entries already on disk and arriving over sync -- - removing it would make every historical snack retroactively satisfy a meal - slot. + to it whenever its ``slot`` argument is omitted or None). Skipping + slot-less entries (in ``satisfied_by_entries``) is still load-bearing for + entries already on disk and arriving over sync -- removing it would make + every historical snack retroactively satisfy a meal slot. + + Args: + schedule: The schedule to snap onto; defaults to today's. Returns: - The distinct integer slot hours logged today (possibly empty). + The distinct slot minutes satisfied today (possibly empty). """ - slots: set[int] = set() - for entry in today_entries(): - value = entry.get("slot") - if isinstance(value, int) and not isinstance(value, bool): - slots.add(value) - return slots + resolved = schedule if schedule is not None else current_schedule() + return satisfied_by_entries(today_entries(), resolved) def remaining_budget() -> float: diff --git a/diet_guard/sync_merge/_schedule.py b/diet_guard/sync_merge/_schedule.py index 2396f9e..c6f4836 100644 --- a/diet_guard/sync_merge/_schedule.py +++ b/diet_guard/sync_merge/_schedule.py @@ -11,7 +11,9 @@ predates meal schedules neither clobbers those fields nor blocks them -- it relays them untouched. That is what makes this shippable without a coordinated release. -The value is a small map (``{"f": 8, "l": 20, "n": 5}``) rather than a scalar. +The value is a small map (``{"f": 8, "l": 20, "n": 5}``, plus ``fm``/``lm`` +minutes only for an off-hour endpoint -- see +:func:`diet_guard._meal_schedule.schedule_to_wire`) rather than a scalar. ``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a malformed one is skipped exactly as a non-int ``hist:`` value is. """ @@ -24,7 +26,7 @@ from typing import TYPE_CHECKING from crdt_sync import Hlc from diet_guard._device import device_id -from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule import schedule_from_wire, schedule_to_wire from diet_guard._meal_schedule_store import ScheduleEntry from diet_guard.sync_merge._clock import _EPOCH @@ -64,11 +66,7 @@ def schedule_fields( """ return { f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": ( - { - "f": entry.schedule.first, - "l": entry.schedule.last, - "n": entry.schedule.count, - }, + schedule_to_wire(entry.schedule), schedule_hlc(entry), ) for entry in entries @@ -90,21 +88,17 @@ def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]: for name, (value, hlc) in record.fields.items(): if not name.startswith(SCHEDULE_FIELD_PREFIX): continue - if not isinstance(value, dict): - continue - first, last, count = value.get("f"), value.get("l"), value.get("n") - if not ( - isinstance(first, int) and isinstance(last, int) and isinstance(count, int) - ) or any(isinstance(part, bool) for part in (first, last, count)): + # Normalised on the way in, so a peer running a future version with a + # wider range cannot hand us a schedule we would derive slots + # differently from. + schedule = schedule_from_wire(value) + if schedule is None: continue edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC) entries.append( ScheduleEntry( effective_from=name[len(SCHEDULE_FIELD_PREFIX) :], - # Normalised on the way in, so a peer running a future version - # with a wider range cannot hand us a schedule we would derive - # slots differently from. - schedule=MealSchedule(first, last, count).normalized(), + schedule=schedule, edited_at=edited.astimezone().isoformat(timespec="seconds"), ), ) diff --git a/diet_guard/tests/_gate_fixtures.py b/diet_guard/tests/_gate_fixtures.py index 517de0d..fb88db6 100644 --- a/diet_guard/tests/_gate_fixtures.py +++ b/diet_guard/tests/_gate_fixtures.py @@ -199,6 +199,9 @@ def fake_tk() -> Iterator[None]: stack.enter_context(patch.object(module, "tk", _FAKE_TK)) stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK)) stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK)) + # The schedule row's HH:MM comboboxes are ttk widgets; left real, they + # would be built against a fake parent. + stack.enter_context(patch.object(_gatelock_calendar_schedule, "ttk", _FAKE_TTK)) yield diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index ccab9eb..69e98e5 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -25,6 +25,7 @@ from types import SimpleNamespace from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar from diet_guard.tests._tk_fakes_containers import ( + FakeCombobox, FakeNotebook, FakeRadiobutton, FakeStyle, @@ -40,6 +41,7 @@ __all__ = [ "_FAKE_TK", "_FAKE_TTK", "FakeCanvas", + "FakeCombobox", "FakeEntry", "FakeListbox", "FakeNotebook", @@ -174,7 +176,9 @@ _FAKE_TK = SimpleNamespace( Event=object, ) -_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle) +_FAKE_TTK = SimpleNamespace( + Notebook=FakeNotebook, Style=FakeStyle, Combobox=FakeCombobox +) # Every mixin module the gate window is built from imports ``tkinter`` # independently; all of them must see the fake so ``tk.TclError`` etc. are the diff --git a/diet_guard/tests/_tk_fakes_containers.py b/diet_guard/tests/_tk_fakes_containers.py index a7e3f34..36c3083 100644 --- a/diet_guard/tests/_tk_fakes_containers.py +++ b/diet_guard/tests/_tk_fakes_containers.py @@ -21,6 +21,7 @@ rather than merely asserting that a widget was constructed. from __future__ import annotations __all__ = [ + "FakeCombobox", "FakeNotebook", "FakeRadiobutton", "FakeStyle", @@ -181,3 +182,27 @@ class FakeStyle: def map(self, style_name: str, **kwargs: object) -> None: self.configured.setdefault(style_name, {}).update(kwargs) + + +class FakeCombobox(FakeWidget): + """A functional stand-in for ``ttk.Combobox``. + + Reads and writes go through its ``textvariable`` (as the real widget's + do), ``state`` is readable via ``cget``, and ``bind`` keeps each handler + so a test can fire the Up/Down step bindings the way a keypress would. + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.bindings: dict[str, object] = {} + + def cget(self, key: str) -> object: + return self.configured.get(key, "") + + def get(self) -> str: + variable = self.configured.get("textvariable") + return str(variable.get()) if hasattr(variable, "get") else "" + + def bind(self, *args: object, **kwargs: object) -> None: + sequence, handler = args[0], args[1] + self.bindings[str(sequence)] = handler diff --git a/diet_guard/tests/test_cli.py b/diet_guard/tests/test_cli.py index 147fc26..d573e74 100644 --- a/diet_guard/tests/test_cli.py +++ b/diet_guard/tests/test_cli.py @@ -195,8 +195,8 @@ class TestStatus: def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None: """The slot line shows logged / DUE / upcoming together.""" with ( - patch.object(_cli, "logged_slots_today", return_value={8}), - patch.object(_cli, "due_slots", return_value=[12]), + patch.object(_cli, "logged_slots_today", return_value={480}), + patch.object(_cli, "due_slots", return_value=[720]), ): _cli._print_slot_status() out = capsys.readouterr().out diff --git a/diet_guard/tests/test_cli_future_ate.py b/diet_guard/tests/test_cli_future_ate.py index fc22331..75b571d 100644 --- a/diet_guard/tests/test_cli_future_ate.py +++ b/diet_guard/tests/test_cli_future_ate.py @@ -13,6 +13,8 @@ from diet_guard import _cli_log from diet_guard._budget import write_budget from diet_guard._cli import main from diet_guard._estimator import Nutrition +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import record_schedule_change from diet_guard._state import load_log, now_local if TYPE_CHECKING: @@ -57,3 +59,32 @@ class TestAteFutureDate: yesterday = (now_local() - timedelta(days=1)).date().isoformat() assert main(["ate", "x", "--date", yesterday, "--hour", "12"]) == 1 assert "not a future date" in capsys.readouterr().out + + def test_an_hhmm_hour_pre_logs_an_off_hour_slot( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """--hour 07:15 names a minute slot and is echoed as HH:MM.""" + record_schedule_change(MealSchedule(435, 1140, 5)) + tomorrow = (now_local() + timedelta(days=1)).date().isoformat() + with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT): + assert main(["ate", "oats", "--date", tomorrow, "--hour", "07:15"]) == 0 + assert f"for {tomorrow} 07:15" in capsys.readouterr().out + (entry,) = load_log()[tomorrow] + assert (entry["slot"], entry["slot_min"]) == (7, 435) + assert entry["time"].startswith(f"{tomorrow}T07:15") + + def test_a_whole_hour_is_echoed_as_hhmm( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + tomorrow = (now_local() + timedelta(days=1)).date().isoformat() + with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT): + assert main(["ate", "x", "--date", tomorrow, "--hour", "20:00"]) == 0 + assert f"for {tomorrow} 20:00" in capsys.readouterr().out + assert "slot_min" not in load_log()[tomorrow][0] + + def test_an_unparseable_hour_errors( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + tomorrow = (now_local() + timedelta(days=1)).date().isoformat() + assert main(["ate", "x", "--date", tomorrow, "--hour", "noon"]) == 1 + assert "expected HH:MM" in capsys.readouterr().out diff --git a/diet_guard/tests/test_cli_gate.py b/diet_guard/tests/test_cli_gate.py index 01420a4..b00046d 100644 --- a/diet_guard/tests/test_cli_gate.py +++ b/diet_guard/tests/test_cli_gate.py @@ -216,5 +216,5 @@ class TestGateReadsFreshState: """At 09:00 only the 08:00 slot is due; logging it flips the decision.""" now = datetime(2026, 1, 1, 9, 0, tzinfo=UTC) assert gate_is_due(now) is True - log_meal("oatmeal", self._nutrition(), slot=8) + log_meal("oatmeal", self._nutrition(), slot=480) assert gate_is_due(now) is False diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py index 74efc6b..7958f3d 100644 --- a/diet_guard/tests/test_gate.py +++ b/diet_guard/tests/test_gate.py @@ -36,8 +36,8 @@ class TestDueSlots: def test_injected_now(self) -> None: """With 08:00 logged at 13:00, only 12:00 is due.""" - with _logged({8}): - assert due_slots(_at(13)) == (12,) + with _logged({480}): + assert due_slots(_at(13)) == (720,) def test_default_now_uses_clock(self) -> None: """Omitting ``now`` reads the real clock (mocked here for determinism).""" @@ -48,7 +48,7 @@ class TestDueSlots: return_value=_at(9), ), ): - assert due_slots() == (8,) + assert due_slots() == (480,) class TestGateIsDue: @@ -61,7 +61,7 @@ class TestGateIsDue: def test_not_due_when_all_logged(self) -> None: """Everything elapsed is logged -> no lock.""" - with _logged({8, 12}): + with _logged({480, 720}): assert gate_is_due(_at(13)) is False @@ -70,12 +70,12 @@ class TestGateMessage: def test_all_logged(self) -> None: """Nothing missing -> the up-to-date message.""" - with _logged({8, 12}): + with _logged({480, 720}): assert "up to date" in gate_message(_at(13)) def test_single_missing(self) -> None: """One missing slot -> singular phrasing.""" - with _logged({8}): + with _logged({480}): assert gate_message(_at(13)) == "Log your 12:00 meal to unlock." def test_multiple_missing(self) -> None: diff --git a/diet_guard/tests/test_gate_schedule_move.py b/diet_guard/tests/test_gate_schedule_move.py new file mode 100644 index 0000000..91d2913 --- /dev/null +++ b/diet_guard/tests/test_gate_schedule_move.py @@ -0,0 +1,65 @@ +"""A schedule edit must not re-open meals already logged that day. + +End-to-end through the real public path -- ``log_meal`` writes signed entries +to the (redirected) log, ``record_schedule_change`` stores the schedule, and +``gate_is_due`` / ``logged_slots_today`` read both back -- with nothing mocked +in between. This is the user-approved satisfaction rule: each logged meal +satisfies the slot *nearest* its recorded minute, and nothing on disk is ever +rewritten. An exact-match rule would lock the user out for two meals they did +log, the moment they nudged breakfast by fifteen minutes. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from diet_guard._estimator import Nutrition +from diet_guard._gate import due_slots, gate_is_due +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import record_schedule_change +from diet_guard._state import log_meal +from diet_guard._state_today import logged_slots_today + + +def _today_at(hour: int, minute: int = 0) -> datetime: + """Return today's local date at ``hour:minute`` (the log is keyed by today).""" + now = datetime.now(tz=UTC).astimezone() + return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + + +def _log_breakfast_and_snack_then_move_the_schedule() -> None: + """07:00 and 10:00 logged under 07:00-19:00 x5, then moved to 07:15.""" + record_schedule_change(MealSchedule(420, 1140, 5)) + meal = Nutrition(300, 10, 40, 8, 200, "manual") + log_meal("oats", meal, slot=420) + log_meal("apple", meal, slot=600) + record_schedule_change(MealSchedule(435, 1140, 5)) + + +class TestScheduleMoveKeepsLoggedMeals: + """07:00/10:00 entries keep covering 07:15/10:15 after the edit.""" + + def test_the_moved_slots_are_derived_as_expected(self) -> None: + """Precondition: the new schedule really moved both checkpoints.""" + assert MealSchedule(435, 1140, 5).slots() == (435, 615, 795, 960, 1140) + + def test_old_entries_satisfy_the_nearest_new_slots(self) -> None: + """Nearest-slot snapping: 420 -> 435 and 600 -> 615.""" + _log_breakfast_and_snack_then_move_the_schedule() + assert logged_slots_today() == {435, 615} + + def test_nothing_is_due_at_eleven(self) -> None: + """Both elapsed checkpoints are covered, so the gate stays down.""" + _log_breakfast_and_snack_then_move_the_schedule() + assert not gate_is_due(_today_at(11)) + + def test_the_next_unlogged_slot_still_falls_due(self) -> None: + """Snapping is not a free pass: 13:15 is due once it arrives.""" + _log_breakfast_and_snack_then_move_the_schedule() + assert due_slots(_today_at(13, 15)) == (795,) + + def test_the_moved_first_slot_opens_on_its_minute(self) -> None: + """With nothing logged, 07:15 is due at 07:15 and not at 07:14.""" + record_schedule_change(MealSchedule(435, 1140, 5)) + assert not gate_is_due(_today_at(7, 14)) + assert due_slots(_today_at(7, 15)) == (435,) diff --git a/diet_guard/tests/test_gate_tk_modules.py b/diet_guard/tests/test_gate_tk_modules.py index 386fe5c..c6e2825 100644 --- a/diet_guard/tests/test_gate_tk_modules.py +++ b/diet_guard/tests/test_gate_tk_modules.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock from diet_guard import ( _gatelock_calendar, + _gatelock_calendar_schedule, _gatelock_calendar_ui, _gatelock_ui, _gatelock_widgetgroups, @@ -149,6 +150,7 @@ def test_fake_tk_actually_replaces_real_tkinter() -> None: assert not wrong, f"fake_tk() left real tkinter in place for: {wrong}" assert _gatelock_calendar.ttk is _FAKE_TTK assert _gatelock_calendar_ui.ttk is _FAKE_TTK + assert _gatelock_calendar_schedule.ttk is _FAKE_TTK # ...and is restored afterwards, so the fake cannot leak into other tests. assert _gatelock_ui.tk is tk diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index c1e7ab0..ec21535 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -69,8 +69,8 @@ class TestModuleHelpers: def test_pending_slots_due(self) -> None: """When slots are due, those are returned verbatim.""" - with patch.object(_gatelock, "due_slots", return_value=[12, 16]): - assert _pending_slots(demo_mode=False) == [12, 16] + with patch.object(_gatelock, "due_slots", return_value=[720, 960]): + assert _pending_slots(demo_mode=False) == [720, 960] def test_pending_slots_demo_fallback(self) -> None: """Demo mode invents a representative slot when nothing is due.""" diff --git a/diet_guard/tests/test_gatelock_calendar_schedule.py b/diet_guard/tests/test_gatelock_calendar_schedule.py new file mode 100644 index 0000000..02b72c1 --- /dev/null +++ b/diet_guard/tests/test_gatelock_calendar_schedule.py @@ -0,0 +1,85 @@ +"""Tests for the schedule row's ``HH:MM`` comboboxes. + +Covers :mod:`._gatelock_calendar_schedule`: the quarter-hour choices, the +Up/Down step that avoids posting the dropdown over a grabbed lock, and the +per-widget state toggle in :mod:`._gatelock_scheduleedit`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast +from unittest.mock import patch + +import pytest + +from diet_guard._gatelock_calendar_schedule import TIME_CHOICES, step_time +from diet_guard.tests._tk_fakes import _FakeTclError + +if TYPE_CHECKING: + from collections.abc import Callable + + from diet_guard._gatelock import MealGate + from diet_guard.tests._tk_fakes import FakeCombobox + + +class TestChoices: + def test_every_quarter_hour_of_the_day(self) -> None: + assert len(TIME_CHOICES) == 96 + assert TIME_CHOICES[:3] == ("00:00", "00:15", "00:30") + assert TIME_CHOICES[-1] == "23:45" + + +class TestStepTime: + @pytest.mark.parametrize( + ("text", "direction", "expected"), + [ + ("07:15", 1, "07:30"), + ("07:15", -1, "07:00"), + # Off-grid steps to the neighbouring mark, not by a flat 15. + ("07:23", 1, "07:30"), + ("07:23", -1, "07:15"), + ("7", 1, "07:15"), + # Clamped to the day's grid. + ("00:00", -1, "00:00"), + ("23:45", 1, "23:45"), + ("23:50", 1, "23:45"), + ], + ) + def test_steps_onto_the_grid( + self, text: str, direction: int, expected: str + ) -> None: + assert step_time(text, direction) == expected + + def test_half_typed_text_is_left_alone(self) -> None: + assert step_time("07:", 1) == "07:" + + +class TestComboBindings: + def _fire(self, gate: MealGate, key: str) -> object: + combo = cast("FakeCombobox", gate._cal_widgets.schedule_first_entry) + handler = cast("Callable[[object], object]", combo.bindings[key]) + return handler(None) + + def test_up_down_step_only_while_editing(self, gate: MealGate) -> None: + gate._cal_vars.schedule.first.set("08:00") + # Locked: the key is swallowed and the value is unchanged. + assert self._fire(gate, "") == "break" + assert gate._cal_vars.schedule.first.get() == "08:00" + + gate._on_edit_or_save_schedule() + assert self._fire(gate, "") == "break" + assert gate._cal_vars.schedule.first.get() == "08:15" + self._fire(gate, "") + self._fire(gate, "") + assert gate._cal_vars.schedule.first.get() == "07:45" + + +class TestStateToggle: + def test_one_dead_widget_does_not_skip_the_rest(self, gate: MealGate) -> None: + """A vanished monitor's combobox must not leave the others locked.""" + widgets = gate._cal_widgets + dead = _FakeTclError("dead") + with patch.object(widgets.schedule_first_entry, "config", side_effect=dead): + gate._set_schedule_entry_state("normal") + assert widgets.schedule_last_entry.configured["state"] == "normal" + assert widgets.schedule_count_entry.configured["state"] == "normal" diff --git a/diet_guard/tests/test_gatelock_dashboard.py b/diet_guard/tests/test_gatelock_dashboard.py index a21e263..cd4bf91 100644 --- a/diet_guard/tests/test_gatelock_dashboard.py +++ b/diet_guard/tests/test_gatelock_dashboard.py @@ -54,10 +54,10 @@ class TestDashboard: gate._pending = [] gate._refresh_slot_header() assert "All meals logged" in gate._vars.slot_header.get() - gate._pending = [8] + gate._pending = [480] gate._refresh_slot_header() assert "Log your" in gate._vars.slot_header.get() - gate._pending = [8, 12] + gate._pending = [480, 720] gate._refresh_slot_header() assert "remaining" in gate._vars.slot_header.get() @@ -74,14 +74,14 @@ class TestSlotWalk: def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None: """A demo gate tags logs with no real slot.""" - gate._pending = [8] + gate._pending = [480] assert gate._slot_for_log() is None def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None: """A production gate tags logs with the current slot.""" gate.demo_mode = False - gate._pending = [12] - assert gate._slot_for_log() == 12 + gate._pending = [720] + assert gate._slot_for_log() == 720 def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None: """Clearing between slots empties the description and the macros.""" @@ -93,7 +93,7 @@ class TestSlotWalk: def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None: """Finishing the final slot triggers unlock.""" - gate._pending = [20] + gate._pending = [1200] with patch.object(gate, "_unlock") as unlock: gate._finish_slot("done") unlock.assert_called_once() @@ -126,7 +126,7 @@ class TestFetchFromSync: def test_pull_failure_keeps_lock(self, gate: MealGate) -> None: """A failed pull shows the reason and leaves pending slots intact.""" gate.demo_mode = False - gate._pending = [8, 12] + gate._pending = [480, 720] with patch.object( _gatelock_delivery, "pull_peer_logs", @@ -134,52 +134,52 @@ class TestFetchFromSync: ): gate._on_fetch_sync() _drive_fetch(gate) - assert gate._pending == [8, 12] + assert gate._pending == [480, 720] assert "still locked" in gate._vars.status.get() def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None: """A clean pull that satisfies nothing reports so and keeps the slots.""" gate.demo_mode = False - gate._pending = [8, 12] + gate._pending = [480, 720] with ( patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None), - patch.object(_gatelock_delivery, "due_slots", return_value=(8, 12)), + patch.object(_gatelock_delivery, "due_slots", return_value=(480, 720)), ): gate._on_fetch_sync() _drive_fetch(gate) - assert gate._pending == [8, 12] + assert gate._pending == [480, 720] assert "No new meals" in gate._vars.status.get() def test_partial_advances_to_next_slot(self, gate: MealGate) -> None: """One slot pulled in leaves the rest; the window advances (singular).""" gate.demo_mode = False - gate._pending = [8, 12] + gate._pending = [480, 720] with ( patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None), - patch.object(_gatelock_delivery, "due_slots", return_value=(12,)), + patch.object(_gatelock_delivery, "due_slots", return_value=(720,)), ): gate._on_fetch_sync() _drive_fetch(gate) - assert gate._pending == [12] + assert gate._pending == [720] assert "Pulled 1 meal " in gate._vars.status.get() def test_partial_plural_wording(self, gate: MealGate) -> None: """Two slots pulled in uses the plural 'meals'.""" gate.demo_mode = False - gate._pending = [8, 12, 16] + gate._pending = [480, 720, 960] with ( patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None), - patch.object(_gatelock_delivery, "due_slots", return_value=(16,)), + patch.object(_gatelock_delivery, "due_slots", return_value=(960,)), ): gate._on_fetch_sync() _drive_fetch(gate) - assert gate._pending == [16] + assert gate._pending == [960] assert "Pulled 2 meals" in gate._vars.status.get() def test_all_satisfied_unlocks(self, gate: MealGate) -> None: """When the pull satisfies every pending slot, the gate unlocks.""" gate.demo_mode = False - gate._pending = [8, 12] + gate._pending = [480, 720] with ( patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None), patch.object(_gatelock_delivery, "due_slots", return_value=()), diff --git a/diet_guard/tests/test_gatelock_fanout.py b/diet_guard/tests/test_gatelock_fanout.py index d3c897f..9eec3d4 100644 --- a/diet_guard/tests/test_gatelock_fanout.py +++ b/diet_guard/tests/test_gatelock_fanout.py @@ -8,21 +8,15 @@ touched the first surface fails here and nowhere else. from __future__ import annotations -from contextlib import ExitStack import tkinter as tk from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from diet_guard import _gatelock_calendar from diet_guard._gatelock import MealGate -from diet_guard.tests.conftest import ( - _FAKE_TK, - _FAKE_TTK, - _GATE_TK_MODULES, - TWO_OUTPUTS, -) +from diet_guard.tests._gate_fixtures import fake_tk +from diet_guard.tests.conftest import TWO_OUTPUTS if TYPE_CHECKING: from collections.abc import Iterator @@ -32,10 +26,10 @@ if TYPE_CHECKING: def dual_gate(dual_output: None) -> Iterator[MealGate]: """A demo gate built across two live outputs.""" del dual_output - with ExitStack() as stack: - for module in _GATE_TK_MODULES: - stack.enter_context(patch.object(module, "tk", _FAKE_TK)) - stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK)) + # The shared patch set, never a hand-picked copy: a copy silently misses + # the next module that starts building widgets (the schedule row's ttk + # comboboxes were exactly that). + with fake_tk(): yield MealGate(demo_mode=True) diff --git a/diet_guard/tests/test_gatelock_fillall.py b/diet_guard/tests/test_gatelock_fillall.py index 715814d..f316e3d 100644 --- a/diet_guard/tests/test_gatelock_fillall.py +++ b/diet_guard/tests/test_gatelock_fillall.py @@ -24,6 +24,7 @@ from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard._kuchnia_parse import Dish from diet_guard._meal_schedule_store import current_schedule +from diet_guard._slot_wire import entry_slot_minute from diet_guard._slots import day_slots from diet_guard._state import log_meal, now_local from diet_guard._state_today import today_entries @@ -89,10 +90,11 @@ def delivered(*dishes: Dish) -> DeliveryResult: def logged() -> dict[int, list[str]]: - """Today's on-disk log as slot -> descriptions.""" + """Today's on-disk log as recorded slot minute -> descriptions.""" by_slot: dict[int, list[str]] = {} for entry in today_entries(): - by_slot.setdefault(int(entry["slot"]), []).append(str(entry["desc"])) + slot = entry_slot_minute(entry) or 0 + by_slot.setdefault(slot, []).append(str(entry["desc"])) return by_slot @@ -104,7 +106,7 @@ def propose(h: Harness) -> None: def test_default_schedule_precondition() -> None: # Every expectation below assumes the redirected (default) schedule. - assert day_slots(current_schedule()) == (8, 12, 16, 20) + assert day_slots(current_schedule()) == (480, 720, 960, 1200) class TestFirstClick: @@ -160,7 +162,7 @@ class TestFirstClick: assert h.label.get() == FILL_LABEL def test_every_slot_already_logged(self) -> None: - for slot in (8, 12, 16, 20): + for slot in (480, 720, 960, 1200): log_meal(f"mine {slot}", _nutrition(), slot) h = Harness() with fetching(delivered(*FOUR)): @@ -169,7 +171,7 @@ class TestFirstClick: assert h.label.get() == FILL_LABEL def test_the_proposal_writes_nothing(self) -> None: - log_meal("my sandwich", _nutrition(), 12) + log_meal("my sandwich", _nutrition(), 720) h = Harness() with fetching(delivered(*FOUR)): propose(h) @@ -178,23 +180,23 @@ class TestFirstClick: "Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, " "20:00 Kolacja — click Confirm." ) - assert logged() == {12: ["my sandwich"]} + assert logged() == {720: ["my sandwich"]} h.on_logged.assert_not_called() class TestConfirm: def test_confirm_fills_only_the_empty_slots(self) -> None: - log_meal("my sandwich", _nutrition(), 12) + log_meal("my sandwich", _nutrition(), 720) h = Harness() with fetching(delivered(*FOUR)) as start: propose(h) h.flow.click() assert start.call_count == 1 # the confirm did not refetch assert logged() == { - 8: ["Owsianka"], - 12: ["my sandwich"], - 16: ["Obiad"], - 20: ["Kolacja"], + 480: ["Owsianka"], + 720: ["my sandwich"], + 960: ["Obiad"], + 1200: ["Kolacja"], } assert h.status == "Logged 3 catering dish(es)." h.on_logged.assert_called_once_with() @@ -207,7 +209,7 @@ class TestConfirm: with fetching(delivered(*five)): propose(h) h.flow.click() - assert logged()[8] == ["Owsianka", "Wrap"] + assert logged()[480] == ["Owsianka", "Wrap"] assert h.status == "Logged 5 catering dish(es)." def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None: @@ -215,16 +217,16 @@ class TestConfirm: with fetching(delivered(*FOUR)): propose(h) assert h.label.get() == "✓ Confirm (4)" - log_meal("toast", _nutrition(), 8) + log_meal("toast", _nutrition(), 480) h.flow.click() - assert logged()[8] == ["toast"] + assert logged()[480] == ["toast"] assert h.status == "Logged 3 catering dish(es)." def test_every_slot_taken_between_the_clicks(self) -> None: h = Harness() with fetching(delivered(*FOUR)): propose(h) - for slot in (8, 12, 16, 20): + for slot in (480, 720, 960, 1200): log_meal(f"mine {slot}", _nutrition(), slot) h.flow.click() assert h.status == "Every slot already has a meal — nothing to fill." @@ -240,10 +242,8 @@ class TestConfirm: with patch.object(_gatelock_fillall, "now_local", return_value=tomorrow): h.flow.click() assert start.call_count == 2 - assert start.call_args.args == ( - _gatelock_fillall._refresh_delivery, - tomorrow.date(), - ) + refetch = (_gatelock_fillall._refresh_delivery, tomorrow.date()) + assert start.call_args.args == refetch assert logged() == {} h.on_logged.assert_not_called() assert h.label.get() == FILL_LABEL diff --git a/diet_guard/tests/test_gatelock_fillall_gate.py b/diet_guard/tests/test_gatelock_fillall_gate.py index 3e934a7..9976fc5 100644 --- a/diet_guard/tests/test_gatelock_fillall_gate.py +++ b/diet_guard/tests/test_gatelock_fillall_gate.py @@ -25,7 +25,7 @@ from diet_guard._gatelock_layout import build_layout from diet_guard._gatelock_mealflow import _UNLOCK_DELAY_MS from diet_guard._gatelock_ui import GateCallbacks, make_vars from diet_guard._state import log_meal, now_local -from diet_guard._state_today import today_entries +from diet_guard._state_today import logged_slots_today from diet_guard.tests._gate_fixtures import fake_tk from diet_guard.tests.conftest import _nutrition from diet_guard.tests.test_gatelock_fillall import ( @@ -50,7 +50,7 @@ def _due_from_log(pending: list[int]) -> Callable[[], tuple[int, ...]]: """``due_slots`` as the log on disk says, so an unlock needs a real write.""" def due() -> tuple[int, ...]: - taken = {entry.get("slot") for entry in today_entries()} + taken = logged_slots_today() return tuple(slot for slot in pending if slot not in taken) return due @@ -86,29 +86,29 @@ class TestTheGateDrivesTheFlow: def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None: gate.demo_mode = False # read once, when the flow is first built - gate._pending = [8, 12] + gate._pending = [480, 720] # Fill all also banks the dishes for one-at-a-time offering; the # unlock line must not then call them "still delivered". gate._delivery_pending = FOUR - due = _due_from_log([8, 12]) + due = _due_from_log([480, 720]) with patch.object(_gatelock_delivery, "due_slots", side_effect=due): _fill(gate, delivered(*FOUR)) status = gate._vars.status.get() assert status == "Filled from catering — all meals logged, unlocking…" assert gate._pending == [] gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close) - assert sorted(logged()) == [8, 12, 16, 20] + assert sorted(logged()) == [480, 720, 960, 1200] assert gate._vars.fill_label.get() == FILL_LABEL def test_a_partial_fill_advances_instead_of_unlocking(self, gate: MealGate) -> None: gate.demo_mode = False - gate._pending = [8, 12] - due = _due_from_log([8, 12]) + gate._pending = [480, 720] + due = _due_from_log([480, 720]) # Two dishes spread onto 08:00 and 16:00: 12:00 is still owed. two = (dish("Owsianka", 1), dish("Obiad", 2)) with patch.object(_gatelock_delivery, "due_slots", side_effect=due): _fill(gate, delivered(*two)) - assert gate._pending == [12] + assert gate._pending == [720] assert "Pulled 1 meal " in gate._vars.status.get() assert "unlocking" not in gate._vars.status.get() diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py index 7890357..76713e6 100644 --- a/diet_guard/tests/test_gatelock_mealflow.py +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -194,14 +194,14 @@ class TestRecord: def test_demo_logs_without_slot(self, gate: MealGate) -> None: """A demo record banks the food but tags no real slot.""" - gate._pending = [8] + gate._pending = [480] with patch.object(_gatelock_mealflow, "log_meal") as log: gate._record("apple", _nutrition(95, 100)) assert log.call_args.args[2] is None def test_last_slot_unlocks(self, gate: MealGate) -> None: """Recording the final pending slot triggers the unlock.""" - gate._pending = [8] + gate._pending = [480] with ( patch.object(_gatelock_mealflow, "log_meal"), patch.object(_gatelock_mealflow, "remember_food"), @@ -212,13 +212,13 @@ class TestRecord: def test_more_slots_continue(self, gate: MealGate) -> None: """With slots remaining, the form clears and prompts the next.""" - gate._pending = [8, 12] + gate._pending = [480, 720] with ( patch.object(_gatelock_mealflow, "log_meal"), patch.object(_gatelock_mealflow, "remember_food"), ): gate._record("apple", _nutrition(95, 100)) - assert gate._pending == [12] + assert gate._pending == [720] assert "next meal" in gate._vars.status.get() def test_unlock_schedules_close(self, gate: MealGate) -> None: diff --git a/diet_guard/tests/test_gatelock_scheduleedit.py b/diet_guard/tests/test_gatelock_scheduleedit.py index 3120ab7..e2206b0 100644 --- a/diet_guard/tests/test_gatelock_scheduleedit.py +++ b/diet_guard/tests/test_gatelock_scheduleedit.py @@ -1,15 +1,18 @@ """Tests for the gate History tab's meal-schedule row. -Covers :mod:`._gatelock_scheduleedit`. The functional fake ``tk`` widgets and -the ``gate`` fixture live in ``conftest.py``, shared with the other gate -tests. +Covers :mod:`._gatelock_scheduleedit` and the row's widgets in +:mod:`._gatelock_calendar_schedule`. The functional fake ``tk``/``ttk`` +widgets and the ``gate`` fixture live in ``conftest.py``, shared with the +other gate tests. """ from __future__ import annotations from typing import TYPE_CHECKING -from diet_guard._gatelock_scheduleedit import schedule_summary +import pytest + +from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule from diet_guard._meal_schedule_store import current_schedule, record_schedule_change @@ -18,21 +21,30 @@ if TYPE_CHECKING: def _type(gate: MealGate, first: str, last: str, count: str) -> None: - """Fill the three schedule entries as the user would.""" + """Fill the three schedule fields as the user would.""" gate._cal_vars.schedule.first.set(first) gate._cal_vars.schedule.last.set(last) gate._cal_vars.schedule.count.set(count) +def _status(gate: MealGate) -> str: + return gate._cal_vars.schedule.status.get() + + class TestScheduleSummary: """The derived-times label.""" def test_lists_every_checkpoint(self) -> None: - """The label spells out the hours the schedule derives.""" - assert schedule_summary(MealSchedule(8, 20, 5)) == ( + """The label spells out the times the schedule derives.""" + assert schedule_summary(MealSchedule(480, 1200, 5)) == ( "08:00 11:00 14:00 17:00 20:00" ) + def test_an_off_hour_schedule_shows_its_minutes(self) -> None: + assert schedule_summary(MealSchedule(435, 1140, 5)) == ( + "07:15 10:15 13:15 16:00 19:00" + ) + class TestEditToggle: """The Edit/Save button's two states.""" @@ -47,71 +59,95 @@ class TestEditToggle: def test_second_click_persists_and_relocks(self, gate: MealGate) -> None: """Clicking Save validates, writes, and returns to read-only.""" gate._on_edit_or_save_schedule() - _type(gate, "8", "20", "5") + _type(gate, "07:15", "19:00", "5") gate._on_edit_or_save_schedule() assert not gate._cal_editing_schedule - assert current_schedule() == MealSchedule(8, 20, 5) - assert gate._cal_vars.schedule.status.get() == "Saved." + assert current_schedule() == MealSchedule(435, 1140, 5) + assert _status(gate) == "Saved." + assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15") + + def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None: + """Typing ``8`` means 08:00, as it did before minutes existed.""" + _type(gate, "8", "20", "5") + + assert gate._save_schedule_entry() + assert current_schedule() == MealSchedule(480, 1200, 5) + assert gate._cal_vars.schedule.first.get() == "08:00" def test_a_failed_save_leaves_editing_open(self, gate: MealGate) -> None: """A bad value can be corrected rather than silently discarded.""" gate._on_edit_or_save_schedule() - _type(gate, "20", "8", "4") + _type(gate, "20:00", "08:00", "4") gate._on_edit_or_save_schedule() assert gate._cal_editing_schedule assert current_schedule() == DEFAULT_SCHEDULE + def test_the_comboboxes_lock_as_disabled_not_readonly(self, gate: MealGate) -> None: + """A ``readonly`` ttk combobox still lets the list edit a locked row.""" + widgets = gate._cal_widgets + gate._on_edit_or_save_schedule() + assert widgets.schedule_first_entry.configured["state"] == "normal" + assert widgets.schedule_count_entry.configured["state"] == "normal" + + _type(gate, "07:15", "19:00", "5") + gate._on_edit_or_save_schedule() + assert widgets.schedule_first_entry.configured["state"] == "disabled" + assert widgets.schedule_last_entry.configured["state"] == "disabled" + assert widgets.schedule_count_entry.configured["state"] == "readonly" + class TestValidation: """Rejected input, with the reason shown rather than silently clamped.""" - def test_rejects_non_numeric(self, gate: MealGate) -> None: - """Letters in an hour field are refused, not coerced.""" - _type(gate, "eight", "20", "4") + @pytest.mark.parametrize("first", ["eight", "7:5", "24:00", "07:60", ""]) + def test_rejects_a_time_that_is_not_hhmm(self, gate: MealGate, first: str) -> None: + _type(gate, first, "20:00", "4") assert not gate._save_schedule_entry() - assert "whole hours" in gate._cal_vars.schedule.status.get() + assert "HH:MM" in _status(gate) - def test_rejects_a_backwards_window(self, gate: MealGate) -> None: - """The last meal must come after the first.""" - _type(gate, "20", "8", "4") + def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None: + _type(gate, "08:00", "20:00", "five") assert not gate._save_schedule_entry() - assert "before the last" in gate._cal_vars.schedule.status.get() + assert "meals per day as a number" in _status(gate) - def test_rejects_an_out_of_range_hour(self, gate: MealGate) -> None: - """Hours outside 0-23 are refused.""" - _type(gate, "8", "25", "4") + @pytest.mark.parametrize( + ("first", "last", "count", "reason"), + [ + ("20:00", "08:00", "4", "at least 15 min after"), + ("08:00", "08:14", "2", "at least 15 min after"), + ("08:00", "20:00", "1", "Meals per day must be 2-6."), + ("08:00", "20:00", "9", "Meals per day must be 2-6."), + ("08:00", "08:45", "5", "Too many meals"), + ], + ) + def test_rejects_what_normalize_would_silently_change( + self, gate: MealGate, first: str, last: str, count: str, reason: str + ) -> None: + _type(gate, first, last, count) assert not gate._save_schedule_entry() - assert "0-23" in gate._cal_vars.schedule.status.get() + assert reason in _status(gate) + assert current_schedule() == DEFAULT_SCHEDULE - def test_rejects_too_few_meals(self, gate: MealGate) -> None: - """One meal a day is below the supported range.""" - _type(gate, "8", "20", "1") + def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None: + """15 min holds two meals; 45 min holds four (one per quarter hour).""" + _type(gate, "08:00", "08:45", "4") - assert not gate._save_schedule_entry() - assert "Meals per day" in gate._cal_vars.schedule.status.get() + assert gate._save_schedule_entry() + assert current_schedule().slots() == (480, 495, 510, 525) - def test_rejects_too_many_meals(self, gate: MealGate) -> None: - """More than the cap is refused.""" - _type(gate, "8", "20", "9") - - assert not gate._save_schedule_entry() - assert "Meals per day" in gate._cal_vars.schedule.status.get() - - def test_rejects_more_meals_than_the_window_holds(self, gate: MealGate) -> None: - """A narrow window cannot hold six distinct whole-hour checkpoints. - - Without this the derivation would round two meals onto the same hour, - and a duplicate slot silently drops a checkpoint. - """ - _type(gate, "8", "11", "6") - - assert not gate._save_schedule_entry() - assert "Too many meals" in gate._cal_vars.schedule.status.get() + def test_an_accepted_schedule_is_stored_unchanged(self) -> None: + """No rule here is looser than ``normalized()``: what passes is kept.""" + for first in range(0, 1440, 37): + for last in range(first + 15, 1440, 53): + for count in range(2, 7): + schedule = MealSchedule(first, last, count) + if schedule_problem(schedule) is None: + assert schedule.normalized() == schedule class TestDisplay: @@ -119,27 +155,27 @@ class TestDisplay: def test_shows_the_stored_schedule(self, gate: MealGate) -> None: """The fields and the derived-times label reflect what is stored.""" - gate._show_schedule(MealSchedule(7, 21, 3)) + gate._show_schedule(MealSchedule(435, 1260, 3)) - assert gate._cal_vars.schedule.first.get() == "7" - assert gate._cal_vars.schedule.last.get() == "21" + assert gate._cal_vars.schedule.first.get() == "07:15" + assert gate._cal_vars.schedule.last.get() == "21:00" assert gate._cal_vars.schedule.count.get() == "3" - assert gate._cal_vars.schedule.times.get() == "07:00 14:00 21:00" + assert gate._cal_vars.schedule.times.get() == "07:15 14:15 21:00" def test_a_refresh_does_not_clobber_an_open_edit(self, gate: MealGate) -> None: """Typing survives a calendar refresh landing mid-edit.""" gate._on_edit_or_save_schedule() - _type(gate, "6", "22", "6") + _type(gate, "06:00", "22:00", "6") gate._refresh_calendar() - assert gate._cal_vars.schedule.first.get() == "6" + assert gate._cal_vars.schedule.first.get() == "06:00" def test_a_refresh_shows_the_stored_schedule_when_idle( self, gate: MealGate ) -> None: """Outside an edit the row tracks whatever is stored.""" - record_schedule_change(MealSchedule(9, 19, 3)) + record_schedule_change(MealSchedule(540, 1140, 3)) gate._refresh_calendar() - assert gate._cal_vars.schedule.first.get() == "9" + assert gate._cal_vars.schedule.first.get() == "09:00" assert gate._cal_vars.schedule.times.get() == "09:00 14:00 19:00" diff --git a/diet_guard/tests/test_kuchnia_fill.py b/diet_guard/tests/test_kuchnia_fill.py index 176840b..56b72c3 100644 --- a/diet_guard/tests/test_kuchnia_fill.py +++ b/diet_guard/tests/test_kuchnia_fill.py @@ -40,33 +40,40 @@ def _eat(desc: str, slot: int) -> None: class TestFillPlan: def test_an_empty_day_fills_every_slot(self) -> None: - plan = [_slotted("A", 8), _slotted("B", 12), _slotted("C", 20)] + plan = [_slotted("A", 480), _slotted("B", 720), _slotted("C", 1200)] assert fill_plan(plan) == plan def test_a_slot_with_a_meal_keeps_it(self) -> None: - _eat("apple", 12) - plan = fill_plan([_slotted("A", 8), _slotted("B", 12), _slotted("C", 20)]) - assert [item.slot for item in plan] == [8, 20] + _eat("apple", 720) + plan = fill_plan([_slotted("A", 480), _slotted("B", 720), _slotted("C", 1200)]) + assert [item.slot for item in plan] == [480, 1200] def test_two_dishes_sharing_an_empty_slot_both_land(self) -> None: # 5 dishes on 4 slots double up the first; occupancy is read once, # before the batch, so the second dish is not refused by the first. - plan = fill_plan([_slotted("A", 8), _slotted("B", 8)]) + plan = fill_plan([_slotted("A", 480), _slotted("B", 480)]) assert log_dishes(plan) == ["A", "B"] def test_a_full_day_fills_nothing(self) -> None: - for slot in (8, 12): + for slot in (480, 720): _eat("apple", slot) - assert fill_plan([_slotted("A", 8), _slotted("B", 12)]) == [] + assert fill_plan([_slotted("A", 480), _slotted("B", 720)]) == [] + + def test_a_meal_near_a_slot_occupies_it(self) -> None: + # Logged at 08:15 (say, under an older schedule): it snaps to 08:00, + # the same rule the gate uses, so catering must not double that slot. + _eat("apple", 495) + plan = fill_plan([_slotted("A", 480), _slotted("B", 720)]) + assert [item.slot for item in plan] == [720] class TestCliFill: def test_log_skips_a_taken_slot(self) -> None: # Two dishes on the default four slots spread to 08:00 and 16:00. - _eat("apple", 8) + _eat("apple", 480) dishes = [ - replace(_slotted("Owsianka", 8).dish, priority=1), - replace(_slotted("Kaszotto", 16).dish, priority=2), + replace(_slotted("Owsianka", 480).dish, priority=1), + replace(_slotted("Kaszotto", 960).dish, priority=2), ] lines: list[str] = [] with patch.object( diff --git a/diet_guard/tests/test_kuchnia_import.py b/diet_guard/tests/test_kuchnia_import.py index a926d03..d19c4ac 100644 --- a/diet_guard/tests/test_kuchnia_import.py +++ b/diet_guard/tests/test_kuchnia_import.py @@ -194,8 +194,8 @@ class TestRefreshOnce: class TestLogDishes: def test_logs_each_dish_into_its_slot(self) -> None: chosen = [ - SlottedDish(dish=_dish("A", kcal=100.0), slot=8), - SlottedDish(dish=_dish("B", kcal=200.0), slot=12), + SlottedDish(dish=_dish("A", kcal=100.0), slot=480), + SlottedDish(dish=_dish("B", kcal=200.0), slot=720), ] assert log_dishes(chosen) == ["A", "B"] entries = today_entries() @@ -203,33 +203,41 @@ class TestLogDishes: assert today_total_kcal() == 300.0 def test_records_the_provenance(self) -> None: - log_dishes([SlottedDish(dish=_dish("A"), slot=8)]) + log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) (entry,) = today_entries() assert entry["source"] == "kuchnia wikinga" def test_a_second_run_logs_nothing(self) -> None: - chosen = [SlottedDish(dish=_dish("A"), slot=8)] + chosen = [SlottedDish(dish=_dish("A"), slot=480)] log_dishes(chosen) assert log_dishes(chosen) == [] assert len(today_entries()) == 1 def test_the_same_dish_in_a_different_slot_is_a_separate_meal(self) -> None: - log_dishes([SlottedDish(dish=_dish("A"), slot=8)]) - assert log_dishes([SlottedDish(dish=_dish("A"), slot=12)]) == ["A"] + log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) + assert log_dishes([SlottedDish(dish=_dish("A"), slot=720)]) == ["A"] assert len(today_entries()) == 2 def test_duplicates_within_one_batch_land_once(self) -> None: # today_entries() is read once, before the loop; without reflecting the # writes locally both copies would be logged. chosen = [ - SlottedDish(dish=_dish("A"), slot=8), - SlottedDish(dish=_dish("A"), slot=8), + SlottedDish(dish=_dish("A"), slot=480), + SlottedDish(dish=_dish("A"), slot=480), ] assert log_dishes(chosen) == ["A"] def test_matching_ignores_case_and_padding(self) -> None: - log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=8)]) - assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=8)]) == [] + log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=480)]) + assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=480)]) == [] + + def test_a_dish_logged_near_the_slot_is_not_relogged(self) -> None: + # The phone logged it at 08:15 under an older schedule: it snaps to + # today's 08:00, so re-importing must not write a second copy. + log_dishes([SlottedDish(dish=_dish("A"), slot=495)]) + assert log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) == [] + (entry,) = today_entries() + assert entry["slot_min"] == 495 def test_logging_nothing_is_not_an_error(self) -> None: assert log_dishes([]) == [] diff --git a/diet_guard/tests/test_kuchnia_parity.py b/diet_guard/tests/test_kuchnia_parity.py index 2af4e5b..edc348c 100644 --- a/diet_guard/tests/test_kuchnia_parity.py +++ b/diet_guard/tests/test_kuchnia_parity.py @@ -32,6 +32,7 @@ from diet_guard import _kuchnia_log from diet_guard._kuchnia_import import dish_to_record from diet_guard._kuchnia_parse import Dish, parse_menu from diet_guard._kuchnia_spread import SlottedDish, assign_slots +from diet_guard._slot_wire import slot_fields FIXTURE = Path(__file__).resolve().parents[2] / "tests/fixtures/kuchnia_day.json" @@ -151,6 +152,12 @@ def test_bank_record_numbers_are_floats(dishes: list[Dish]) -> None: assert not isinstance(record["count"], bool) +def _as_entry(row: dict[str, Any]) -> dict[str, Any]: + """Turn a fixture row's resolved ``slot`` minute into on-disk entry fields.""" + fields = {key: value for key, value in row.items() if key != "slot"} + return {**fields, **slot_fields(row["slot"])} + + def _spread( dishes: list[Dish], fill: dict[str, Any], case: dict[str, Any] ) -> list[SlottedDish]: @@ -168,7 +175,10 @@ def test_fill_plan_matches( fill = fixture["expected"]["fill"] assert fill["fill_plan"], "fixture carries no fill_plan cases" for key, case in fill["fill_plan"].items(): - today = [{"slot": slot} for slot in case["occupied"]] + # ``occupied`` holds RESOLVED slot minutes, not on-disk fields: seed + # them through the writer's encoding, never as a raw ``{"slot": m}`` + # (which the reader would take for an hour, i.e. ``m * 60``). + today = [slot_fields(slot) for slot in case["occupied"]] monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t) kept = _kuchnia_log.fill_plan(_spread(dishes, fill, case)) actual = [[item.dish.name, item.slot] for item in kept] @@ -185,7 +195,8 @@ def test_log_dishes_matches( fill = fixture["expected"]["fill"] for key, case in fill["log_dishes"].items(): recorder = MagicMock() - monkeypatch.setattr(_kuchnia_log, "today_entries", lambda c=case: c["today"]) + today = [_as_entry(row) for row in case["today"]] + monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t) monkeypatch.setattr(_kuchnia_log, "log_meal", recorder) _kuchnia_log.log_dishes(_spread(dishes, fill, case)) written = [[call.args[0], call.args[2]] for call in recorder.call_args_list] diff --git a/diet_guard/tests/test_kuchnia_parse.py b/diet_guard/tests/test_kuchnia_parse.py index 518b909..93d04aa 100644 --- a/diet_guard/tests/test_kuchnia_parse.py +++ b/diet_guard/tests/test_kuchnia_parse.py @@ -137,18 +137,20 @@ def _dish(priority: int, name: str = "d") -> Dish: class TestAssignSlots: def test_follows_the_providers_priority_not_payload_order(self) -> None: dishes = [_dish(3), _dish(1), _dish(2)] - assigned = assign_slots(dishes, (8, 12, 16)) + assigned = assign_slots(dishes, (480, 720, 960)) assert [item.dish.name for item in assigned] == ["d1", "d2", "d3"] - assert [item.slot for item in assigned] == [8, 12, 16] + assert [item.slot for item in assigned] == [480, 720, 960] def test_five_dishes_over_four_slots_double_up_the_earliest(self) -> None: - assigned = assign_slots([_dish(i) for i in range(1, 6)], (8, 12, 16, 20)) - assert [item.slot for item in assigned] == [8, 8, 12, 16, 20] + assigned = assign_slots([_dish(i) for i in range(1, 6)], (480, 720, 960, 1200)) + assert [item.slot for item in assigned] == [480, 480, 720, 960, 1200] def test_five_dishes_over_five_slots_map_one_to_one(self) -> None: # kuhy's real schedule; the catering plan is also five meals. - assigned = assign_slots([_dish(i) for i in range(1, 6)], (8, 11, 14, 17, 20)) - assert [item.slot for item in assigned] == [8, 11, 14, 17, 20] + assigned = assign_slots( + [_dish(i) for i in range(1, 6)], (480, 660, 840, 1020, 1200) + ) + assert [item.slot for item in assigned] == [480, 660, 840, 1020, 1200] @pytest.mark.parametrize("count", range(1, 9)) @pytest.mark.parametrize("span", range(2, 7)) @@ -170,8 +172,8 @@ class TestAssignSlots: # Two dishes sharing a priority must not reshuffle between runs: an # unstable order looks like a change and re-stamps every bank record. dishes = [_dish(1, "b"), _dish(1, "a")] - first = [item.dish.name for item in assign_slots(dishes, (8, 12))] - second = [item.dish.name for item in assign_slots(dishes[::-1], (8, 12))] + first = [item.dish.name for item in assign_slots(dishes, (480, 720))] + second = [item.dish.name for item in assign_slots(dishes[::-1], (480, 720))] assert first == second @pytest.mark.parametrize( diff --git a/diet_guard/tests/test_kuchnia_queue.py b/diet_guard/tests/test_kuchnia_queue.py index 5e82413..8cb69e4 100644 --- a/diet_guard/tests/test_kuchnia_queue.py +++ b/diet_guard/tests/test_kuchnia_queue.py @@ -135,7 +135,7 @@ class TestTheGateAdvancesTheQueue: a dead letter: the form clears and the remaining dishes are stranded behind another button click, which is an unguarded network walk each. """ - gate._pending = [8, 12] + gate._pending = [480, 720] gate._delivery_pending = (_dish("Second course"),) with ( patch.object(_gatelock_mealflow, "log_meal"), diff --git a/diet_guard/tests/test_mcp.py b/diet_guard/tests/test_mcp.py index a0eed12..d10900a 100644 --- a/diet_guard/tests/test_mcp.py +++ b/diet_guard/tests/test_mcp.py @@ -34,8 +34,8 @@ class TestGetStatus: _mcp_read, "today_total_macros", return_value=(30.0, 50.0, 12.0) ), patch.object(_mcp_read, "consumption_band", return_value="on track"), - patch.object(_mcp_read, "due_slots", return_value=(12,)), - patch.object(_mcp_read, "logged_slots_today", return_value={8}), + patch.object(_mcp_read, "due_slots", return_value=(720,)), + patch.object(_mcp_read, "logged_slots_today", return_value={480}), patch.object(_mcp_read, "current_slot", return_value=12), patch.object(_mcp, "now_local"), patch.object(_mcp_read, "slot_label", return_value="12:00"), @@ -46,7 +46,7 @@ class TestGetStatus: assert out["consumption_band"] == "on track" assert out["budget_initialized"] is True assert out["due_slots"] == ["12:00"] - assert out["logged_slots"] == [8] + assert out["logged_slots"] == [480] assert out["current_slot"] == 12 def test_no_budget_hides_band(self) -> None: @@ -92,7 +92,8 @@ class TestListToday: view = out["entries"][0] assert view["desc"] == "big mac" assert view["kcal"] == 550.0 - assert view["slot"] == 12 + # The legacy on-disk hour is reported as a minute, like every slot. + assert view["slot"] == 720 assert "hmac" not in view def test_empty(self) -> None: @@ -104,14 +105,13 @@ class TestListToday: class TestGetSlots: def test_lists_slots_and_current(self) -> None: with ( - patch.object(_mcp_read, "day_slots", return_value=(8, 12, 16, 20)), - patch.object(_mcp_read, "current_slot", return_value=16), + patch.object(_mcp_read, "day_slots", return_value=(435, 720, 960, 1200)), + patch.object(_mcp_read, "current_slot", return_value=960), patch.object(_mcp, "now_local"), - patch.object(_mcp_read, "slot_label", side_effect=lambda s: f"{s:02d}:00"), ): out = _mcp.get_slots() - assert out["current_slot"] == 16 - assert out["day_slots"][0] == {"hour": 8, "label": "08:00"} + assert out["current_slot"] == 960 + assert out["day_slots"][0] == {"minute": 435, "label": "07:15"} assert len(out["day_slots"]) == 4 @@ -141,7 +141,7 @@ class TestLogMealGate: ) record.assert_not_called() assert out["preview"] is True - assert out["target_slot"] == 12 + assert out["target_slot"] == 720 # the legacy int hour, as minutes assert out["resolved"]["kcal"] == 250.0 assert out["confirm_required"] is True @@ -149,7 +149,7 @@ class TestLogMealGate: with ( patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()), patch.object(_mcp, "ManualMacros") as manual, - patch.object(_mcp, "slot_for_log", return_value=8), + patch.object(_mcp, "slot_for_log", return_value=480), patch.object(_mcp, "now_local"), patch.object(_mcp, "record_meal") as record, ): @@ -157,7 +157,7 @@ class TestLogMealGate: manual.assert_not_called() record.assert_not_called() assert out["preview"] is True - assert out["target_slot"] == 8 + assert out["target_slot"] == 480 def test_confirm_applies_signed(self) -> None: entry = {"desc": "apple", "hmac": "sig"} @@ -169,7 +169,7 @@ class TestLogMealGate: record.assert_called_once() assert out["applied"] is True assert out["signed"] is True - assert out["target_slot"] == 8 + assert out["target_slot"] == 480 def test_confirm_applies_unsigned(self) -> None: entry = {"desc": "apple"} diff --git a/diet_guard/tests/test_mcp_slot.py b/diet_guard/tests/test_mcp_slot.py new file mode 100644 index 0000000..e013912 --- /dev/null +++ b/diet_guard/tests/test_mcp_slot.py @@ -0,0 +1,65 @@ +"""The MCP ``log_meal`` tool's ``slot`` argument: ``"HH:MM"`` or a legacy hour. + +Kept apart from ``test_mcp.py`` for the repo's file-length cap. These run +against the real (redirected) schedule store rather than a patched +``day_slots``, so "is this one of today's slots?" is answered the way the gate +answers it. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from diet_guard import _mcp +from diet_guard._estimator import Nutrition +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import record_schedule_change + +_MEAL = Nutrition(250.0, 10.0, 20.0, 5.0, 150.0, "manual") + + +def _preview(slot: int | str) -> dict[str, object]: + """Preview a log with ``slot`` (never writes: ``confirm`` stays False).""" + with patch.object(_mcp, "resolve_nutrition", return_value=_MEAL): + return _mcp.log_meal("apple", slot=slot) + + +class TestSlotArgument: + def test_an_hhmm_string_names_an_off_hour_slot(self) -> None: + record_schedule_change(MealSchedule(435, 1140, 5)) + assert _preview("07:15")["target_slot"] == 435 + + def test_a_legacy_int_hour_is_read_as_that_hour(self) -> None: + """``12`` is 12:00 -- never 00:12, which is what a bare minute would be.""" + assert _preview(12)["target_slot"] == 720 + + def test_an_hh_string_is_a_whole_hour(self) -> None: + assert _preview("20")["target_slot"] == 1200 + + @pytest.mark.parametrize( + ("slot", "reason"), + [ + ("13:00", "13:00 is not a meal slot today"), + (13, "13:00 is not a meal slot today"), + ("noon", "invalid time"), + (25, "is not an hour 0-23"), + (-1, "is not an hour 0-23"), + (True, "is not an hour 0-23"), + ], + ) + def test_anything_but_one_of_todays_slots_is_refused( + self, slot: int | str, reason: str + ) -> None: + """A clear reason, and nothing resolved or written.""" + with patch.object(_mcp, "record_meal") as record: + out = _preview(slot) + record.assert_not_called() + assert out["ok"] is False + assert reason in str(out["reason"]) + + def test_the_refusal_lists_the_valid_slots(self) -> None: + record_schedule_change(MealSchedule(435, 1140, 5)) + reason = str(_preview("07:00")["reason"]) + assert "07:15, 10:15, 13:15, 16:00, 19:00" in reason diff --git a/diet_guard/tests/test_meal_schedule_store.py b/diet_guard/tests/test_meal_schedule_store.py index 0f38091..0ad8818 100644 --- a/diet_guard/tests/test_meal_schedule_store.py +++ b/diet_guard/tests/test_meal_schedule_store.py @@ -30,16 +30,32 @@ class TestEntryJson: """Encoding then decoding preserves the schedule and the stamp.""" entry = store.ScheduleEntry( effective_from="2026-08-16", - schedule=MealSchedule(8, 20, 5), + schedule=MealSchedule(480, 1200, 5), edited_at="2026-08-16T12:00:00+00:00", ) assert store.entry_from_json("2026-08-16", store.entry_to_json(entry)) == entry + def test_a_whole_hour_schedule_encodes_exactly_as_before(self) -> None: + """No ``fm``/``lm`` for whole hours: an older build reads it unchanged.""" + entry = store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t") + assert store.entry_to_json(entry) == {"f": 8, "l": 20, "n": 5, "t": "t"} + + def test_an_off_hour_schedule_round_trips_its_minutes(self) -> None: + """07:15-19:00 keeps its quarter hour; ``f`` still carries the hour.""" + entry = store.ScheduleEntry("2026-08-16", MealSchedule(435, 1140, 5), "t") + raw = store.entry_to_json(entry) + assert raw == {"f": 7, "l": 19, "n": 5, "fm": 435, "t": "t"} + assert store.entry_from_json("2026-08-16", raw) == entry + + def test_rejects_a_bool_field(self) -> None: + """``true`` is not an int on the Dart side, so it is not one here.""" + assert store.entry_from_json("2026-08-16", {"f": True, "l": 20, "n": 4}) is None + def test_normalizes_on_the_way_in(self) -> None: """A peer's out-of-range schedule is clamped, not trusted verbatim.""" entry = store.entry_from_json("2026-08-16", {"f": 8, "l": 20, "n": 99}) assert entry is not None - assert entry.schedule == MealSchedule(8, 20, 6) + assert entry.schedule == MealSchedule(480, 1200, 6) def test_missing_timestamp_falls_back_to_the_epoch(self) -> None: """An entry with no stamp still parses, losing only its ordering.""" @@ -62,7 +78,7 @@ class TestHistoryJson: def test_round_trips_and_sorts(self) -> None: """Entries come back ascending regardless of insertion order.""" entries = ( - store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"), + store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t1"), store.ScheduleEntry("2026-01-01", DEFAULT_SCHEDULE, "t0"), ) parsed = store.history_from_json(store.history_to_json(entries)) @@ -90,17 +106,21 @@ class TestScheduleForDay: def test_defaults_when_the_history_is_silent(self) -> None: """A day before any entry uses the default, not the newest entry.""" - entries = (store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t"),) + entries = (store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t"),) assert store.schedule_for_day(entries, "2026-08-15") == DEFAULT_SCHEDULE def test_uses_the_newest_applicable_entry(self) -> None: """The latest entry effective on or before the day wins.""" entries = ( - store.ScheduleEntry("2026-01-01", MealSchedule(8, 20, 4), "t0"), - store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"), + store.ScheduleEntry("2026-01-01", MealSchedule(480, 1200, 4), "t0"), + store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t1"), + ) + assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule( + 480, 1200, 5 + ) + assert store.schedule_for_day(entries, "2026-05-01") == MealSchedule( + 480, 1200, 4 ) - assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5) - assert store.schedule_for_day(entries, "2026-05-01") == MealSchedule(8, 20, 4) class TestUpsert: @@ -108,16 +128,16 @@ class TestUpsert: def test_appends_a_new_day(self) -> None: """An edit on a fresh day adds an entry.""" - entries = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16")) + entries = store.upsert((), MealSchedule(480, 1200, 5), _at("2026-08-16")) assert len(entries) == 1 assert entries[0].effective_from == "2026-08-16" def test_replaces_a_same_day_re_edit(self) -> None: """Editing twice in one day leaves one entry, not two.""" - first = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16")) - second = store.upsert(first, MealSchedule(9, 21, 3), _at("2026-08-16")) + first = store.upsert((), MealSchedule(480, 1200, 5), _at("2026-08-16")) + second = store.upsert(first, MealSchedule(540, 1260, 3), _at("2026-08-16")) assert len(second) == 1 - assert second[0].schedule == MealSchedule(9, 21, 3) + assert second[0].schedule == MealSchedule(540, 1260, 3) class TestSeedDefault: @@ -144,10 +164,10 @@ class TestPersistence: def test_round_trips_through_disk(self) -> None: """What is written is what is read back.""" - store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16")) + store.record_schedule_change(MealSchedule(480, 1200, 5), when=_at("2026-08-16")) assert store.schedule_for_day( store.load_entries(), "2026-08-16" - ) == MealSchedule(8, 20, 5) + ) == MealSchedule(480, 1200, 5) def test_recording_grandfathers_earlier_days(self) -> None: """Switching to five meals leaves past days on the four-meal schedule. @@ -156,10 +176,12 @@ class TestPersistence: past day would adopt the schedule chosen today and look like it had missed a checkpoint. """ - store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16")) + store.record_schedule_change(MealSchedule(480, 1200, 5), when=_at("2026-08-16")) entries = store.load_entries() assert store.schedule_for_day(entries, "2020-01-01") == DEFAULT_SCHEDULE - assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5) + assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule( + 480, 1200, 5 + ) def test_corrupt_file_degrades_to_the_default(self) -> None: """Unparsable JSON is treated as "no history", never a crash.""" @@ -189,8 +211,8 @@ class TestPersistence: def test_record_defaults_to_now(self) -> None: """Omitting ``when`` stamps the edit with the current time.""" - store.record_schedule_change(MealSchedule(7, 19, 3)) + store.record_schedule_change(MealSchedule(420, 1140, 3)) today = datetime.now(tz=UTC).astimezone().date().isoformat() assert store.schedule_for_day(store.load_entries(), today) == MealSchedule( - 7, 19, 3 + 420, 1140, 3 ) diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index f702acc..fe58185 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -81,25 +81,25 @@ class TestLogAndTotals: def test_log_and_total(self) -> None: """A logged meal counts toward the day's calories.""" - log_meal("toast", _nut(150), slot=8) + log_meal("toast", _nut(150), slot=480) assert today_total_kcal() == 150.0 def test_entry_carries_signature(self) -> None: """With a key present, the stored entry is signed.""" - entry = log_meal("toast", _nut(150), slot=8) + entry = log_meal("toast", _nut(150), slot=480) assert "hmac" in entry def test_unsigned_when_no_key(self) -> None: """With no key, the entry is written unsigned and still read back.""" with patch.object(_state, "compute_entry_hmac", return_value=None): - log_meal("toast", _nut(150), slot=8) + log_meal("toast", _nut(150), slot=480) assert "hmac" not in _raw()[next(iter(_raw()))][0] assert today_total_kcal() == 150.0 def test_macros_sum(self) -> None: """today_total_macros sums protein/carbs/fat across entries.""" - log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=8) - log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=12) + log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=480) + log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=720) assert today_total_macros() == (16.0, 45.0, 11.0) def test_slotless_entry_counts_calories_only(self) -> None: @@ -141,12 +141,12 @@ class TestVerification: def test_valid_entry_kept(self) -> None: """A correctly signed entry survives verification.""" - log_meal("toast", _nut(150), slot=8) + log_meal("toast", _nut(150), slot=480) assert today_entries() def test_tampered_entry_dropped(self) -> None: """An edited calorie value invalidates the signature and is dropped.""" - log_meal("toast", _nut(150), slot=8) + log_meal("toast", _nut(150), slot=480) raw = _raw() day = next(iter(raw)) raw[day][0]["kcal"] = 999 @@ -184,19 +184,19 @@ class TestIdAndComponents: def test_entry_has_id(self) -> None: """Every logged entry carries a UUID id.""" - entry = log_meal("toast", _nut(150), slot=8) + entry = log_meal("toast", _nut(150), slot=480) assert isinstance(entry["id"], str) assert entry["id"] def test_ids_are_unique(self) -> None: """Two entries never collide on id.""" - first = log_meal("a", _nut(1), slot=8) - second = log_meal("b", _nut(1), slot=12) + first = log_meal("a", _nut(1), slot=480) + second = log_meal("b", _nut(1), slot=720) assert first["id"] != second["id"] def test_components_omitted_by_default(self) -> None: """A single-food entry carries no components field.""" - entry = log_meal("toast", _nut(150), slot=8) + entry = log_meal("toast", _nut(150), slot=480) assert "components" not in entry def test_components_carried_through(self) -> None: @@ -211,7 +211,7 @@ class TestIdAndComponents: "grams": 100.0, } ] - entry = log_meal("dinner", _nut(165), slot=20, components=parts) + entry = log_meal("dinner", _nut(165), slot=1200, components=parts) assert entry["components"] == parts @@ -221,7 +221,7 @@ class TestFutureLogging: def test_buckets_under_the_future_date(self) -> None: """A future ``when`` places the entry under that date, not today.""" future = now_local() + timedelta(days=1) - entry = log_meal("future meal", _nut(200), slot=12, when=future) + entry = log_meal("future meal", _nut(200), slot=720, when=future) future_day = future.date().isoformat() assert today_total_kcal() == 0.0 assert load_log()[future_day][0]["desc"] == "future meal" @@ -231,5 +231,5 @@ class TestFutureLogging: """A ``when`` before now raises ValueError and writes nothing.""" past = now_local() - timedelta(days=1) with pytest.raises(ValueError, match="in the past"): - log_meal("late", _nut(50), slot=8, when=past) + log_meal("late", _nut(50), slot=480, when=past) assert load_log() == {} diff --git a/diet_guard/tests/test_state_sync.py b/diet_guard/tests/test_state_sync.py index f9d3349..4e2e023 100644 --- a/diet_guard/tests/test_state_sync.py +++ b/diet_guard/tests/test_state_sync.py @@ -52,8 +52,8 @@ class TestUndo: def test_undo_leaves_earlier_entries(self) -> None: """Undo tombstones only the last entry when others remain.""" - log_meal("a", _nut(100), slot=8) - log_meal("b", _nut(200), slot=12) + log_meal("a", _nut(100), slot=480) + log_meal("b", _nut(200), slot=720) removed = undo_last_today() assert removed is not None assert removed["desc"] == "b" @@ -61,7 +61,7 @@ class TestUndo: def test_undo_tombstones_in_place(self) -> None: """Undoing the only entry keeps it on disk, marked deleted.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() raw = _raw() day = next(iter(raw)) @@ -70,7 +70,7 @@ class TestUndo: def test_undo_tombstone_excluded_from_reads(self) -> None: """A tombstoned entry no longer counts toward totals or slots.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() assert today_total_kcal() == 0.0 assert today_entries() == [] @@ -78,7 +78,7 @@ class TestUndo: def test_undo_re_signs_the_tombstone(self) -> None: """The mutated (tombstoned) entry still carries a valid signature.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() raw = _raw() day = next(iter(raw)) @@ -86,7 +86,7 @@ class TestUndo: def test_undo_unsigned_when_no_key(self) -> None: """Re-signing a tombstone with no key available leaves it unsigned.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) with patch.object(_state_sync, "compute_entry_hmac", return_value=None): undo_last_today() raw = _raw() @@ -95,8 +95,8 @@ class TestUndo: def test_undo_skips_already_tombstoned(self) -> None: """Undoing twice tombstones the prior entry, not the same one again.""" - log_meal("a", _nut(100), slot=8) - log_meal("b", _nut(200), slot=12) + log_meal("a", _nut(100), slot=480) + log_meal("b", _nut(200), slot=720) undo_last_today() second = undo_last_today() assert second is not None @@ -104,7 +104,7 @@ class TestUndo: def test_undo_nothing_left_once_all_tombstoned(self) -> None: """Once every entry today is tombstoned, undo returns None.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() assert undo_last_today() is None @@ -114,7 +114,7 @@ class TestLoadLogSkipsTombstones: def test_day_with_only_a_tombstone_is_omitted(self) -> None: """A day whose sole entry is tombstoned is dropped entirely.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() assert load_log() == {} @@ -124,7 +124,7 @@ class TestRawLogAccess: def test_read_raw_log_includes_tombstones(self) -> None: """Unlike load_log, read_raw_log keeps a tombstoned entry.""" - log_meal("a", _nut(100), slot=8) + log_meal("a", _nut(100), slot=480) undo_last_today() raw = read_raw_log() day = next(iter(raw)) @@ -163,7 +163,7 @@ class TestResignEntry: def test_strips_and_recomputes_signature(self) -> None: """A re-signed entry's hmac changes but verifies against the key.""" - entry = log_meal("a", _nut(100), slot=8) + entry = log_meal("a", _nut(100), slot=480) tampered = dict(entry, kcal=999.0) resigned = resign_entry(tampered) assert resigned["hmac"] != entry["hmac"] @@ -171,9 +171,23 @@ class TestResignEntry: with patch.object(_state_today, "_today", return_value="2026-06-22"): assert today_entries() == [resigned] + def test_an_off_hour_slot_survives_a_resign_and_still_verifies(self) -> None: + """``slot_min`` is covered by the new signature, not dropped by it. + + The PC re-signs every merged entry; losing ``slot_min`` there would + quietly turn a phone-logged 07:15 meal back into a 07:00 one. + """ + entry = log_meal("a", _nut(100), slot=435) + assert (entry["slot"], entry["slot_min"]) == (7, 435) + resigned = resign_entry(entry) + assert resigned["slot_min"] == 435 + write_raw_log({"2026-06-22": [resigned]}) + with patch.object(_state_today, "_today", return_value="2026-06-22"): + assert today_entries() == [resigned] + def test_no_op_signature_wise_when_no_key_available(self) -> None: """Without an HMAC key, resign_entry produces no hmac field.""" - entry = log_meal("a", _nut(100), slot=8) + entry = log_meal("a", _nut(100), slot=480) with patch.object(_state_sync, "compute_entry_hmac", return_value=None): resigned = resign_entry(entry) assert "hmac" not in resigned diff --git a/diet_guard/tests/test_state_today.py b/diet_guard/tests/test_state_today.py index 6504292..8905b8b 100644 --- a/diet_guard/tests/test_state_today.py +++ b/diet_guard/tests/test_state_today.py @@ -14,6 +14,8 @@ import pytest from diet_guard import _state from diet_guard._budget import BudgetNotInitializedError, write_budget from diet_guard._estimator import Nutrition +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import record_schedule_change from diet_guard._state import ( log_meal, ) @@ -40,19 +42,36 @@ class TestLoggedSlots: """Which slots today's log has satisfied.""" def test_int_slots_counted(self) -> None: - """Integer slot tags are reported.""" - log_meal("a", _nut(1), slot=8) - log_meal("b", _nut(1), slot=12) - assert logged_slots_today() == {8, 12} + """Whole-hour slot minutes are reported as the slots they are.""" + log_meal("a", _nut(1), slot=480) + log_meal("b", _nut(1), slot=720) + assert logged_slots_today() == {480, 720} def test_bool_slot_excluded(self) -> None: """A bool masquerading as a slot is ignored.""" - log_meal("a", _nut(1), slot=8) + log_meal("a", _nut(1), slot=480) raw = _raw() day = next(iter(raw)) raw[day].append({"kcal": 1, "slot": True}) _state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8") - assert logged_slots_today() == {8} + assert logged_slots_today() == {480} + + def test_an_off_hour_slot_is_written_and_read_as_minutes(self) -> None: + """07:15 survives the round trip through disk and the HMAC check.""" + record_schedule_change(MealSchedule(435, 1140, 5)) + log_meal("a", _nut(1), slot=435) + assert logged_slots_today() == {435} + + def test_a_legacy_hour_entry_counts_toward_the_nearest_slot(self) -> None: + """An old ``{"slot": 7}`` entry still covers a 07:15 checkpoint.""" + record_schedule_change(MealSchedule(435, 1140, 5)) + log_meal("a", _nut(1), slot=420) + assert logged_slots_today() == {435} + + def test_an_explicit_schedule_is_snapped_onto(self) -> None: + """The caller's schedule wins over the stored one.""" + log_meal("a", _nut(1), slot=480) + assert logged_slots_today(MealSchedule(495, 1200, 4)) == {495} class TestBudgetViews: @@ -66,23 +85,23 @@ class TestBudgetViews: def test_remaining_value(self) -> None: """Remaining is budget minus today's total.""" write_budget(2000) - log_meal("lunch", _nut(500), slot=12) + log_meal("lunch", _nut(500), slot=720) assert remaining_budget() == 1500.0 def test_band_on_track(self) -> None: """Well under the warn fraction is 'on track'.""" write_budget(2000) - log_meal("a", _nut(500), slot=8) + log_meal("a", _nut(500), slot=480) assert consumption_band() == "on track" def test_band_approaching(self) -> None: """At or above the warn fraction but under budget is 'approaching limit'.""" write_budget(2000) - log_meal("a", _nut(1700), slot=8) + log_meal("a", _nut(1700), slot=480) assert consumption_band() == "approaching limit" def test_band_over(self) -> None: """At or above budget is 'OVER BUDGET'.""" write_budget(2000) - log_meal("a", _nut(2100), slot=8) + log_meal("a", _nut(2100), slot=480) assert consumption_band() == "OVER BUDGET" diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index 8d75d4f..efd6c50 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -45,7 +45,7 @@ class TestRunSync: def test_pushes_local_log_when_no_other_devices_have_synced(self) -> None: _write_token() - log_meal("oatmeal", _nutrition(), slot=8) + log_meal("oatmeal", _nutrition(), slot=480) client = _mock_client(devices=()) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): merged = _sync.run_sync() @@ -192,7 +192,7 @@ class TestRunSync: def test_rebuilds_the_food_bank_after_merge(self) -> None: _write_token() - log_meal("oatmeal", _nutrition(), slot=8) + log_meal("oatmeal", _nutrition(), slot=480) client = _mock_client(devices=()) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): _sync.run_sync() diff --git a/diet_guard/tests/test_sync_budget.py b/diet_guard/tests/test_sync_budget.py index e54658d..bad58fb 100644 --- a/diet_guard/tests/test_sync_budget.py +++ b/diet_guard/tests/test_sync_budget.py @@ -145,11 +145,11 @@ class TestSyncBudget: """ _write_token() write_budget(2000) - record_schedule_change(MealSchedule(8, 20, 5)) + record_schedule_change(MealSchedule(480, 1200, 5)) client = _mock_client(devices=()) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): _sync.run_sync() - assert load_schedule_entries()[-1].schedule == MealSchedule(8, 20, 5) + assert load_schedule_entries()[-1].schedule == MealSchedule(480, 1200, 5) def test_malformed_remote_budget_is_skipped(self) -> None: """A corrupt remote budget.json is skipped, not a crash.""" diff --git a/diet_guard/tests/test_sync_merge.py b/diet_guard/tests/test_sync_merge.py index be2426b..e07ab23 100644 --- a/diet_guard/tests/test_sync_merge.py +++ b/diet_guard/tests/test_sync_merge.py @@ -166,6 +166,20 @@ class TestEntryRecordRoundTrip: entry = _entry(id="x") assert record_to_entry(entry_to_record(entry)) == entry + def test_an_off_hour_slot_survives_a_merge(self) -> None: + """``slot_min`` rides in the opaque body, so a merge keeps it. + + Merged from both sides so neither argument order can drop it -- a + lost ``slot_min`` would silently move a 07:15 meal to 07:00. + """ + entry = _entry(id="x", slot=7, slot_min=435) + for merged in ( + _merge_daylogs({"2026-06-22": [entry]}, {}), + _merge_daylogs({}, {"2026-06-22": [entry]}), + ): + assert merged["2026-06-22"][0]["slot_min"] == 435 + assert merged["2026-06-22"][0]["slot"] == 7 + def test_round_trip_of_a_deleted_entry_preserves_the_tombstone(self) -> None: entry = _entry(id="x", deleted=True) assert record_to_entry(entry_to_record(entry))["deleted"] is True diff --git a/diet_guard/tests/test_sync_merge_schedule.py b/diet_guard/tests/test_sync_merge_schedule.py index 73f2c39..e07443b 100644 --- a/diet_guard/tests/test_sync_merge_schedule.py +++ b/diet_guard/tests/test_sync_merge_schedule.py @@ -42,7 +42,13 @@ class TestScheduleFields: def test_one_field_per_entry(self) -> None: """Each entry becomes its own ``sched:`` field.""" fields = schedule_fields( - (_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),) + ( + _entry( + "2026-08-16", + MealSchedule(480, 1200, 5), + "2026-08-16T10:00:00+02:00", + ), + ) ) assert set(fields) == {f"{SCHEDULE_FIELD_PREFIX}2026-08-16"} value, _ = fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"] @@ -51,7 +57,7 @@ class TestScheduleFields: def test_hlc_is_deterministic(self) -> None: """Re-syncing an unchanged history is a no-op, not a fresh write.""" entry = _entry( - "2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00" + "2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00" ) first = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] second = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] @@ -59,7 +65,7 @@ class TestScheduleFields: def test_unparsable_timestamp_falls_back_to_the_epoch(self) -> None: """A malformed stamp still yields a clock, so the entry still merges.""" - entry = _entry("2026-08-16", MealSchedule(8, 20, 5), "not-a-timestamp") + entry = _entry("2026-08-16", MealSchedule(480, 1200, 5), "not-a-timestamp") hlc = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] assert hlc.wall_time_ms == 0 @@ -74,12 +80,30 @@ class TestLogToScheduleHistory: def test_round_trips(self) -> None: """What one device pushes is what the other reads back.""" entries = ( - _entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"), + _entry( + "2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00" + ), ) back = log_to_schedule_history(budget_to_log(_RECORD, (), entries)) assert len(back) == 1 assert back[0].effective_from == "2026-08-16" - assert back[0].schedule == MealSchedule(8, 20, 5) + assert back[0].schedule == MealSchedule(480, 1200, 5) + + def test_round_trips_an_off_hour_schedule(self) -> None: + """07:15-19:00 crosses the wire with its minutes, ``f`` still an hour. + + An older peer reads ``f``/``l`` and relays ``fm`` untouched; a peer on + this build reads ``fm`` and derives the same slots as the editor. + """ + entries = ( + _entry( + "2026-08-16", MealSchedule(435, 1140, 5), "2026-08-16T10:00:00+02:00" + ), + ) + value, _ = schedule_fields(entries)[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"] + assert value == {"f": 7, "l": 19, "n": 5, "fm": 435} + back = log_to_schedule_history(budget_to_log(_RECORD, (), entries)) + assert back[0].schedule == MealSchedule(435, 1140, 5) def test_normalizes_a_peers_out_of_range_schedule(self) -> None: """A future peer cannot make this device derive different slots.""" @@ -90,7 +114,7 @@ class TestLogToScheduleHistory: {"f": 8, "l": 20, "n": 99}, hlc, ) - assert log_to_schedule_history(log)[0].schedule == MealSchedule(8, 20, 6) + assert log_to_schedule_history(log)[0].schedule == MealSchedule(480, 1200, 6) def test_skips_malformed_values(self) -> None: """One bad field from a peer cannot take out the whole history.""" @@ -118,7 +142,9 @@ class TestCrossDeviceMerge: """ entries = ( _entry("1970-01-01", DEFAULT_SCHEDULE, "1970-01-01T00:00:00+00:00"), - _entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"), + _entry( + "2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00" + ), ) ours = budget_to_log(_RECORD, (), entries) peer = budget_to_log( @@ -130,7 +156,7 @@ class TestCrossDeviceMerge: assert [entry.schedule for entry in back] == [ DEFAULT_SCHEDULE, - MealSchedule(8, 20, 5), + MealSchedule(480, 1200, 5), ] def test_the_newer_edit_wins_per_day(self) -> None: @@ -140,7 +166,9 @@ class TestCrossDeviceMerge: (), ( _entry( - "2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00" + "2026-08-16", + MealSchedule(480, 1200, 5), + "2026-08-16T10:00:00+02:00", ), ), ) @@ -149,10 +177,12 @@ class TestCrossDeviceMerge: (), ( _entry( - "2026-08-16", MealSchedule(7, 21, 3), "2026-08-16T18:00:00+02:00" + "2026-08-16", + MealSchedule(420, 1260, 3), + "2026-08-16T18:00:00+02:00", ), ), ) merged = merge_logs(parse_remote_budget(_wire(older)), newer) - assert log_to_schedule_history(merged)[0].schedule == MealSchedule(7, 21, 3) + assert log_to_schedule_history(merged)[0].schedule == MealSchedule(420, 1260, 3) diff --git a/docs/DOCS-meal-schedule.md b/docs/DOCS-meal-schedule.md index 0d62eba..a600fda 100644 --- a/docs/DOCS-meal-schedule.md +++ b/docs/DOCS-meal-schedule.md @@ -2,38 +2,77 @@ _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. +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. -Four rules, each load-bearing: +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. -- **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 +## 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 25 and `hour < cutoff` is vacuously - true — the window never closes. + 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:` 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 hours on one device only, which is the split brain that can lock the +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 @@ -44,8 +83,17 @@ them untouched. Editable on both surfaces: the app's Settings screen (`settings_meal_schedule.dart`) and the gate's History tab -(`_gatelock_scheduleedit.py`). +(`_gatelock_scheduleedit.py`, widgets in `_gatelock_calendar_schedule.py`). +The gate's first/last fields are `ttk.Combobox`es offering every quarter hour +and accepting any typed `HH:MM`; Up/Down step 15 minutes without posting the +dropdown, which gatelock's grab watch would close over the real lock. -**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. +`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. diff --git a/install.sh b/install.sh index 219ff4f..8b2f361 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ # site-packages (the systemd service runs /usr/bin/python directly, not # a venv, so the package must live where that interpreter can find it — # see CLAUDE.md's "Production dependency installation" section) -# 3. Installs + enables the systemd user timer that fires the gate every ~30m +# 3. Installs + enables the systemd user timer that fires the gate every ~15m # 4. Installs + enables the systemd user timer that syncs the log every ~15m # (the sync itself stays unconfigured -- and a no-op -- until you create # a sync token; see the reminder this step prints) @@ -57,7 +57,7 @@ cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service" cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer" systemctl --user daemon-reload systemctl --user enable --now diet-guard-gate.timer -echo " Timer enabled and started (fires the gate every ~30 min)." +echo " Timer enabled and started (fires the gate every ~15 min)." # 4. Retire the old periodic sync timer --------------------------------------- # Sync is event-driven now (before a lock, after a meal is logged -- see @@ -96,6 +96,6 @@ else fi echo "=== Installation complete ===" -echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal" -echo "once you have gone 5h without logging." +echo "The gate checks every ~15 min and locks until you log a meal once a meal" +echo "slot passes unlogged (08:00/12:00/16:00/20:00 by default; editable)." echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo"