mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +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
116 lines
4.3 KiB
Python
116 lines
4.3 KiB
Python
"""Turn confirmed catering dishes into log entries.
|
|
|
|
Nothing here runs without an explicit confirmation from the user -- that is the
|
|
whole design of this feature. A delivered meal is not an eaten meal: if the
|
|
importer wrote entries unattended, the gate would auto-satisfy the exact
|
|
checkpoint it exists to enforce, and the log would record what the courier
|
|
dropped off rather than what was eaten.
|
|
|
|
Duplicate suppression works off **today's log**, not a local "already
|
|
imported" marker. ``log_meal`` mints a fresh uuid per entry, so a re-import
|
|
after a lost marker would merge as duplicates on every peer; comparing against
|
|
the synced log instead is robust to marker loss *and* correct across devices --
|
|
if the phone already logged the dish, the PC sees it and skips.
|
|
"""
|
|
|
|
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
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
from collections.abc import Sequence
|
|
|
|
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,
|
|
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 slot in satisfied_by_entries((entry,), schedule)
|
|
for entry in entries
|
|
)
|
|
|
|
|
|
def fill_plan(slotted: Sequence[SlottedDish]) -> list[SlottedDish]:
|
|
"""Keep only the dishes whose slot has nothing logged in it today.
|
|
|
|
"Fill all with catering" fills *empty* slots: a slot the user already
|
|
logged something else into keeps that meal rather than gaining a second
|
|
one. Occupancy is read once, before the batch, so two dishes the spread
|
|
put in the same empty slot (5 dishes on 4 slots) both land.
|
|
|
|
Args:
|
|
slotted: Today's dishes, already paired with slots.
|
|
|
|
Returns:
|
|
The dishes to log, in the order given.
|
|
"""
|
|
taken = satisfied_by_entries(today_entries(), current_schedule())
|
|
return [item for item in slotted if item.slot not in taken]
|
|
|
|
|
|
def dish_nutrition(dish: Dish) -> Nutrition:
|
|
"""Return a dish as the whole-portion :class:`Nutrition` it gets logged as.
|
|
|
|
``grams`` is the dish's own portion, which matters to the gate: its form
|
|
scales the label macros from the "per" basis to the amount eaten, and a
|
|
dish whose basis was left at the 100 g default logged 3.5x too much.
|
|
"""
|
|
return Nutrition(
|
|
kcal=dish.kcal,
|
|
protein_g=dish.protein_g,
|
|
carbs_g=dish.carbs_g,
|
|
fat_g=dish.fat_g,
|
|
grams=dish.grams,
|
|
source=SOURCE,
|
|
)
|
|
|
|
|
|
def log_dishes(chosen: Sequence[SlottedDish]) -> list[str]:
|
|
"""Log each chosen dish, skipping any already logged today in that slot.
|
|
|
|
Args:
|
|
chosen: The dishes the user confirmed, already paired with slots.
|
|
|
|
Returns:
|
|
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, 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_fields(item.slot)}]
|
|
written.append(dish.name)
|
|
return written
|