mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
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>
194 lines
7.1 KiB
Python
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}")
|