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

281 lines
11 KiB
Python

"""History tab: budget-adherence calendar, streaks, YTD tally, budget edit.
Split out of :mod:`._gatelock` (and kept out of :mod:`._gatelock_ui`) to keep
every gate module under the repo's 500-line limit. ``_GateCalendar`` extends
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` with a second
``ttk.Notebook`` tab: the calendar/streak/tally view built from
:mod:`diet_guard._calendar_view`'s pure grid math, plus a budget-edit field
writing through :func:`diet_guard._budget.write_budget` -- the one place
either device edits the now freely-editable, synced daily budget.
The month-grid math itself lives in :mod:`diet_guard._calendar_view` (no Tk
dependency), so the boundary cases (varying month length/first weekday,
every :class:`~diet_guard._daystatus.DayStatus`, future days, no budget set)
are unit-tested directly without a fake Tk in the loop.
"""
from __future__ import annotations
import calendar
import contextlib
import tkinter as tk
from tkinter import ttk
from diet_guard._budget import (
BudgetError,
BudgetFileCorruptError,
BudgetNotInitializedError,
budget_weight,
current_schedule,
daily_budget,
write_budget,
)
from diet_guard._calendar_view import (
CalendarCell,
build_month_cells,
cell_style,
streaks_text,
ytd_text,
)
from diet_guard._daystatus import DayStatus, status_map
from diet_guard._gatelock_calendar_ui import (
_DECEMBER,
_DEFAULT_BUDGET_KCAL,
_JANUARY,
_MONTH_AFTER_DECEMBER,
CalendarCallbacks,
CalendarVars,
CalendarWidgets,
_style_notebook,
build_calendar_frame,
make_calendar_vars,
)
from diet_guard._gatelock_mealflow import _GateMealFlow
from diet_guard._gatelock_ui import (
ERR,
FG,
GateCallbacks,
GateWidgets,
build_layout,
)
from diet_guard._state import load_log, now_local
__all__ = [
"CalendarCallbacks",
"CalendarVars",
"CalendarWidgets",
"_GateCalendar",
"build_calendar_frame",
"make_calendar_vars",
]
class _GateCalendar(_GateMealFlow):
"""History tab: calendar, streaks, YTD tally, and budget editing."""
_cal_vars: CalendarVars
_cal_widgets: CalendarWidgets
_cal_surfaces: list[CalendarWidgets]
_cal_year: int
_cal_month: int
_cal_editing_budget: bool
_notebook: ttk.Notebook
def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets:
"""Build one monitor's notebook, wire both tabs, return its meal widgets.
Called once per live output since gatelock v0.2.0. Every copy shares
one set of ``GateVars``, so the meal form stays in step across
monitors; the calendar tab is read-only apart from the budget field,
which is likewise variable-backed.
The History tab's own widgets are stashed per surface so the refresh
pass can repaint every monitor's grid.
"""
today = now_local().date()
self._cal_year = today.year
self._cal_month = today.month
self._cal_editing_budget = False
_style_notebook(parent)
notebook = ttk.Notebook(parent)
notebook.place(relx=0, rely=0, relwidth=1, relheight=1)
self._notebook = notebook
widgets = build_layout(
notebook, self._vars, callbacks, demo_mode=self.demo_mode
)
notebook.add(widgets.frame, text="Log Meal")
cal_callbacks = CalendarCallbacks(
on_prev_month=self._on_prev_month,
on_next_month=self._on_next_month,
on_edit_or_save_budget=self._on_edit_or_save_budget,
)
cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks)
notebook.add(cal_widgets.frame, text="History")
self._cal_surfaces.append(cal_widgets)
self._cal_widgets = cal_widgets
return widgets
# -- refresh --------------------------------------------------------------
def _refresh_calendar(self) -> None:
"""Recompute the calendar grid, streaks, YTD tally, and budget field.
Each past day is judged against the budget that applied *then*
(:mod:`diet_guard._budget_history`), so changing the budget today
leaves the grid, the streaks, and the tally for earlier days alone.
A budget that was never set defaults to
:data:`_DEFAULT_BUDGET_KCAL`, matching the phone app, so the tab is
meaningful from a fresh install with no setup ritual. A genuinely
corrupt budget file is a real problem, not just "unset" -- that
still degrades to a neutral grid and an error message, instead of
raising through the tab and crashing (or failing open) the lock.
Degradation is deliberately asymmetric: an unreadable *history*
silently falls back to today's scalar budget (the pre-history
behaviour), because that is a display nicety, whereas an unreadable
budget is the number itself.
"""
log = load_log()
try:
budget: int | None = daily_budget()
except BudgetNotInitializedError:
budget = _DEFAULT_BUDGET_KCAL
except BudgetFileCorruptError:
budget = None
status_map_ = (
status_map(log, schedule=current_schedule(default=budget))
if budget is not None
else None
)
self._render_month(status_map_)
if status_map_ is None:
self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.")
self._cal_vars.ytd.set("")
else:
self._cal_vars.streaks.set(streaks_text(status_map_))
self._cal_vars.ytd.set(ytd_text(status_map_))
if not self._cal_editing_budget:
self._refresh_budget_field(budget)
def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None:
"""Redraw the day-cell grid and month label for the displayed month."""
weeks = build_month_cells(self._cal_year, self._cal_month, status_map_)
self._cal_vars.month_label.set(
f"{calendar.month_name[self._cal_month]} {self._cal_year}",
)
for surface in self._cal_surfaces:
for index, cell_widget in enumerate(surface.day_cells):
row, col = divmod(index, 7)
spec = weeks[row][col] if row < len(weeks) else CalendarCell(None, None)
bg, fg, outline = cell_style(spec.status)
with contextlib.suppress(tk.TclError):
cell_widget.config(
text=str(spec.day) if spec.day else "",
bg=bg,
fg=fg,
highlightbackground=outline,
)
def _refresh_budget_field(self, budget: int | None) -> None:
"""Show ``budget`` in the (read-only) entry, or leave it blank if unset.
The text goes through the shared variable rather than the widget,
which sidesteps the old dance of flipping to "normal" to mutate it:
a ``readonly`` Entry rejects ``.insert``/``.delete`` but still
follows its ``textvariable``.
"""
self._cal_vars.budget.set("" if budget is None else str(budget))
self._set_budget_entry_state("readonly")
# -- month navigation -------------------------------------------------------
def _on_prev_month(self) -> None:
"""Step the displayed month back one, wrapping the year at January."""
self._cal_month -= 1
if self._cal_month < _JANUARY:
self._cal_month = _DECEMBER
self._cal_year -= 1
self._refresh_calendar()
def _on_next_month(self) -> None:
"""Step the displayed month forward one, wrapping the year at December."""
self._cal_month += 1
if self._cal_month == _MONTH_AFTER_DECEMBER:
self._cal_month = _JANUARY
self._cal_year += 1
self._refresh_calendar()
# -- budget editing -----------------------------------------------------
def _set_budget_entry_state(self, state: str) -> None:
"""Lock or unlock the budget entry on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_entry.config(state=state)
def _set_budget_button_text(self, text: str) -> None:
"""Relabel the budget edit/save button on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_edit_button.config(text=text)
def _set_budget_status(self, text: str, *, error: bool) -> None:
"""Update the budget-edit status line, red for errors."""
self._cal_vars.budget_status.set(text)
colour = ERR if error else FG
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_status_label.config(fg=colour)
def _on_edit_or_save_budget(self) -> None:
"""Toggle the budget row between read-only display and editing.
First click: unlock the entry for typing and relabel the button
"Save" -- nothing is persisted yet. Second click: validate and
persist; on success, lock the entry back to read-only and relabel
the button "Edit". A validation failure leaves editing open so the
user can correct the value instead of silently discarding it.
"""
if not self._cal_editing_budget:
self._cal_editing_budget = True
self._set_budget_entry_state("normal")
self._set_budget_button_text("Save")
self._set_budget_status("", error=False)
return
if not self._save_budget_entry():
return
self._cal_editing_budget = False
self._set_budget_button_text("Edit")
self._refresh_calendar()
self._refresh_dashboard()
self._refresh_projection()
def _save_budget_entry(self) -> bool:
"""Validate and persist the entry's current text.
Preserves any body weight already stored alongside the budget (used
to derive the protein target) -- a bare ``write_budget(value)`` would
silently drop it, since the file holds one record, not a diff.
Returns:
Whether the value was valid and persisted.
"""
raw = self._cal_vars.budget.get().strip()
try:
value = int(raw)
except ValueError:
self._set_budget_status("Enter a whole number of kcal.", error=True)
return False
if value <= 0:
self._set_budget_status("Budget must be a positive number.", error=True)
return False
try:
weight = budget_weight()
except BudgetError:
weight = None
write_budget(value, weight_kg=weight)
self._set_budget_status("Saved.", error=False)
return True