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

139 lines
4.5 KiB
Python

"""Pure meal-slot arithmetic for the diet_guard gate.
This module is deliberately I/O-free and clock-free: every function is a total
function of its ``now`` argument and the configured slot constants, so the
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
reset) are exhaustively unit-testable without mocking the filesystem or the
wall clock. The stateful "which slots have I actually logged?" question lives
in :mod:`diet_guard._state`; the two are composed in
:mod:`diet_guard._gate`.
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
the daily enforcement window; an elapsed slot with no logged meal is what makes
the gate fire.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._constants import (
GATE_DAY_START_HOUR,
GATE_EATING_END_HOUR,
GATE_SLOT_INTERVAL_HOURS,
)
if TYPE_CHECKING:
from datetime import datetime
_HOURS_PER_DAY = 24
def day_slots() -> tuple[int, ...]:
"""Return the fixed meal-slot hours for a day, e.g. ``(8, 12, 16, 20)``.
Slots run from the day-start hour, every interval, up to (but not past) the
overnight cutoff. Derived from the constants so changing the cadence in one
place reshapes the whole schedule.
Returns:
The slot hours in ascending order.
"""
return tuple(
range(GATE_DAY_START_HOUR, GATE_EATING_END_HOUR, GATE_SLOT_INTERVAL_HOURS)
)
def within_enforcement_window(now: datetime) -> bool:
"""Return True if ``now`` is inside the daily slot-enforcement window.
Outside ``[day_start, eating_end)`` the gate never fires, so unlogged slots
lapse overnight instead of trapping you at 03:00.
Args:
now: Reference local time.
Returns:
True if slot enforcement is active at ``now``.
"""
return GATE_DAY_START_HOUR <= now.hour < GATE_EATING_END_HOUR
def elapsed_slots(now: datetime) -> tuple[int, ...]:
"""Return today's slots whose hour has arrived as of ``now``.
Empty outside the enforcement window (before the first slot, or after the
overnight cutoff), so the caller never has to special-case the night.
Args:
now: Reference local time.
Returns:
The elapsed slot hours, ascending (possibly empty).
"""
if not within_enforcement_window(now):
return ()
return tuple(slot for slot in day_slots() if slot <= now.hour)
def missing_slots(now: datetime, logged: set[int]) -> tuple[int, ...]:
"""Return elapsed slots that have not been satisfied by a logged meal.
Args:
now: Reference local time.
logged: The set of slot hours already covered by today's log.
Returns:
The unsatisfied elapsed slot hours, ascending (empty == nothing due).
"""
return tuple(slot for slot in elapsed_slots(now) if slot not in logged)
def current_slot(now: datetime) -> int | None:
"""Return the most recent elapsed slot as of ``now``, or None.
Reports the schedule position only. Tagging a *log* with a slot goes
through :func:`slot_for_log`, which additionally clamps off-hours meals
instead of returning None.
Args:
now: Reference local time.
Returns:
The latest elapsed slot hour, or None when none have elapsed yet.
"""
elapsed = elapsed_slots(now)
return elapsed[-1] if elapsed else None
def slot_for_log(now: datetime) -> int:
"""Return the slot a meal logged at ``now`` should be attributed to.
CLAMP RULE (keep byte-identical with ``slot.dart``'s ``slotForLog``): before
the first window, clamp to the first slot; after the last window, clamp to
the last slot; behaviour inside a window is unchanged.
Unlike :func:`current_slot` this never returns None, which is the point: an
off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
still left the gate firing for that checkpoint. Attribution is deliberately
separate from :func:`elapsed_slots`/:func:`missing_slots` -- widening *those*
would instead make every slot fall due at 23:00.
Args:
now: Reference local time.
Returns:
The slot hour to tag the log with.
"""
slots = day_slots()
if now.hour < GATE_DAY_START_HOUR:
return slots[0]
current = current_slot(now)
return current if current is not None else slots[-1]
def slot_label(slot: int) -> str:
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
return f"{slot % _HOURS_PER_DAY:02d}:00"