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

264 lines
11 KiB
Python

"""Fullscreen "log your meals to unlock" gate window for diet_guard.
The fullscreen/grab/VT-disable/lifecycle mechanics -- an ``overrideredirect``
window with a global input grab and disabled VT switching, hardened so a
grabbed window can never become a trap (VT switching restored on every exit
path, every callback error swallowed and surfaced) -- now live in the shared
``gatelock`` package, also used by wake_alarm and screen-locker. ``MealGate``
owns a :class:`~gatelock.LockWindow` and implements
:class:`~gatelock.LockWindowHooks`.
The window walks the user through each *missing* meal slot in turn (coming home
at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every
elapsed slot carries a logged meal.
Resolution is built around one idea: the macro fields plus the "per" field hold
the food's nutrition *as a reference for some amount*, and how much you ate
scales that reference into the total that is logged. Measure by **grams** and
the reference is "per 100 g" off a label; measure by **items** and it is "per 1
item" (with the piece's approximate weight, which you can correct). Either way
the total shown in the preview is exactly what gets recorded, and changing how
much you ate never rewrites the reference fields, so the two cannot desync. As
you type, the picker offers your banked foods and built-in staples, so a common
food fills in one click. Leaving the calorie field blank looks the food up
(food bank, then staples, then Open Food Facts), fills the fields, names the
source, and offers alternatives. A running dashboard makes the day's calories
prominent, with macros and the protein target beneath. The unlock condition is
*logging*, never *estimating correctly*: a manual calorie value always works
offline, so a dead OFF endpoint can never trap you behind the lock.
Building ``MealGate`` spans several sibling modules to keep each under the
repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf
widget/field helpers and state (``_GateCore``, ``_GateState``);
:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and
food lookup (``_GateNutrition``); :mod:`._gatelock_mealflow` provides the
submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``);
and :mod:`._gatelock_calendar` provides the second ``ttk.Notebook`` tab --
the budget-adherence calendar, streaks, and YTD tally from
:mod:`._daystatus`, plus budget editing (``_GateCalendar``). ``MealGate``
wires these mixins together, owns the ``gatelock.LockWindow``, and handles
construction, layout, and event binding.
"""
from __future__ import annotations
import contextlib
import fcntl
import sys
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import (
RANK_DIET_GUARD,
Arbiter,
GateRoot,
LockConfig,
LockWindow,
SurfaceInfo,
)
from diet_guard._constants import GATE_LOCK_FILE
from diet_guard._gate import due_slots
from diet_guard._gatelock_calendar import _GateCalendar, make_calendar_vars
from diet_guard._gatelock_core import _GateState
from diet_guard._gatelock_groups import GateWidgetsGroup
from diet_guard._gatelock_ui import (
GateCallbacks,
make_vars,
)
from diet_guard._slots import current_slot, day_slots
from diet_guard._state import now_local
if TYPE_CHECKING:
from typing import TextIO
def _assert_not_under_pytest() -> None:
"""Raise if a real Tk gate is being built inside a pytest run.
Defence-in-depth: prevents a real fullscreen window from locking the screen
when a test forgets to mock ``tk.Tk``. When ``tk`` is mocked the module
name is no longer ``tkinter``, so genuine mocked tests pass straight through.
"""
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
msg = "SAFETY: MealGate built under pytest with real tkinter (tk.Tk unmocked)"
raise RuntimeError(msg)
def acquire_gate_lock() -> TextIO | None:
"""Acquire the gate's single-instance ``flock``.
Returns:
An open file handle that must be kept alive for the gate's lifetime
(closing it releases the lock), or None if another gate already holds
it -- in which case the caller must not open a second window.
"""
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def release_gate_lock(handle: TextIO) -> None:
"""Release the single-instance lock and close its handle."""
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
def _pending_slots(*, demo_mode: bool) -> list[int]:
"""Return the slots the window must collect before it can unlock.
In production this is exactly the elapsed-but-unlogged slots. In demo mode
-- where there may be nothing genuinely due -- fall back to a representative
slot so the UI is always demonstrable.
Args:
demo_mode: Whether the window is a safe sandbox.
Returns:
The slot hours to collect, ascending.
"""
pending = list(due_slots())
if pending:
return pending
if demo_mode:
return [current_slot(now_local()) or day_slots()[0]]
return []
class MealGate(_GateCalendar):
"""A fullscreen lock that dismisses only once every missing slot is logged."""
def __init__(self, *, demo_mode: bool = True) -> None:
"""Build the lock window.
Args:
demo_mode: When True, use a local (not global) input grab and add a
close button, so the gate can be exercised without locking the
real session. Production passes False.
"""
_assert_not_under_pytest()
self.demo_mode = demo_mode
self._pending = _pending_slots(demo_mode=demo_mode)
# All mutable logical state (provenance, suggestions, meal-in-progress)
# lives in one bundle; see _GateState for the per-field rationale.
self._state = _GateState()
self.root = GateRoot()
self.root.on_callback_error = self.on_callback_error
self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else ""))
# No bg=/fg=/... overrides here: LockConfig's own defaults already
# are the unified-design-system palette, and _gatelock_ui reads the
# same LockConfig() rather than a separate hardcoded copy -- see
# DESIGN_AUDIT_TODO.md for why an override here previously drifted.
config = LockConfig(
mode="soft" if demo_mode else "hard",
app_name="diet_guard",
rank=RANK_DIET_GUARD,
)
# Published here, then owned by LockWindow, which releases it on
# close -- so deliberately not kept as an attribute.
arbiter = Arbiter(
"diet_guard",
RANK_DIET_GUARD,
grab=config.resolved_grab(),
disable_vt=config.resolved_disable_vt(),
)
arbiter.publish()
arbiter.acquire_holder()
self._lock = LockWindow(self.root, config, hooks=self, arbiter=arbiter)
self._vars = make_vars(self.root)
# Built before setup(), which calls build_surface per live output.
self._cal_vars = make_calendar_vars(self.root)
self._cal_surfaces = []
self._widgets = GateWidgetsGroup(self._vars)
self._callbacks = GateCallbacks(
on_unit_change=self._on_unit_change,
on_submit=self._on_submit,
on_close=self.close,
on_fetch_sync=self._on_fetch_sync,
)
self._build()
def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None:
"""Lay the whole gate out on one monitor.
Called once per live output, and again whenever an output comes back.
Every copy shares one set of GateVars, so a value typed on any screen
is the same value on all of them and logging works from whichever
monitor the user is actually looking at.
"""
widgets = self._build_tabs(parent, self._callbacks)
self._widgets.add(widgets, surface.output_name)
# An output coming back mid-lock gets its bindings too; tk's bind
# replaces rather than stacks, so re-wiring the others is a no-op.
self._wire_events()
def teardown_surface(self, surface: SurfaceInfo) -> None:
"""Forget the copy for an output that went dark."""
self._widgets.discard(surface.output_name)
self._cal_surfaces = self._cal_surfaces[: len(self._widgets.bundles)]
def _build(self) -> None:
"""Lay out the UI, wire events, seed the first prompt, and grab input."""
self._lock.setup()
self._wire_events()
self._relabel_basis()
self._refresh_slot_header()
self._refresh_dashboard()
self._refresh_projection()
self._refresh_calendar()
self._lock.grab_input()
self._widgets.desc_text.focus_set()
def on_focus_ready(self, surface: SurfaceInfo | None) -> None:
"""Put keyboard focus on the description entry once it is mapped.
``surface`` is None when no output is live -- the gate is held with
nothing to show, so there is nothing to focus.
"""
if surface is None:
return
self._widgets.desc_text.focus_force()
def on_close(self) -> None:
"""No hardware/state to release; meal-log writes already happened."""
def close(self) -> None:
"""Restore VT switching and destroy the window (no process exit)."""
self._lock.close()
def run(self) -> None:
"""Run the Tk loop, restoring VT switching on every exit path."""
self._lock.run()
def _wire_events(self) -> None:
"""Bind the live per-keystroke events to the freshly built widgets.
Construction-time commands (button and option-menu) are wired inside
``build_layout``; the key bindings that drive lookup, scaling, and
submission are connected here, where the controller methods are in scope.
"""
widgets = self._widgets
widgets.desc_text.bind("<KeyRelease>", self._on_desc_keyrelease)
widgets.desc_text.bind("<Return>", self._on_desc_return)
widgets.suggestion_box.bind(
"<<ListboxSelect>>",
self._on_suggestion_select,
)
for entry in (widgets.amount_entry, widgets.per_entry):
entry.bind("<KeyRelease>", self._on_amount_change)
entry.bind("<Return>", self._on_return)
for entry in self._macro_entries():
entry.bind("<Return>", self._on_return)
entry.bind("<KeyRelease>", self._on_macro_edit)
def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str:
"""Submit on Enter in the description box, suppressing the newline."""
self._on_submit()
return "break"