mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
140 lines
5.3 KiB
Python
140 lines
5.3 KiB
Python
"""Today's read model over the signed food log.
|
|
|
|
Split out of :mod:`._state` to keep both files under the repo's 250-line
|
|
limit. Everything here is *derived*: it reads through
|
|
:func:`diet_guard._state.load_log` and never touches the file itself.
|
|
|
|
That division is deliberate rather than cosmetic. ``FOOD_LOG_FILE`` and the
|
|
two functions that open it stay in :mod:`._state`, because
|
|
``tests/conftest.py`` redirects the log by patching
|
|
``diet_guard._state.FOOD_LOG_FILE``. Moving a reader of that constant into
|
|
this module would leave the patch pointing at a name nothing reads any more --
|
|
it would still resolve, ``check_patch_targets.py`` would still exit 0, and the
|
|
suite would quietly write to the real ``~/.local/share/diet_guard``.
|
|
|
|
The names are re-exported from :mod:`._state`, which is where a dozen modules
|
|
already import them from.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from diet_guard._budget import daily_budget
|
|
from diet_guard._constants import BUDGET_WARN_FRACTION
|
|
from diet_guard._meal_schedule_store import current_schedule
|
|
from diet_guard._slot_wire import satisfied_by_entries
|
|
from diet_guard._state import (
|
|
_entry_float,
|
|
_today,
|
|
entry_kcal,
|
|
load_log,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from diet_guard._meal_schedule import MealSchedule
|
|
|
|
__all__ = [
|
|
"consumption_band",
|
|
"logged_slots_today",
|
|
"remaining_budget",
|
|
"today_entries",
|
|
"today_total_kcal",
|
|
"today_total_macros",
|
|
]
|
|
|
|
|
|
def today_entries() -> list[dict[str, object]]:
|
|
"""Return today's valid log entries (possibly empty)."""
|
|
return load_log().get(_today(), [])
|
|
|
|
|
|
def today_total_kcal() -> float:
|
|
"""Return total kcal logged today across valid entries."""
|
|
total = sum(entry_kcal(entry) for entry in today_entries())
|
|
return round(total, 1)
|
|
|
|
|
|
def today_total_macros() -> tuple[float, float, float]:
|
|
"""Return today's total ``(protein_g, carbs_g, fat_g)`` across valid entries.
|
|
|
|
Returned as a fixed ``(protein, carbs, fat)`` triple so callers (the gate
|
|
dashboard, the CLI status) can show how the day's macros are stacking up
|
|
next to the calorie total.
|
|
|
|
Returns:
|
|
The summed protein, carbohydrate, and fat grams, each rounded to 0.1 g.
|
|
"""
|
|
entries = today_entries()
|
|
protein = sum(_entry_float(entry, "protein_g") for entry in entries)
|
|
carbs = sum(_entry_float(entry, "carbs_g") for entry in entries)
|
|
fat = sum(_entry_float(entry, "fat_g") for entry in entries)
|
|
return round(protein, 1), round(carbs, 1), round(fat, 1)
|
|
|
|
|
|
def logged_slots_today(schedule: MealSchedule | None = None) -> set[int]:
|
|
"""Return the set of today's slot minutes already covered by today's log.
|
|
|
|
Each entry satisfies the slot *nearest* its recorded minute
|
|
(:func:`diet_guard._slot_wire.satisfied_by_entries`), so a meal logged at
|
|
07:00 still covers a checkpoint the user has since moved to 07:15.
|
|
Without the snap, every schedule edit would re-open the slots already
|
|
eaten that day and lock the user out for meals they did log.
|
|
|
|
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
|
|
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
|
|
direction. An entry without a ``slot`` field (e.g. a snack logged with no
|
|
checkpoint) contributes calories but satisfies no slot.
|
|
|
|
Nothing produces a slot-less entry any more: the phone's "Snack" chip was
|
|
removed on 2026-08-14, and every remaining writer resolves a concrete slot
|
|
through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate
|
|
(:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back
|
|
to it whenever its ``slot`` argument is omitted or None). Skipping
|
|
slot-less entries (in ``satisfied_by_entries``) is still load-bearing for
|
|
entries already on disk and arriving over sync -- removing it would make
|
|
every historical snack retroactively satisfy a meal slot.
|
|
|
|
Args:
|
|
schedule: The schedule to snap onto; defaults to today's.
|
|
|
|
Returns:
|
|
The distinct slot minutes satisfied today (possibly empty).
|
|
"""
|
|
resolved = schedule if schedule is not None else current_schedule()
|
|
return satisfied_by_entries(today_entries(), resolved)
|
|
|
|
|
|
def remaining_budget() -> float:
|
|
"""Return kcal remaining against the daily budget (may be negative).
|
|
|
|
Raises:
|
|
BudgetError: If the budget is uninitialized or its file is corrupt;
|
|
the caller decides whether to guide the user or fail closed.
|
|
"""
|
|
return round(daily_budget() - today_total_kcal(), 1)
|
|
|
|
|
|
def consumption_band() -> str:
|
|
"""Return a qualitative band for today's intake, never revealing the budget.
|
|
|
|
Mirrors how the focus daemon surfaces "at home?" rather than the raw
|
|
coordinates: the caller learns whether to worry, not the number behind the
|
|
threshold. The threshold still leaks by boundary-probing (watch the label
|
|
flip), so this hides the anchor, it does not make the budget unrecoverable.
|
|
|
|
Returns:
|
|
``"OVER BUDGET"``, ``"approaching limit"``, or ``"on track"``.
|
|
|
|
Raises:
|
|
BudgetError: Propagated from :func:`daily_budget` for the caller to
|
|
translate into guidance.
|
|
"""
|
|
budget = daily_budget()
|
|
consumed = today_total_kcal()
|
|
if consumed >= budget:
|
|
return "OVER BUDGET"
|
|
if consumed >= budget * BUDGET_WARN_FRACTION:
|
|
return "approaching limit"
|
|
return "on track"
|