mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
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>
196 lines
6.5 KiB
Python
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,
|
|
)
|