mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:27:39 +01:00
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
196 lines
6.4 KiB
Python
196 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
|
|
from diet_guard._averages_report import 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)}"
|
|
)
|