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

200 lines
6.4 KiB
Python

"""Pure month-grid math and status text for the diet_guard History tab.
Split out of :mod:`._gatelock_calendar` so the calendar math -- month-grid
construction, per-cell styling, and the streak/YTD summary lines -- carries no
Tk widget dependency and is unit-tested directly, with no fake Tk in the
loop. Also keeps :mod:`._gatelock_calendar` (the Tk-facing half) under the
repo's 500-line limit.
"""
from __future__ import annotations
import calendar
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING
from gatelock import LockConfig
from diet_guard._averages import (
PeriodAverage,
band_label,
monthly_average,
weekly_average,
)
from diet_guard._daystatus import (
DayStatus,
adherence_streak,
logging_streak,
year_to_date_tally,
)
from diet_guard._gatelock_ui import BG, FG
from diet_guard._state import now_local
if TYPE_CHECKING:
from diet_guard._budget_history import BudgetSchedule
from diet_guard._state import DayLog
_COLORS = LockConfig()
# A "not logged" cell renders hollow -- background-colored fill, near-white
# outline -- rather than a pure-black square (safe-design-rules rule 1),
# distinguishing "no data" from "logged and over/under budget" (colored fill).
_NOT_LOGGED_FILL = BG
_NOT_LOGGED_OUTLINE = FG
_STATUS_COLORS = {
DayStatus.GREEN: _COLORS.success,
DayStatus.YELLOW: _COLORS.warning,
DayStatus.RED: _COLORS.danger,
}
_STATUS_TEXT_COLOR = _COLORS.on_fill
_MUTED = _COLORS.muted
@dataclass(frozen=True)
class CalendarCell:
"""One day cell in a rendered month grid.
``day`` is None for a blank cell outside the displayed month. ``status``
is None for a blank cell, a future day (nothing to judge yet), or when no
budget is set (nothing to color by) -- callers render all three the same
neutral way, never as a false "not logged" black cell.
"""
day: int | None
status: DayStatus | None
def _cell_status(
day: date,
status_map_: dict[str, DayStatus] | None,
today: date,
) -> DayStatus | None:
"""Return the status to render for ``day``, or None for nothing to show.
A day after ``today`` has not happened yet, and ``status_map_`` being
None means no budget is set to judge by -- both render neutrally rather
than as a false "not logged" black cell.
"""
if day > today or status_map_ is None:
return None
return status_map_.get(day.isoformat(), DayStatus.NOT_LOGGED)
def build_month_cells(
year: int,
month: int,
status_map_: dict[str, DayStatus] | None,
*,
today: date | None = None,
) -> list[list[CalendarCell]]:
"""Return the month's weeks as a grid of :class:`CalendarCell`.
Mirrors :func:`calendar.monthcalendar`'s shape (a list of weeks, each
seven day-of-month ints with 0 for days outside the month), translated
into cells carrying the status to render.
Args:
year: The displayed year.
month: The displayed month (1-12).
status_map_: Per-day statuses as returned by
:func:`diet_guard._daystatus.status_map`, or None if no budget is
set to classify days by.
today: The reference "today"; defaults to the real current date.
Returns:
A list of weeks, each a list of seven :class:`CalendarCell`.
"""
ref = today if today is not None else now_local().date()
grid: list[list[CalendarCell]] = []
for week in calendar.monthcalendar(year, month):
row: list[CalendarCell] = []
for day_num in week:
if day_num == 0:
row.append(CalendarCell(day=None, status=None))
continue
row.append(
CalendarCell(
day=day_num,
status=_cell_status(date(year, month, day_num), status_map_, ref),
),
)
grid.append(row)
return grid
def cell_style(status: DayStatus | None) -> tuple[str, str, str]:
"""Return ``(bg, fg, highlightbackground)`` for a cell's status."""
if status is None:
return BG, _MUTED, BG
if status is DayStatus.NOT_LOGGED:
return _NOT_LOGGED_FILL, FG, _NOT_LOGGED_OUTLINE
color = _STATUS_COLORS[status]
return color, _STATUS_TEXT_COLOR, color
def _plural(count: int) -> str:
"""Return "day" or "days" for ``count``."""
return "day" if count == 1 else "days"
def streaks_text(
status_map_: dict[str, DayStatus],
*,
today: date | None = None,
) -> str:
"""Render the logging and adherence streak line.
``today`` defaults to the real current date for live UI use; tests pass
it explicitly for deterministic streak boundaries.
"""
logging = logging_streak(status_map_, today=today)
adherence = adherence_streak(status_map_, today=today)
return (
f"Logging streak: {logging} {_plural(logging)} · "
f"Adherence streak: {adherence} {_plural(adherence)}"
)
def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str:
"""Render the year-to-date logged/elapsed/adherent tally line.
``today`` defaults to the real current date for live UI use; tests pass
it explicitly for a deterministic elapsed-day count.
"""
tally = year_to_date_tally(status_map_, today=today)
return (
f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · "
f"{tally.adherent_days} within budget"
)
def _average_part(period: PeriodAverage) -> str:
"""Render one period as ``"NNNN kcal (band)"``, or ``"no data"``."""
if period.avg_kcal is None:
return band_label(period.band)
return f"{period.avg_kcal:.0f} kcal ({band_label(period.band)})"
def averages_text(
log: DayLog,
*,
schedule: BudgetSchedule,
today: date | None = None,
) -> str:
"""Render the weekly/monthly average-intake line.
Both periods stop at yesterday (see :mod:`diet_guard._averages`), so the
line never swings every time a meal is logged today -- it answers "how has
this week gone", not "how am I doing right now", which is what the Log Meal
tab's remaining-budget readout is for.
``today`` defaults to the real current date for live UI use; tests pass it
explicitly for deterministic period edges.
"""
week = weekly_average(log, schedule=schedule, today=today)
month = monthly_average(log, schedule=schedule, today=today)
return (
f"Avg/day (to yesterday): week {_average_part(week)} \u00b7 "
f"month {_average_part(month)}"
)