mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:47:12 +01:00
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
467 lines
15 KiB
Python
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)
|