diet-guard/diet_guard/_slot_wire.py
Krzysztof kuhy Rudnicki c310861855 slots: minute-of-day consumers (phase 2 of 3: Python side)
Every Python consumer now speaks slot minutes:

- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
  at every "is this slot logged?" site: gate, CLI status, MCP, catering
  occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
  keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
  whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
  legacy int hour, validated against today's slots; get_slots returns
  {minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
  list, typed times, Up/Down step without posting the popdown), locked
  as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:53:39 +02:00

137 lines
4.9 KiB
Python

"""How a log entry records its meal slot on disk and on the sync wire.
Slots are minutes of day (see :mod:`diet_guard._slots`), but every entry ever
written before that change carries an hour in ``"slot"``, and a peer running an
older build reads only that field. So the wire rule is additive:
* **Writers** always write ``"slot" = minute // 60`` and add
``"slot_min" = minute`` *only* when ``minute % 60 != 0``. A whole-hour slot
therefore encodes byte-identically to the pre-minute format -- no re-signing
storm, no diff on every existing entry -- and an older peer reading an
off-hour entry sees the hour it starts in. :func:`slot_fields` is that rule.
* **Readers** prefer an int ``"slot_min"``, fall back to ``"slot" * 60``, and
otherwise report no slot. :func:`entry_slot_minute` is that rule.
``bool`` is rejected wherever an int is expected: Python's ``bool`` subclasses
``int`` but Dart's does not, and a value one device accepts while the other
ignores is the cross-device split brain the shared fixture exists to catch.
KEEP IN SYNC WITH ``app/lib/models/slot.dart`` (``slotFields``,
``entrySlotMinute``); ``tests/fixtures/meal_schedule_vectors.json`` gates both.
"""
from __future__ import annotations
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 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.
_HHMM = re.compile(r"([0-9]{1,2})(?::([0-9]{2}))?")
_HOURS_PER_DAY = 24
_MINUTES_PER_HOUR = 60
def slot_fields(minute: int) -> dict[str, int]:
"""Return the entry fields that record ``minute`` as its slot.
Args:
minute: The slot's minute of day, ``0..1439``.
Returns:
``{"slot": hour}``, plus ``"slot_min"`` when ``minute`` is off the hour.
Raises:
ValueError: ``minute`` is outside the day -- a writer bug, refused
rather than encoded, since ``//`` on a negative value would floor
here and truncate in Dart.
"""
if not 0 <= minute < MINUTES_PER_DAY:
msg = f"slot minute {minute} is outside 0..{MINUTES_PER_DAY - 1}"
raise ValueError(msg)
fields = {"slot": minute // 60}
if minute % 60:
fields["slot_min"] = minute
return fields
def entry_slot_minute(entry: Mapping[str, object]) -> int | None:
"""Return the slot minute an entry records, or None when it has none.
Args:
entry: One decoded log entry.
Returns:
``entry["slot_min"]`` if it is an int, else ``entry["slot"] * 60`` if
that is an int, else None. Values are not range-checked: an entry is
reported as written, and :func:`diet_guard._slots.nearest_slot` maps
anything onto a real slot.
"""
slot_min = entry.get("slot_min")
if is_wire_int(slot_min):
return slot_min
slot = entry.get("slot")
if is_wire_int(slot):
return slot * 60
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.
Accepts ``H``, ``HH``, ``H:MM`` and ``HH:MM`` with surrounding whitespace,
e.g. ``"7"`` -> 420, ``"07:15"`` -> 435.
Args:
text: The time as typed.
Returns:
The minute of day, ``0..1439``.
Raises:
ValueError: ``text`` is not in one of those forms, or the hour is not
0-23 or the minutes not 0-59.
"""
match = _HHMM.fullmatch(text.strip())
if match is None:
msg = f"invalid time {text!r}, expected HH:MM (e.g. 07:15) or HH"
raise ValueError(msg)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
if hour >= _HOURS_PER_DAY or minute >= _MINUTES_PER_HOUR:
msg = f"invalid time {text!r}: hour must be 0-23 and minutes 0-59"
raise ValueError(msg)
return hour * 60 + minute