diet-guard/diet_guard/_kuchnia_spread.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

88 lines
3.7 KiB
Python

"""Assign catering dishes to meal slots, in the provider's own order.
The panel returns each dish with a ``mealPriority`` (1..N: Śniadanie, II
śniadanie, Obiad, Podwieczorek, Kolacja) -- the order the catering intends them
to be eaten in. That beats inferring an order from the dish names, and it
beats spreading them arithmetically, so this module sorts by priority and then
maps position onto the user's configured slots.
Counts rarely match: a 5-meal plan against the default 4 slots means two dishes
share the first slot. The mapping is ``i * S // N``, which is monotonic
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/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.
**The mirror now exists**: ``app/lib/services/kuchnia_spread.dart`` runs this
same mapping on the phone, which fetches the catering menu itself rather than
waiting for the PC to bank it. The parity risk stopped being theoretical the
day that landed, so the two implementations are gated by one shared fixture,
``tests/fixtures/kuchnia_day.json``, asserted by
``diet_guard/tests/test_kuchnia_parity.py`` *and*
``app/test/kuchnia_parity_test.dart`` -- same input, same expected slots.
One Dart-only hazard has no Python counterpart and is easy to reintroduce:
``List.sort`` is not stable while :func:`sorted` is, so the Dart comparator
carries the payload index as a final tiebreak. Without it two dishes sharing
both a priority and a name could order differently on the two devices.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slots import day_slots
if TYPE_CHECKING:
from collections.abc import Sequence
from diet_guard._kuchnia_parse import Dish
class SlottedDish(NamedTuple):
"""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 minute, following the provider's meal order.
Args:
dishes: The day's dishes, in any order (sorted here by ``priority``).
slots: The configured slot minutes, ascending, e.g.
``(480, 720, 960, 1200)``.
Returns:
One :class:`SlottedDish` per dish, ordered by the provider's priority.
Empty when either input is empty -- a day with no delivery and a
schedule with no slots are both "nothing to assign", not errors.
"""
if not dishes or not slots:
return []
# Ties (two dishes sharing a priority) fall back to name so the result is
# deterministic; a bank import that reshuffles between runs would look like
# a change and re-stamp every record.
ordered = sorted(dishes, key=lambda dish: (dish.priority, dish.name))
count = len(ordered)
span = len(slots)
return [
SlottedDish(dish=dish, slot=slots[index * span // count])
for index, dish in enumerate(ordered)
]
def dishes_in_slot_order(dishes: tuple[Dish, ...]) -> tuple[Dish, ...]:
"""Return ``dishes`` ordered by the provider's own meal priority.
So the dish offered for the slot being filled is the one the catering
actually intends for it, rather than whatever order the payload arrived in.
"""
slotted = assign_slots(dishes, day_slots(current_schedule()))
return tuple(item.dish for item in slotted)