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

467 lines
15 KiB
Python

"""Command-line interface for diet_guard.
Examples:
python -m diet_guard init
python -m diet_guard ate "big mac"
python -m diet_guard ate "two slices of pizza" --grams 240
python -m diet_guard ate "protein shake" --kcal 180
python -m diet_guard status
python -m diet_guard averages
python -m diet_guard undo
The daily budget lives outside the repo (so it is never exposed online) but is
shown freely on this machine: ``status`` and each log print how many calories
are left of the day's budget, plus which meal slots still need logging.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import sys
from typing import TYPE_CHECKING
from diet_guard._budget import (
Biometrics,
BudgetFileCorruptError,
BudgetNotInitializedError,
compute_target_budget,
daily_budget,
protein_target_g,
write_budget,
)
from diet_guard._cli_averages import cmd_averages, register_averages_subparser
from diet_guard._cli_gate import cmd_gate
from diet_guard._cli_sync import cmd_sync, register_sync_subparser
from diet_guard._foodbank import remember_food
from diet_guard._gate import due_slots
from diet_guard._portions import (
DEFAULT_ITEM_GRAMS,
estimate_unit_grams,
)
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import day_slots, slot_for_log, slot_label
from diet_guard._state import (
entry_kcal,
log_meal,
logged_slots_today,
now_local,
today_entries,
today_total_kcal,
today_total_macros,
undo_last_today,
)
if TYPE_CHECKING:
from collections.abc import Callable
# Column width for a meal description in the status listing.
_DESC_WIDTH = 24
# An ISO timestamp formats as "YYYY-MM-DDTHH:MM:SS"; HH:MM is chars 11..16.
_TIME_SLICE = slice(11, 16)
# Accepted answers for the sex prompt that map to the male BMR constant.
_MALE_ANSWERS = {"m", "male"}
_FEMALE_ANSWERS = {"f", "female"}
@dataclass(frozen=True)
class _ManualMacros:
"""User-supplied calories/macros for ``ate``, all optional.
Grouping these keeps :func:`_cmd_ate` within the argument-count limit and
makes "manual values were supplied" a single, testable value object.
Attributes:
kcal: Calories entered manually (None means look the food up instead).
protein: Protein grams, recorded alongside ``kcal``.
carbs: Carbohydrate grams, recorded alongside ``kcal``.
fat: Fat grams, recorded alongside ``kcal``.
"""
kcal: float | None
protein: float | None
carbs: float | None
fat: float | None
@dataclass(frozen=True)
class _Portion:
"""How much was eaten and the basis for any typed macros.
Grouped so :func:`_cmd_ate` stays within the argument-count limit.
Attributes:
grams: Explicit grams eaten, or None.
count: Number of items eaten (an alternative to ``grams``), or None.
per_grams: Reference weight the typed macros are stated for (e.g. 100
for a per-100 g label), or None to treat the macros as totals.
"""
grams: float | None
count: float | None
per_grams: float | None
def _emit(text: str = "") -> None:
"""Write one line to stdout.
A thin wrapper over ``sys.stdout.write`` so genuine CLI output does not
trip ruff's ``T201`` (no ``print``) without resorting to a suppression.
"""
sys.stdout.write(f"{text}\n")
def _ask(label: str) -> str:
"""Print a prompt label and return one trimmed line from stdin."""
_emit(label)
return sys.stdin.readline().strip()
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse diet_guard CLI arguments."""
parser = argparse.ArgumentParser(
prog="diet_guard",
description="Log calories and check your daily budget.",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser(
"init",
help="Compute your starting daily budget from biometrics.",
)
ate = sub.add_parser("ate", help="Log a meal you just ate.")
ate.add_argument("description", help='What you ate, e.g. "big mac".')
ate.add_argument(
"--grams",
type=float,
default=None,
help="Portion size in grams (default: OFF serving size, else 100 g).",
)
ate.add_argument(
"--kcal",
type=float,
default=None,
help="Calories entered manually; skips the food bank and OFF lookup.",
)
ate.add_argument(
"--protein",
type=float,
default=None,
help="Protein in grams (recorded with --kcal to seed the food bank).",
)
ate.add_argument(
"--carbs",
type=float,
default=None,
help="Carbohydrate in grams (recorded with --kcal).",
)
ate.add_argument(
"--fat",
type=float,
default=None,
help="Fat in grams (recorded with --kcal).",
)
ate.add_argument(
"--per",
type=float,
default=None,
help="Grams the macros are stated for (e.g. 100 for a per-100 g label);"
" the typed macros are scaled from this to how much you ate.",
)
ate.add_argument(
"--count",
type=float,
default=None,
help="Number of items eaten (e.g. 5 apples) instead of --grams;"
" multiplied by the staple's unit weight.",
)
sub.add_parser("status", help="Show today's calories and budget band.")
sub.add_parser("undo", help="Remove today's most recent entry.")
register_averages_subparser(sub)
register_sync_subparser(sub)
gate = sub.add_parser(
"gate",
help="Log-to-unlock screen gate (intended to be run by a timer).",
)
gate.add_argument(
"--check",
action="store_true",
help="Headless: exit 0 if NOT due, 1 if a lock is due. Prints, no window.",
)
gate.add_argument(
"--demo",
action="store_true",
help="Show the lock in safe demo mode (local grab + close button).",
)
return parser.parse_args(argv)
def _print_summary() -> None:
"""Print today's total and how much of the daily budget is left.
The budget number is shown here on purpose: it is "hidden" only in the
sense of never leaving this machine (it lives outside the repo), not hidden
from the user, who needs it to make portion decisions.
"""
total = today_total_kcal()
try:
budget = daily_budget()
except BudgetNotInitializedError:
_emit(
f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)",
)
return
except BudgetFileCorruptError:
_emit(f"today: {total:g} kcal (budget file corrupt - re-run init)")
return
remaining = round(budget - total, 1)
_emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}")
def _print_entry_line(entry: dict[str, object]) -> None:
"""Print a single log entry as 'HH:MM desc kcal (source)'."""
time_str = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))
source = str(entry.get("source", ""))
_emit(
f" {time_str:>5} {desc:<{_DESC_WIDTH}.{_DESC_WIDTH}} "
f"{entry_kcal(entry):>6.0f} kcal ({source})",
)
def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
"""Prompt for biometrics on stdin; return (bio, activity, deficit) or None.
Returns None (after printing why) on any unparsable or out-of-range input,
so a typo never sets a wrong budget.
"""
try:
weight = float(_ask("weight in kg:"))
height = float(_ask("height in cm:"))
age = float(_ask("age in years:"))
sex_raw = _ask("sex (m/f):").lower()
activity = float(
_ask(
"activity factor "
"(1.2 sedentary / 1.375 light / 1.55 moderate / 1.725 active):",
),
)
deficit = float(_ask("daily deficit in kcal (e.g. 200):"))
except ValueError:
_emit("that was not a number; nothing was set.")
return None
if sex_raw in _MALE_ANSWERS:
is_male = True
elif sex_raw in _FEMALE_ANSWERS:
is_male = False
else:
_emit('sex must be "m" or "f"; nothing was set.')
return None
bio = Biometrics(
weight_kg=weight,
height_cm=height,
age_years=age,
is_male=is_male,
)
return bio, activity, deficit
def _cmd_init() -> int:
"""Compute the starting budget from biometrics and write it."""
inputs = _read_init_inputs()
if inputs is None:
return 2
bio, activity, deficit = inputs
budget = compute_target_budget(
bio,
activity_factor=activity,
deficit_kcal=deficit,
)
write_budget(budget, weight_kg=bio.weight_kg)
_emit(f"budget computed from your biometrics: {budget:g} kcal/day.")
_emit("edit it any time from the gate's calendar tab or the phone app.")
return 0
def _eaten_grams(
description: str,
portion: _Portion,
) -> tuple[float | None, str | None]:
"""Resolve how many grams were eaten, plus a note if a weight was assumed.
A count of items is turned into grams via the staple's unit weight; an
unknown item falls back to a default weight, with a note so the estimate is
never silent.
Args:
description: The food name (used to look up a per-item weight).
portion: The user's portion inputs.
Returns:
``(grams, note)`` where ``grams`` may be None (no portion given) and
``note`` is a one-line caveat to print, or None.
"""
if portion.count is not None:
unit = estimate_unit_grams(description)
if unit is None:
return (
portion.count * DEFAULT_ITEM_GRAMS,
f"(assumed {DEFAULT_ITEM_GRAMS:g} g per item; "
"pass --grams to be exact)",
)
return portion.count * unit, None
return portion.grams, None
def _cmd_ate(description: str, portion: _Portion, macros: _ManualMacros) -> int:
"""Resolve and log a meal, tag its slot, bank it, then print the total.
Resolution order is manual, then food bank, then the staple table, then
Open Food Facts (see :func:`resolve_nutrition`). A per-item count or a
per-reference macro basis is converted to the amount actually eaten first,
and the food is remembered so next time it is served from local history.
"""
eaten, note = _eaten_grams(description, portion)
if note is not None:
_emit(note)
manual_macros = (
ManualMacros(
kcal=macros.kcal,
protein=macros.protein or 0.0,
carbs=macros.carbs or 0.0,
fat=macros.fat or 0.0,
per_grams=portion.per_grams,
)
if macros.kcal is not None
else None
)
nutrition = resolve_nutrition(
description,
grams=eaten,
manual_macros=manual_macros,
)
if nutrition is None:
_emit(
f'no food bank, staple, or Open Food Facts match for "{description}". '
"re-run with --kcal <number> to log it manually.",
)
return 1
log_meal(description, nutrition, slot_for_log(now_local()))
remember_food(description, nutrition)
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
_emit(
f"logged: {description} {nutrition.kcal:g} kcal "
f"({macro_str}) [{nutrition.source}, {portion_str}]",
)
_print_summary()
return 0
def _print_slot_status() -> None:
"""Print each meal slot as logged / DUE / upcoming for today."""
logged = logged_slots_today()
due = set(due_slots())
parts: list[str] = []
for slot in day_slots():
if slot in logged:
mark = "logged"
elif slot in due:
mark = "DUE"
else:
mark = "upcoming"
parts.append(f"{slot_label(slot)} {mark}")
_emit("slots: " + " ".join(parts))
def _print_macro_status() -> None:
"""Print today's macros so far, with the protein target when it is known.
Mirrors the gate's dashboard on the command line so "how am I doing" is
answerable without opening the window. The protein target only appears once
the budget has been initialized with a body weight (see ``init``).
"""
protein, carbs, fat = today_total_macros()
line = f"macros: P{protein:g} C{carbs:g} F{fat:g} g"
target = protein_target_g()
if target is not None:
remaining = round(target - protein, 1)
line += f" - protein {protein:g}/{target:g} g ({remaining:g} left)"
_emit(line)
def _cmd_status() -> int:
"""Print today's entries, per-slot status, macros, and the budget remaining."""
entries = today_entries()
for entry in entries:
_print_entry_line(entry)
if entries:
_emit("-" * 48)
_print_slot_status()
_print_summary()
_print_macro_status()
return 0
def _cmd_undo() -> int:
"""Remove today's most recent entry and report what was removed."""
removed = undo_last_today()
if removed is None:
_emit("nothing to undo today.")
return 0
desc = str(removed.get("desc", "?"))
_emit(f"removed: {desc} ({entry_kcal(removed):g} kcal)")
_print_summary()
return 0
def _dispatch_ate(args: argparse.Namespace) -> int:
"""Marshal ``ate``'s flags into value objects, then log the meal."""
return _cmd_ate(
args.description,
_Portion(grams=args.grams, count=args.count, per_grams=args.per),
_ManualMacros(
kcal=args.kcal,
protein=args.protein,
carbs=args.carbs,
fat=args.fat,
),
)
def _dispatch_gate(args: argparse.Namespace) -> int:
"""Run the gate subcommand with its two flags."""
return cmd_gate(_emit, check=args.check, demo=args.demo)
# A table rather than an if-chain: the chain grew a return per subcommand and
# tripped ruff's return-count limit at the seventh, and a table also has no
# unreachable trailing branch for the 100%-coverage gate to chase.
_COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
"init": lambda _args: _cmd_init(),
"ate": _dispatch_ate,
"status": lambda _args: _cmd_status(),
"averages": lambda _args: cmd_averages(_emit),
"sync": lambda _args: cmd_sync(_emit),
"gate": _dispatch_gate,
"undo": lambda _args: _cmd_undo(),
}
def main(argv: list[str] | None = None) -> int:
"""Dispatch a diet_guard subcommand.
Args:
argv: Argument list (defaults to ``sys.argv[1:]``).
Returns:
A process exit code (0 on success).
"""
args = _parse_args(sys.argv[1:] if argv is None else argv)
return _COMMANDS[args.command](args)