"""The user's eating window and meal count, and the slot minutes they imply. A schedule is three numbers -- the first meal's minute of day, the last meal's minute of day, and how many meals fall between them inclusive -- from which the intermediate checkpoints are derived by even division and rounded to the 15-minute grid. ``MealSchedule(480, 1200, 5)`` (08:00-20:00, five meals) yields ``(480, 660, 840, 1020, 1200)``: 08:00, 11:00, 14:00, 17:00, 20:00. Every time here is a *minute of day*, an int in ``0..1439``. The fields are deliberately named ``first_minute``/``last_minute`` rather than reusing the old hour-valued ``first``/``last``: renaming them makes mypy enumerate every call site that still thinks in hours, instead of one quietly passing ``8`` and getting a 00:08 breakfast. This module is pure: no clock, no filesystem, no configuration lookup. Storage lives in :mod:`diet_guard._meal_schedule_store`, the slot arithmetic that consumes a schedule in :mod:`diet_guard._slots`, and a log entry's slot encoding in :mod:`diet_guard._slot_wire`. The schedule's own wire encoding (:func:`schedule_to_wire`/:func:`schedule_from_wire`) lives here. KEEP IN SYNC WITH ``app/lib/models/meal_schedule.dart``. The two must agree on every input, because a device that derives different slots than its peer nags for checkpoints the other never offers -- and a slot that can never be satisfied is a permanent lock. ``tests/fixtures/meal_schedule_vectors.json`` (regenerated by ``scripts/build_meal_schedule_fixture.py``) is read by both test suites; three rules make that agreement checkable: * **Integer arithmetic only.** No floats and no ``round()`` anywhere in the derivation. Python's ``round`` is banker's rounding (``round(2.5) == 2``) while Dart's is half-away-from-zero (``2.5.round() == 3``), so any float path is a latent cross-language split brain. ``//`` here, ``~/`` there. * **Non-negative operands to every division.** Python's ``//`` floors while Dart's ``~/`` truncates, so ``-7 // 15 == -1`` but ``-7 ~/ 15 == 0``. Every division below runs on a value :meth:`MealSchedule.normalized` has already pulled into the day, which is what keeps the two operators equivalent. * **Clamp, don't reject.** Every out-of-range input is normalised to the nearest legal schedule rather than raising, so the two languages cannot disagree about which inputs are errors. """ from __future__ import annotations from dataclasses import dataclass from typing import TypeGuard # A day's meals must fit inside a day, and the pills that render them have to # stay legible on a phone at the widest (all-logged) size -- see # ``app/test/widgets/slot_selector_row_test.dart``. MIN_MEAL_COUNT = 2 MAX_MEAL_COUNT = 6 MINUTES_PER_DAY = 1440 # Both the minimum spacing between two checkpoints and the grid the interior # checkpoints are rounded onto. One constant for both is what makes the # strict-ascent proof in ``slots()`` work: points at least one grid step apart # cannot round onto the same grid mark. SLOT_GRID_MINUTES = 15 # Grace period after the final checkpoint, before the gate stops firing for # the day. Deliberately a constant rather than the slot spacing: it is how # long you have to log a late dinner, which has nothing to do with how many # meals you eat. Tying it to the spacing would stretch the lockout window to # midnight at four meals, contradicting the "don't trap me overnight" intent # this cutoff exists to serve. ENFORCEMENT_TAIL_MINUTES = 120 _LAST_MINUTE = MINUTES_PER_DAY - 1 def _clamp(value: int, low: int, high: int) -> int: """Return ``value`` confined to ``[low, high]``.""" return max(low, min(high, value)) @dataclass(frozen=True) class MealSchedule: """An eating window and the number of meals inside it. Attributes: first_minute: Minute of day of the first meal, 0-1424. last_minute: Minute of day of the last meal, at least one grid step (15 minutes) after ``first_minute`` and at most 1439. count: Total meals including both endpoints. """ first_minute: int last_minute: int count: int def normalized(self) -> MealSchedule: """Return an equivalent schedule guaranteed to satisfy the invariants. Ordering matters: ``first_minute`` is clamped into the day leaving room for one grid step, then ``last_minute`` is clamped to at least one grid step after it, then ``count`` is clamped to how many grid-spaced checkpoints the window holds. That last clamp is the load-bearing one -- see :meth:`slots`. Returns: A schedule whose ``slots()`` is strictly ascending. """ first = _clamp(self.first_minute, 0, _LAST_MINUTE - SLOT_GRID_MINUTES) last = _clamp(self.last_minute, first + SLOT_GRID_MINUTES, _LAST_MINUTE) # A window of W minutes holds at most W//15 + 1 checkpoints spaced a # grid step apart; asking for more would round two onto one mark. capacity = (last - first) // SLOT_GRID_MINUTES + 1 count = _clamp(self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, capacity)) return MealSchedule(first, last, count) def slots(self) -> tuple[int, ...]: """Return the meal-slot minutes, ascending, endpoints exact. With ``span = last - first`` and ``d = count - 1``, interior slot *i* is ``raw = first + (i*span + d//2) // d`` (even division, the ``d//2`` term a round-half-up bias that keeps it float-free), then snapped to the nearest absolute quarter hour by ``(raw + 7) // 15 * 15``. The endpoints are ``first`` and ``last`` exactly -- never snapped -- so the eating window the user typed is always honoured, even at 07:23. Strictly ascending, provably: :meth:`normalized` caps ``count`` so that ``span / d >= 15``, hence consecutive ``raw`` values differ by at least 15, and two integers at least 15 apart never snap onto the same grid mark. Snapping moves a value by at most 7, so the first interior slot is at least 8 past ``first`` and the last at least 8 before ``last``. That matters because slot minutes are set members, dict keys *and* notification ids: a repeat silently drops a checkpoint. Returns: The slot minutes, ascending, of length ``normalized().count``. """ schedule = self.normalized() first, last = schedule.first_minute, schedule.last_minute span = last - first divisions = schedule.count - 1 interior = ( (first + (index * span + divisions // 2) // divisions + 7) // SLOT_GRID_MINUTES * SLOT_GRID_MINUTES for index in range(1, divisions) ) return (first, *interior, last) @property def enforcement_end_minute(self) -> int: """Return the minute at which slot enforcement stops for the day. Derived from the *normalised* last meal, so it always agrees with the last entry of :meth:`slots`. Clamped to the end of the day: a 23:00 last meal would otherwise put the cutoff at 1500, making ``minute < cutoff`` vacuously true so the enforcement window never closes and the gate can never stop firing. """ last = self.normalized().last_minute return min(last + ENFORCEMENT_TAIL_MINUTES, MINUTES_PER_DAY) # The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, with the # enforcement window closing at 22:00. Still what a device uses before the # user has ever chosen anything, so upgrading changes no behaviour. DEFAULT_SCHEDULE = MealSchedule(first_minute=8 * 60, last_minute=20 * 60, count=4) def is_wire_int(value: object) -> TypeGuard[int]: """Return True for a real int; ``bool`` is excluded, as Dart's ``is int`` is. Python's ``bool`` subclasses ``int``, so a bare ``isinstance`` would accept ``true`` from a peer's JSON that the Dart side rejects -- two devices disagreeing about which values are usable. """ return isinstance(value, int) and not isinstance(value, bool) def schedule_to_wire(schedule: MealSchedule) -> dict[str, int]: """Return the synced ``sched:`` value for ``schedule``. Backward compatible by construction: ``f``/``l`` carry whole hours exactly as before the move to minutes, and ``fm``/``lm`` appear only when an endpoint is off the hour. So a whole-hour schedule encodes byte-identically to the old ``{"f", "l", "n"}`` form. The schedule is normalised first, so every ``//`` runs on a non-negative value (see the module docstring). Returns: ``{"f", "l", "n"}`` plus ``"fm"``/``"lm"`` for off-hour endpoints. """ norm = schedule.normalized() first, last = norm.first_minute, norm.last_minute wire = {"f": first // 60, "l": last // 60, "n": norm.count} if first % 60: wire["fm"] = first if last % 60: wire["lm"] = last return wire def schedule_from_wire(raw: object) -> MealSchedule | None: """Return the normalised schedule a ``sched:`` value describes. ``fm``/``lm`` win when they are ints; otherwise ``f``/``l`` are hours. Never raises: anything without int ``f``, ``l`` and ``n`` yields None, so one bad field from a peer cannot take out the whole history. 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. Returns: The decoded, normalised schedule, or None when ``raw`` is unusable. """ if not isinstance(raw, dict): return None first, last, count = raw.get("f"), raw.get("l"), raw.get("n") if not (is_wire_int(first) and is_wire_int(last) and is_wire_int(count)): return None first_minute, last_minute = raw.get("fm"), raw.get("lm") return MealSchedule( first_minute if is_wire_int(first_minute) else first * 60, last_minute if is_wire_int(last_minute) else last * 60, count, ).normalized()