diet-guard/diet_guard/_daystatus.py
Krzysztof kuhy Rudnicki e07284d3e1 Show weekly and monthly average intake on both devices
The calendar already said which individual days went over budget, but not
whether the week as a whole did. A run of small overages reads as four
harmless yellow squares; averaged, it is the thing that actually moves the
needle.

Three rules are load-bearing, and are documented in CLAUDE.md because getting
any of them wrong silently changes what "under budget" means:

- The denominator is logged days, not elapsed days. An unlogged day is not a
  zero-kcal day, and averaging gaps in as zeros would make a badly-tracked
  week look like the best week of the year.
- The yardstick is the mean of the per-day budgets over those same days,
  resolved through BudgetSchedule -- never today's budget, which would
  retroactively reclassify past weeks on every budget edit.
- Today is excluded. A half-logged today drags the mean far enough to flip
  the band, so every period ends at yesterday.

Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by
import rather than by copy, so a period and its days can never disagree
about what "over" means; that constant loses its underscore to say so.

The MCP get_averages tool returns the average and the band but drops the
average budget, keeping the module's existing invariant that the budget
number never leaves this machine. The CLI, the gate and the phone all show
both, since it is hidden from the network, not from the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
2026-08-09 14:59:40 +02:00

199 lines
6.7 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. Public
# within the package because :mod:`diet_guard._averages` classifies a
# *period average* with the same boundary -- two copies of this number
# would let the calendar and the averages line disagree about "over".
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,
)