diet-guard/diet_guard/_gatelock_core.py
Krzysztof kuhy Rudnicki f7f06aee53
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Remove unused features; fix reminder false alarms; share all state
Four asks, plus the fixes two review passes turned up.

Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
  was the sole trigger for. Replaced with a post-log progress card showing
  kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
  `components` field stays: nothing writes it now, but historical composite
  entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
  wrapper's /blobs/ route, image_picker, and the CAMERA permission.

Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
  15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
  until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
  check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
  slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
  across Python and Dart. Deliberately separate from elapsed_slots, which
  would otherwise make every slot fall due at 23:00.

Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
  the budget in force then. Verified live: setting 2000 left a 36-day
  adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
  merges per-field over the union of field names and both devices push the
  merged log, so a device that predates the feature relays them untouched --
  no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
  empty history after merging with a pre-feature peer, and a presence check
  would then never seed again, silently reclassifying every past day.

One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
  was collateral damage of whole-map LWW -- any phone budget edit deleted it,
  and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
  eating them) was the last device-local state. The derived half merges with
  count as the clock, so LWW means max-count-wins; the log stays
  authoritative for membership, because a CRDT union never shrinks and a
  peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
  a cast failure there is an Error, not an Exception, so one malformed
  remote record would have broken phone sync permanently.

_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:39:52 +02:00

194 lines
7.1 KiB
Python

"""Shared base class and state for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateCore`` holds the leaf widget/field helpers that every other
gatelock mixin (`_gatelock_nutrition`, `_gatelock_mealflow`) derives from,
plus the small dataclass (`_GateState`) that :mod:`._gatelock` itself depends
on. The window/lock mechanics and the ``GateRoot`` Tk root subclass that used
to live here now come from the shared ``gatelock`` package.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from diet_guard._gatelock_ui import (
BASIS_PREFIX_GRAMS,
BASIS_PREFIX_ITEMS,
DEFAULT_PER_GRAMS,
UNIT_ITEMS,
GateVars,
GateWidgets,
)
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from diet_guard._slots import slot_label
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock import GateRoot
from diet_guard._estimator import Nutrition
_logger = logging.getLogger(__name__)
def _safe_float(raw: str) -> float | None:
"""Return ``raw`` parsed as a float, or None if it is blank/non-numeric."""
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
@dataclass
class _GateState:
"""Mutable logical state of the in-progress entry (no widget references).
``source`` is the provenance of the values in the reference fields
("manual", "food bank", "staple: apple", ...). It is a label only -- the
maths read the fields directly -- so there is no second copy of the numbers
to desync; it resets to "manual" the moment a macro is hand-edited.
``suggestions`` pairs each listed pick with its nutrition, and
``suggestion_mode`` says whether picking one overwrites the description
(bank entries are the user's own names) or only fills macros (OFF products).
``last_reference`` is the natural-basis nutrition of the food last picked
or looked up, kept so a grams<->items toggle can re-express it losslessly;
it is cleared the moment a macro is hand-edited.
"""
source: str = "manual"
suggestions: list[tuple[str, Nutrition]] = field(default_factory=list)
suggestion_mode: str = "bank"
last_reference: Nutrition | None = None
class _GateCore:
"""Leaf widget/field helpers shared by every MealGate mixin.
Declares the attributes that
:class:`~diet_guard._gatelock.MealGate` sets up in ``__init__``
and ``_build`` so subclasses can reference them without tripping pylint's
no-member check.
"""
root: GateRoot
demo_mode: bool
_pending: list[int]
_state: _GateState
_vars: GateVars
_widgets: GateWidgets
close: Callable[[], None]
# -- description field ---------------------------------------------------
def _get_desc(self) -> str:
"""Return the description text, trimmed (a Text always trails a newline)."""
return self._widgets.desc_text.get("1.0", "end-1c").strip()
def _set_desc(self, value: str) -> None:
"""Replace the description box's contents with ``value``."""
self._widgets.desc_text.delete("1.0", tk.END)
if value:
self._widgets.desc_text.insert("1.0", value)
def _macro_entries(self) -> tuple[tk.Entry, ...]:
"""Return the four numeric entry widgets in (kcal, P, C, F) order."""
macros = self._widgets.macros
return (macros.kcal, macros.protein, macros.carbs, macros.fat)
# -- slot walk --------------------------------------------------------------
def _refresh_slot_header(self) -> None:
"""Update the header to prompt for the slot now being collected."""
total = len(self._pending)
if total == 0:
self._vars.slot_header.set("All meals logged.")
return
slot = self._pending[0]
position = "" if total == 1 else f" (1 of {total} remaining)"
self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}")
def _reset_per_default(self) -> None:
"""Set the "per" field to the basis default for the current unit."""
self._widgets.per_entry.delete(0, tk.END)
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(self._get_desc())
self._widgets.per_entry.insert(
0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}"
)
else:
self._widgets.per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}")
def _relabel_basis(self) -> None:
"""Point the per-basis label at grams or per-item for the current unit."""
items = self._vars.unit.get() == UNIT_ITEMS
self._widgets.basis_prefix.config(
text=BASIS_PREFIX_ITEMS if items else BASIS_PREFIX_GRAMS,
)
# -- field helpers ------------------------------------------------------
def _basis_grams(self) -> float:
"""Return the grams the label macros describe (per 100 g or per item).
Honours an explicit "per" value when the user has typed one; otherwise
falls back to one piece's weight in items mode, or 100 g in grams mode.
"""
typed = _safe_float(self._widgets.per_entry.get().strip())
if typed is not None and typed > 0:
return typed
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(self._get_desc())
return grams if grams is not None else DEFAULT_ITEM_GRAMS
return DEFAULT_PER_GRAMS
def _eaten_grams(self) -> float | None:
"""Return how many grams were eaten, or None if no amount is entered.
In grams mode the amount *is* the grams; in items mode it is multiplied
by one piece's weight (the "per" field), so "5 apples" becomes a weight.
"""
amount = _safe_float(self._widgets.amount_entry.get().strip())
if amount is None:
return None
if self._vars.unit.get() == UNIT_ITEMS:
return amount * self._basis_grams()
return amount
def _macro_values(self) -> tuple[float | None, ...] | None:
"""Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric."""
values: list[float | None] = []
for entry in self._macro_entries():
raw = entry.get().strip()
parsed = _safe_float(raw)
if raw and parsed is None:
return None
values.append(parsed)
return tuple(values)
def _set_entry(self, entry: tk.Entry, value: str) -> None:
"""Replace an entry's contents with ``value``."""
entry.delete(0, tk.END)
entry.insert(0, value)
def _fill_macro_fields(self, nutrition: Nutrition) -> None:
"""Write a nutrition's macros into the kcal/P/C/F fields."""
pairs = zip(
self._macro_entries(),
(
nutrition.kcal,
nutrition.protein_g,
nutrition.carbs_g,
nutrition.fat_g,
),
strict=True,
)
for entry, value in pairs:
self._set_entry(entry, f"{value:g}")