diet-guard/diet_guard/_kuchnia_log.py
Krzysztof kuhy Rudnicki 6dd52a11ae slots: never post the schedule combobox list; kuchnia dedup on raw minute
- The popdown maps beneath the gatelock surface yet holds ttk's grab
  (verified on Xvfb: invisible, swallows the next click, steals keys), so
  arrow clicks are intercepted as Down already was. Typing and Up/Down stay.
- Catering dedup compares the entry's recorded minute (parity with the
  app); occupancy (fill_plan) still snaps.
- Test: a corrupt peer slot (slot 24, slot_min 5000) re-signs and reads
  without raising.

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

110 lines
4.2 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 entry_slot_minute, 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
#: 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) -> bool:
"""Return True when today's log already holds this dish at this slot minute.
Compares the entry's *recorded* minute (:func:`entry_slot_minute`), not its
snapped slot -- the app's catering fill uses the same rule, which is the
point: both devices must agree on which dishes are duplicates, or one
re-logs what the other skipped. Snapping still guards the fill flow,
because :func:`fill_plan` skips any slot an entry snaps to (occupancy).
"""
wanted = name.strip().casefold()
return any(
str(entry.get("desc", "")).strip().casefold() == wanted
and entry_slot_minute(entry) == slot
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()
written: list[str] = []
for item in chosen:
dish = item.dish
if _already_logged(entries, dish.name, item.slot):
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