mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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
137 lines
4.9 KiB
Python
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
|