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

196 lines
6.5 KiB
Python

"""Per-day budget-adherence status, streaks, and year-to-date tallies.
DAY STATUS SPEC (keep in sync with ``app/lib/services/day_status_service.dart``):
* green: ``total_kcal <= budget``
* yellow: ``budget < total_kcal <= budget * (1 + (1 - BUDGET_WARN_FRACTION))``
* red: ``total_kcal > budget * (1 + (1 - BUDGET_WARN_FRACTION))``
* not_logged: the day is absent from a filtered log (no valid,
non-tombstoned entries) -- see :func:`diet_guard._state.load_log`.
* ordering, worst to best: not_logged > red > yellow > green.
* the logging streak breaks on a not_logged day.
* the adherence streak breaks on a red or a not_logged day; yellow and green
both keep it alive.
Every function here is a pure function of its explicit ``log`` /
``schedule`` / ``status_map`` arguments (never reaching into on-disk state
itself), so the boundary matrix above is trivially testable with synthetic
data.
``budget`` is per-day, not a single number: :func:`status_map` resolves each
day through a :class:`~diet_guard._budget_history.BudgetSchedule` so that
lowering the budget today does not retroactively reclassify every past day
(see :mod:`diet_guard._budget_history`).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import enum
from typing import TYPE_CHECKING
from diet_guard._constants import BUDGET_WARN_FRACTION
from diet_guard._state import entry_kcal, now_local
if TYPE_CHECKING:
from diet_guard._budget_history import BudgetSchedule
from diet_guard._state import DayLog
class DayStatus(enum.Enum):
"""Qualitative per-day budget-adherence state, worst to best."""
NOT_LOGGED = "not_logged"
RED = "red"
YELLOW = "yellow"
GREEN = "green"
# The top of the "yellow" band above budget: a margin as wide as
# BUDGET_WARN_FRACTION's margin below 100% (the existing "approaching limit"
# band), so the two bands are symmetric around the budget line.
_OVER_BUDGET_YELLOW_CEILING = 1.0 + (1.0 - BUDGET_WARN_FRACTION)
_LOGGED_STATUSES = frozenset(
{DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED},
)
_ADHERENT_STATUSES = frozenset({DayStatus.GREEN, DayStatus.YELLOW})
@dataclass(frozen=True)
class YtdTally:
"""Year-to-date logging/adherence counts.
Attributes:
logged_days: Days this year with at least one valid log entry.
elapsed_days: Days from Jan 1 through the reference date, inclusive.
adherent_days: Of ``logged_days``, how many were green or yellow.
"""
logged_days: int
elapsed_days: int
adherent_days: int
def day_total_kcal(log: DayLog, day: str) -> float:
"""Return the summed kcal for one ``YYYY-MM-DD`` key in ``log``."""
return sum(entry_kcal(entry) for entry in log.get(day, []))
def day_status(log: DayLog, day: str, budget: int) -> DayStatus:
"""Classify one day's budget adherence.
Args:
log: A filtered log as returned by
:func:`diet_guard._state.load_log` (only valid, non-tombstoned
entries; a day with none is simply absent).
day: The ``YYYY-MM-DD`` date key to classify.
budget: The daily kcal budget to compare against.
Returns:
The day's :class:`DayStatus`.
"""
if day not in log:
return DayStatus.NOT_LOGGED
total = day_total_kcal(log, day)
if total <= budget:
return DayStatus.GREEN
if total <= budget * _OVER_BUDGET_YELLOW_CEILING:
return DayStatus.YELLOW
return DayStatus.RED
def status_map(log: DayLog, *, schedule: BudgetSchedule) -> dict[str, DayStatus]:
"""Return a :class:`DayStatus` for every date key present in ``log``.
Each day is judged against the budget that applied *on that day*, so a
later budget change leaves earlier days classified exactly as before.
Days with no log entries are simply absent from the result; a caller
rendering a full calendar treats a missing key as
:attr:`DayStatus.NOT_LOGGED`.
"""
return {day: day_status(log, day, schedule.for_day(day)) for day in log}
def _streak(
status_map_: dict[str, DayStatus],
keeps: frozenset[DayStatus],
*,
today: date | None = None,
) -> int:
"""Count consecutive days ending at ``today`` whose status is in ``keeps``.
A ``today`` absent from ``status_map_`` (not yet logged) is skipped
rather than treated as a break, so the streak does not appear broken
every morning before the user has eaten; counting resumes from
yesterday in that case. Any other day missing from ``keeps`` -- logged
or not -- ends the streak normally.
"""
day = today if today is not None else now_local().date()
if status_map_.get(day.isoformat()) is None:
day -= timedelta(days=1)
count = 0
while status_map_.get(day.isoformat()) in keeps:
count += 1
day -= timedelta(days=1)
return count
def logging_streak(
status_map_: dict[str, DayStatus],
*,
today: date | None = None,
) -> int:
"""Return the consecutive-day logging streak ending at ``today``."""
return _streak(status_map_, _LOGGED_STATUSES, today=today)
def adherence_streak(
status_map_: dict[str, DayStatus],
*,
today: date | None = None,
) -> int:
"""Return the consecutive-day budget-adherence streak ending at ``today``.
Breaks on a red or not-logged day; green and yellow days both keep it
alive ("not too aggressively crossed").
"""
return _streak(status_map_, _ADHERENT_STATUSES, today=today)
def year_to_date_tally(
status_map_: dict[str, DayStatus],
*,
today: date | None = None,
) -> YtdTally:
"""Return this year's logged/elapsed/adherent day counts.
Args:
status_map_: Per-day statuses as returned by :func:`status_map`.
today: The reference "today"; defaults to the real current date.
Returns:
Counts of logged days this year, elapsed days so far this year
(inclusive of ``today``), and how many logged days were adherent.
"""
ref = today if today is not None else now_local().date()
elapsed_days = (ref - date(ref.year, 1, 1)).days + 1
logged_days = 0
adherent_days = 0
for key, status in status_map_.items():
if status is DayStatus.NOT_LOGGED:
continue
day = date.fromisoformat(key)
if day.year != ref.year or day > ref:
continue
logged_days += 1
if status in _ADHERENT_STATUSES:
adherent_days += 1
return YtdTally(
logged_days=logged_days,
elapsed_days=elapsed_days,
adherent_days=adherent_days,
)