mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
8.1 KiB
Python
232 lines
8.1 KiB
Python
"""CLI for the Body feature: ``body``, ``weight``, ``profile``, ``plan-week``.
|
|
|
|
Split out of :mod:`diet_guard._cli` for the 250-line cap. Everything shown is
|
|
informational; nothing here writes the daily budget. The one budget-record
|
|
field touched is ``w`` (body weight, feeding the protein target), and only
|
|
when the typed weigh-in is the newest one.
|
|
|
|
Edits publish straight away (``publish_after_log``), for the same reason a
|
|
logged meal does: until the full tick runs, the phone shows stale data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import replace
|
|
from datetime import date
|
|
from typing import TYPE_CHECKING
|
|
|
|
from diet_guard._body_energy import (
|
|
ACTIVITY_CHOICES,
|
|
DIRECTIONS,
|
|
TIME_UNITS,
|
|
WEIGHT_UNITS,
|
|
Goal,
|
|
)
|
|
from diet_guard._body_fat_store import (
|
|
MAX_BODY_FAT,
|
|
MIN_BODY_FAT,
|
|
body_fats,
|
|
set_body_fat,
|
|
)
|
|
from diet_guard._body_prefs import saved_goal, set_goal
|
|
from diet_guard._body_report import (
|
|
activity_lines,
|
|
bmi_lines,
|
|
bmr_lines,
|
|
bodyfat_lines,
|
|
ideal_lines,
|
|
plan_lines,
|
|
profile_lines,
|
|
target_lines,
|
|
)
|
|
from diet_guard._body_store import (
|
|
MAX_KG,
|
|
MIN_KG,
|
|
newest_weight,
|
|
set_profile,
|
|
set_weight,
|
|
weights,
|
|
)
|
|
from diet_guard._body_view import budget_or_default, build_view
|
|
from diet_guard._daystatus import day_total_kcal
|
|
from diet_guard._phone_weight import update_weight
|
|
from diet_guard._state import load_log, now_local
|
|
from diet_guard._sync_events import publish_after_log
|
|
from diet_guard._week_plan import WEEKDAYS, plan_week
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
_WEIGHT_LIST = 14
|
|
|
|
|
|
def _iso_date(text: str) -> str:
|
|
"""Argparse type: a ``YYYY-MM-DD`` date, normalised."""
|
|
try:
|
|
return date.fromisoformat(text).isoformat()
|
|
except ValueError as exc:
|
|
msg = f"not a YYYY-MM-DD date: {text!r}"
|
|
raise argparse.ArgumentTypeError(msg) from exc
|
|
|
|
|
|
def _day_kcal(text: str) -> tuple[int, int]:
|
|
"""Argparse type: ``mon=2500`` -> ``(0, 2500)``."""
|
|
name, _, kcal = text.partition("=")
|
|
names = [w.lower() for w in WEEKDAYS]
|
|
if name.lower()[:3] not in names or not kcal.isdigit():
|
|
msg = f"expected e.g. mon=2500, got {text!r}"
|
|
raise argparse.ArgumentTypeError(msg)
|
|
return names.index(name.lower()[:3]), int(kcal)
|
|
|
|
|
|
def register_body_subparsers(sub: argparse._SubParsersAction) -> None:
|
|
"""Register ``body``, ``weight``, ``profile`` and ``plan-week``."""
|
|
body = sub.add_parser("body", help="Calorie target, BMI, ideal weight, workouts.")
|
|
body.add_argument("--direction", choices=DIRECTIONS, help="lose, maintain or gain.")
|
|
body.add_argument("--amount", type=float, help="how much, e.g. 0.5.")
|
|
body.add_argument(
|
|
"--weight-unit", choices=list(WEIGHT_UNITS), help="kg, lb, g, st."
|
|
)
|
|
body.add_argument("--time-unit", choices=list(TIME_UNITS), help="per day/week/...")
|
|
body.add_argument("--activity", choices=ACTIVITY_CHOICES, help="activity level.")
|
|
body.add_argument("--save", action="store_true", help="remember this goal.")
|
|
fat = sub.add_parser("bodyfat", help="Log (or list) body fat %%.")
|
|
fat.add_argument("pct", nargs="?", type=float, help="body fat in %%.")
|
|
fat.add_argument("--date", type=_iso_date, help="day (default today).")
|
|
fat.add_argument("--delete", action="store_true", help="delete that day.")
|
|
weight = sub.add_parser("weight", help="Log (or list) body weight.")
|
|
weight.add_argument("kg", nargs="?", type=float, help="weight in kg.")
|
|
weight.add_argument("--date", type=_iso_date, help="day (default today).")
|
|
weight.add_argument("--delete", action="store_true", help="delete that day.")
|
|
prof = sub.add_parser("profile", help="Set birth date, height and sex.")
|
|
prof.add_argument("--birth", type=_iso_date, help="YYYY-MM-DD.")
|
|
prof.add_argument("--height", type=float, help="height in cm.")
|
|
prof.add_argument("--sex", choices=("m", "f"), help="for the BMR constant.")
|
|
plan = sub.add_parser(
|
|
"plan-week", help="kcal per remaining day for a weekly average."
|
|
)
|
|
plan.add_argument("--target", type=int, help="average kcal/day (default: budget).")
|
|
plan.add_argument(
|
|
"--day", type=_day_kcal, action="append", default=[], help="mon=2500"
|
|
)
|
|
|
|
|
|
def _publish(emit: Callable[[str], None]) -> None:
|
|
"""Push the edit now, reporting (not raising) an outage."""
|
|
failure = publish_after_log()
|
|
if failure is not None:
|
|
emit(f"saved locally, not yet published ({failure}).")
|
|
|
|
|
|
def _goal(args: argparse.Namespace) -> Goal:
|
|
"""The saved goal with any typed part replaced."""
|
|
goal = saved_goal()
|
|
parts = {
|
|
"direction": args.direction,
|
|
"amount": args.amount,
|
|
"weight_unit": args.weight_unit,
|
|
"time_unit": args.time_unit,
|
|
"activity": args.activity,
|
|
}
|
|
return replace(goal, **{k: v for k, v in parts.items() if v is not None})
|
|
|
|
|
|
def cmd_body(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
|
"""Print every Body block: the target first, profile and body fat last.
|
|
|
|
The profile is set up once, so it sits at the bottom -- unless something
|
|
in it is missing, when it comes first so the gap is the first thing seen.
|
|
"""
|
|
today = now_local().date()
|
|
goal = _goal(args)
|
|
if args.save:
|
|
set_goal(goal)
|
|
emit("goal saved.")
|
|
view = build_view(today, goal)
|
|
blocks = [
|
|
target_lines(view),
|
|
bmi_lines(view),
|
|
ideal_lines(view),
|
|
bmr_lines(view),
|
|
activity_lines(view),
|
|
]
|
|
profile = profile_lines(view, today)
|
|
blocks = [profile, *blocks] if not view.profile.complete else [*blocks, profile]
|
|
blocks.append(bodyfat_lines(view))
|
|
for block in blocks:
|
|
for line in block:
|
|
emit(line)
|
|
emit("")
|
|
return 0
|
|
|
|
|
|
def cmd_bodyfat(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
|
"""Log, delete or list body-fat readings."""
|
|
day = args.date or now_local().date().isoformat()
|
|
if args.pct is None and not args.delete:
|
|
for when, pct in list(body_fats().items())[-_WEIGHT_LIST:]:
|
|
emit(f"{when} {pct:.1f}%")
|
|
return 0
|
|
if args.pct is not None and not MIN_BODY_FAT <= args.pct <= MAX_BODY_FAT:
|
|
emit(f"body fat must be between {MIN_BODY_FAT:g} and {MAX_BODY_FAT:g} %.")
|
|
return 1
|
|
set_body_fat(day, None if args.delete else args.pct)
|
|
emit(f"{'deleted' if args.delete else f'{args.pct:g}%'} for {day}.")
|
|
_publish(emit)
|
|
return 0
|
|
|
|
|
|
def cmd_weight(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
|
"""Log, delete or list weigh-ins."""
|
|
day = args.date or now_local().date().isoformat()
|
|
if args.kg is None and not args.delete:
|
|
for when, kg in list(weights().items())[-_WEIGHT_LIST:]:
|
|
emit(f"{when} {kg:.1f} kg")
|
|
return 0
|
|
if args.kg is not None and not MIN_KG <= args.kg <= MAX_KG:
|
|
emit(f"weight must be between {MIN_KG:g} and {MAX_KG:g} kg.")
|
|
return 1
|
|
set_weight(day, None if args.delete else args.kg)
|
|
newest = newest_weight()
|
|
# Editing (or deleting) the newest day moves ``w``; an older day does not.
|
|
if newest is not None and day >= newest[0]:
|
|
update_weight(newest[1])
|
|
emit(f"{'deleted' if args.delete else f'{args.kg:g} kg'} for {day}.")
|
|
_publish(emit)
|
|
return 0
|
|
|
|
|
|
def cmd_profile(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
|
"""Set any of birth/height/sex."""
|
|
fields = {
|
|
name: value
|
|
for name, value in (
|
|
("birth", args.birth),
|
|
("height_cm", args.height),
|
|
("sex", args.sex),
|
|
)
|
|
if value is not None
|
|
}
|
|
if not fields:
|
|
emit(profile_lines(build_view(now_local().date()), now_local().date())[0])
|
|
return 0
|
|
set_profile(**fields)
|
|
emit("profile saved.")
|
|
_publish(emit)
|
|
return 0
|
|
|
|
|
|
def cmd_plan_week(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
|
"""Print the week plan."""
|
|
log = load_log()
|
|
plan = plan_week(
|
|
now_local().date(),
|
|
args.target or budget_or_default(),
|
|
{day: day_total_kcal(log, day) for day in log},
|
|
dict(args.day),
|
|
)
|
|
for line in plan_lines(plan):
|
|
emit(line)
|
|
return 0
|