diet-guard/diet_guard/_cli_body.py
Krzysztof kuhy Rudnicki e761926df4
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
Body tab: weight/body-fat logs, BMI, ideal weight, goal-based kcal target, workouts + Health Connect steps, week planner
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 09:03:03 +02:00

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