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

111 lines
3.7 KiB
Python

"""CLI handler for the ``averages`` subcommand.
Split out from :mod:`diet_guard._cli` for the same reason
:mod:`diet_guard._cli_sync` is: that module is already close to the repo's
500-line cap.
The daily budget is printed here on purpose. It is "hidden" only in the sense
of never leaving this machine (see :mod:`diet_guard._constants`), and an
average intake with no yardstick beside it is unreadable -- "2310 kcal/day,
slightly over" invites the obvious "over *what*?". The MCP server's
equivalent tool deliberately makes the opposite trade and returns the band
alone; see :mod:`diet_guard._mcp`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._averages import (
band_label,
monthly_average,
weekly_average,
)
from diet_guard._budget import (
BudgetFileCorruptError,
BudgetNotInitializedError,
current_schedule,
daily_budget,
)
from diet_guard._state import load_log
if TYPE_CHECKING:
import argparse
from collections.abc import Callable
from diet_guard._averages import PeriodAverage
from diet_guard._budget_history import BudgetSchedule
# Width of the leading period label, so the numbers line up in a column.
_LABEL_WIDTH = 11
def register_averages_subparser(sub: argparse._SubParsersAction) -> None:
"""Register the ``averages`` subcommand on ``sub``."""
sub.add_parser(
"averages",
help="Average kcal/day this and last week, and this and last month.",
)
def _format_period(label: str, period: PeriodAverage) -> str:
"""Render one period as a single aligned line.
Args:
label: The period's name, e.g. ``"this week"``.
period: The computed average.
Returns:
A line ending in the coverage count, or a short "nothing yet" line
when the period has no logged day to average.
"""
head = f"{label:<{_LABEL_WIDTH}} {period.start}..{period.end}"
if period.avg_kcal is None or period.avg_budget is None:
return f"{head} no logged days yet"
return (
f"{head} {period.avg_kcal:.0f} kcal/day - "
f"{band_label(period.band)} (budget {period.avg_budget:.0f}) "
f"[{period.logged_days}/{period.elapsed_days} days logged]"
)
def _emit_periods(emit: Callable[[str], None], schedule: BudgetSchedule) -> None:
"""Print the four period lines against ``schedule``."""
log = load_log()
rows = (
("this week", weekly_average(log, schedule=schedule, weeks_ago=0)),
("last week", weekly_average(log, schedule=schedule, weeks_ago=1)),
("this month", monthly_average(log, schedule=schedule, months_ago=0)),
("last month", monthly_average(log, schedule=schedule, months_ago=1)),
)
for label, period in rows:
emit(_format_period(label, period))
def cmd_averages(emit: Callable[[str], None]) -> int:
"""Print weekly and monthly average intake and its budget band.
Every period ends at *yesterday*: today is still being logged, and half a
day of entries would drag the mean far enough to flip the band (see
:mod:`diet_guard._averages`).
Args:
emit: A one-line output sink (``_cli._emit``, passed in rather than
imported so this module has no reach-in dependency on ``_cli``).
Returns:
0 once the periods are printed, 1 when no usable budget exists to
judge them against.
"""
try:
budget = daily_budget()
except BudgetNotInitializedError:
emit("budget not set - run: python -m diet_guard init")
return 1
except BudgetFileCorruptError:
emit("budget file corrupt - re-run init")
return 1
emit("averages exclude today, which is still being logged.")
_emit_periods(emit, current_schedule(default=budget))
return 0