Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
946 lines
34 KiB
Python
946 lines
34 KiB
Python
"""Chart-ready aggregations for the health module.
|
|
|
|
Every `stats/*` response follows the contract of datamodel-health-vape.md §8.1:
|
|
{from, to, unit, series:[{name, type, points:[[date, value|null]]}], meta}.
|
|
Points are ready for `dataset.source` in ECharts; series names are stable
|
|
identifiers (the UI maps them to French labels).
|
|
"""
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.health import calculations as calc
|
|
from app.modules.health import service
|
|
from app.modules.health.models import Goal, HealthProfile, MealType, ScheduleKind
|
|
from app.modules.health.schemas import (
|
|
AdherenceDay,
|
|
AdherenceKind,
|
|
AdherenceResponse,
|
|
BudgetRead,
|
|
DashboardResponse,
|
|
GoalRead,
|
|
MealTotals,
|
|
NutritionDayDetail,
|
|
NutritionDayRead,
|
|
ProjectionRead,
|
|
Series,
|
|
StatsResponse,
|
|
StreakRead,
|
|
TodayItem,
|
|
TodayResponse,
|
|
)
|
|
|
|
STREAK_WINDOW_DAYS = 365
|
|
MACRO_KCAL = {"protein_g": 4.0, "carbs_g": 4.0, "fat_g": 9.0}
|
|
|
|
# Calendar-heatmap encoding of an adherence day. `None` (rest) keeps the cell
|
|
# empty in ECharts; the UI colours 0/1/2 via visualMap pieces.
|
|
ADHERENCE_STATUS_CODES: dict[str, int | None] = {
|
|
"rest": None,
|
|
"missed": 0,
|
|
"done_unplanned": 1,
|
|
"done": 2,
|
|
}
|
|
|
|
|
|
def _iso(day: dt.date) -> str:
|
|
return day.isoformat()
|
|
|
|
|
|
def _round(value: float | None, digits: int = 1) -> float | None:
|
|
return None if value is None else round(value, digits)
|
|
|
|
|
|
# --- Daily energy model -------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class DailyModel:
|
|
day: dt.date
|
|
trend_weight_kg: float | None = None
|
|
bmr_kcal: float | None = None
|
|
tdee_kcal: float | None = None
|
|
tdee_method: str | None = None
|
|
tdee_smoothed_kcal: float | None = None
|
|
intake_kcal: float | None = None
|
|
budget_kcal: float | None = None
|
|
deficit_target_kcal: float | None = None
|
|
balance_kcal: float | None = None
|
|
steps: int | None = None
|
|
active_kcal: float | None = None
|
|
total_kcal: float | None = None
|
|
distance_m: int | None = None
|
|
floor_applied: bool = False
|
|
rate_clamped: bool = False
|
|
|
|
|
|
def build_daily_models(
|
|
db: Session,
|
|
user_id: int,
|
|
start: dt.date,
|
|
end: dt.date,
|
|
tz: ZoneInfo,
|
|
profile: HealthProfile | None = None,
|
|
goal: Goal | None = None,
|
|
) -> list[DailyModel]:
|
|
"""One model per local day of [start, end], TDEE smoothed over 7 days."""
|
|
if profile is None:
|
|
profile = service.get_profile(db, user_id)
|
|
if goal is None:
|
|
goal = service.active_goal(db, user_id)
|
|
warmup = start - dt.timedelta(days=calc.TDEE_SMOOTHING_DAYS - 1)
|
|
days = calc.date_range(warmup, end)
|
|
trend = calc.weight_trend(service.daily_weights(db, user_id, tz, until=end))
|
|
activity = service.merged_activity(db, user_id, warmup, end)
|
|
intake = service.intake_by_day(db, user_id, warmup, end, tz)
|
|
|
|
models: list[DailyModel] = []
|
|
for day in days:
|
|
model = DailyModel(day=day)
|
|
merged = activity.get(day)
|
|
if merged is not None:
|
|
model.steps = merged.steps
|
|
model.active_kcal = merged.active_kcal
|
|
model.total_kcal = merged.total_kcal
|
|
model.distance_m = merged.distance_m
|
|
model.trend_weight_kg = calc.trend_at_day(trend, day)
|
|
if profile is not None and model.trend_weight_kg is not None:
|
|
model.bmr_kcal = calc.bmr_mifflin(
|
|
model.trend_weight_kg,
|
|
float(profile.height_cm),
|
|
calc.age_on(day, profile.birthdate),
|
|
profile.sex.value,
|
|
)
|
|
tdee = calc.tdee_effective(
|
|
model.bmr_kcal,
|
|
profile.activity_level.value,
|
|
total_kcal=model.total_kcal,
|
|
active_kcal=model.active_kcal,
|
|
)
|
|
model.tdee_kcal = tdee.kcal
|
|
model.tdee_method = tdee.method
|
|
bucket = intake.get(day)
|
|
model.intake_kcal = bucket["kcal"] if bucket else None
|
|
models.append(model)
|
|
|
|
smoothed = calc.moving_average(
|
|
[m.tdee_kcal for m in models], calc.TDEE_SMOOTHING_DAYS
|
|
)
|
|
for model, value in zip(models, smoothed, strict=True):
|
|
model.tdee_smoothed_kcal = value
|
|
model.balance_kcal = calc.energy_balance(model.intake_kcal, model.tdee_kcal)
|
|
if profile is None or value is None:
|
|
continue
|
|
rate = calc.resolve_goal_rate(
|
|
mode=goal.mode.value if goal else "maintain",
|
|
day=model.day,
|
|
trend_now=model.trend_weight_kg or 0.0,
|
|
target_weight_kg=float(goal.target_weight_kg) if goal else 0.0,
|
|
weekly_rate_kg=float(goal.weekly_rate_kg)
|
|
if goal and goal.weekly_rate_kg is not None
|
|
else None,
|
|
target_date=goal.target_date if goal else None,
|
|
)
|
|
budget = calc.daily_budget(
|
|
value, rate, profile.sex.value, profile.calorie_floor_kcal
|
|
)
|
|
model.budget_kcal = budget.kcal
|
|
model.deficit_target_kcal = budget.deficit_target
|
|
model.floor_applied = budget.floor_applied
|
|
model.rate_clamped = budget.rate_clamped
|
|
return [m for m in models if m.day >= start]
|
|
|
|
|
|
def plan_end_date(goal: Goal) -> dt.date | None:
|
|
"""Day the PLAN reaches the target weight (§5.6b) — never the chart bound.
|
|
|
|
`target_date` goals carry it; `weekly_rate` goals derive it from the rate
|
|
(`days = delta / (weekly_rate_kg / 7)`). Returns None when the plan cannot
|
|
converge (maintain, zero rate, or a rate pushing away from the target).
|
|
"""
|
|
if goal.target_date is not None:
|
|
return goal.target_date
|
|
rate = float(goal.weekly_rate_kg) if goal.weekly_rate_kg is not None else 0.0
|
|
delta = float(goal.start_weight_kg) - float(goal.target_weight_kg)
|
|
if rate == 0.0 or delta == 0.0 or delta / rate <= 0:
|
|
return None
|
|
return goal.start_date + dt.timedelta(days=round(delta / rate * 7))
|
|
|
|
|
|
# --- Weight -------------------------------------------------------------------
|
|
|
|
|
|
def weight_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
raw_all = service.daily_weights(db, user_id, tz, until=end)
|
|
trend_all = calc.weight_trend(raw_all)
|
|
raw = [(d, w) for d, w in raw_all if start <= d <= end]
|
|
trend = [(d, w) for d, w in trend_all if start <= d <= end]
|
|
goal = service.active_goal(db, user_id)
|
|
|
|
series = [
|
|
Series(
|
|
name="weight_raw",
|
|
type="scatter",
|
|
points=[[_iso(d), w] for d, w in raw],
|
|
),
|
|
Series(
|
|
name="weight_trend",
|
|
type="line",
|
|
points=[[_iso(d), w] for d, w in trend],
|
|
),
|
|
]
|
|
|
|
slope_14 = calc.regression_slope(
|
|
[p for p in trend_all if p[0] > end - dt.timedelta(days=14)]
|
|
)
|
|
slope_30 = calc.regression_slope(
|
|
[p for p in trend_all if p[0] > end - dt.timedelta(days=30)]
|
|
)
|
|
trend_now = trend_all[-1][1] if trend_all else None
|
|
|
|
projection = calc.Projection(status="not_converging")
|
|
if trend_now is not None and goal is not None:
|
|
projection = calc.project_target_date(
|
|
trend_now, float(goal.target_weight_kg), slope_30 or slope_14, end
|
|
)
|
|
plan_end = plan_end_date(goal)
|
|
if plan_end is not None:
|
|
series.append(
|
|
Series(
|
|
name="plan_line",
|
|
type="line",
|
|
points=[
|
|
[_iso(goal.start_date), float(goal.start_weight_kg)],
|
|
[_iso(plan_end), float(goal.target_weight_kg)],
|
|
],
|
|
)
|
|
)
|
|
if projection.status == "ok" and projection.date is not None:
|
|
series.append(
|
|
Series(
|
|
name="projection",
|
|
type="line",
|
|
points=[
|
|
[_iso(trend_all[-1][0]), trend_now],
|
|
[_iso(projection.date), float(goal.target_weight_kg)],
|
|
],
|
|
)
|
|
)
|
|
|
|
profile = service.get_profile(db, user_id)
|
|
bmi = (
|
|
calc.bmi(trend_now, float(profile.height_cm))
|
|
if trend_now is not None and profile is not None
|
|
else None
|
|
)
|
|
meta = {
|
|
"trend_now_kg": _round(trend_now, 2),
|
|
"last_weight_kg": _round(raw_all[-1][1], 2) if raw_all else None,
|
|
"slope_14d_kg_day": _round(slope_14, 4),
|
|
"slope_14d_kg_week": _round(slope_14 * 7, 3) if slope_14 is not None else None,
|
|
"slope_30d_kg_day": _round(slope_30, 4),
|
|
"slope_30d_kg_week": _round(slope_30 * 7, 3) if slope_30 is not None else None,
|
|
"total_change_kg": _round(trend[-1][1] - trend[0][1], 2)
|
|
if len(trend) > 1
|
|
else None,
|
|
"bmi": _round(bmi, 1),
|
|
"target_weight_kg": float(goal.target_weight_kg) if goal else None,
|
|
"plan_end_date": _iso(plan_end_date(goal))
|
|
if goal and plan_end_date(goal)
|
|
else None,
|
|
"projection": {
|
|
"status": projection.status,
|
|
"date": _iso(projection.date) if projection.date else None,
|
|
},
|
|
"count": len(raw),
|
|
}
|
|
return StatsResponse(from_=start, to=end, unit="kg", series=series, meta=meta)
|
|
|
|
|
|
def measurement_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
from app.modules.health.schemas import MEASUREMENT_SITES
|
|
|
|
rows = service.measurements_between(db, user_id, start, end, tz)
|
|
series: list[Series] = []
|
|
for site in MEASUREMENT_SITES:
|
|
points = [
|
|
[_iso(service.local_day_of(row.measured_at, tz)), float(getattr(row, site))]
|
|
for row in rows
|
|
if getattr(row, site) is not None
|
|
]
|
|
if points:
|
|
series.append(Series(name=site, type="line", points=points))
|
|
|
|
profile = service.get_profile(db, user_id)
|
|
navy_points: list[list] = []
|
|
if profile is not None:
|
|
for row in rows:
|
|
value = calc.navy_body_fat_pct(
|
|
profile.sex.value,
|
|
float(profile.height_cm),
|
|
float(row.waist_cm) if row.waist_cm is not None else None,
|
|
float(row.neck_cm) if row.neck_cm is not None else None,
|
|
float(row.hips_cm) if row.hips_cm is not None else None,
|
|
)
|
|
if value is not None:
|
|
navy_points.append(
|
|
[_iso(service.local_day_of(row.measured_at, tz)), round(value, 1)]
|
|
)
|
|
if navy_points:
|
|
series.append(Series(name="body_fat_navy_pct", type="line", points=navy_points))
|
|
return StatsResponse(
|
|
from_=start,
|
|
to=end,
|
|
unit="cm",
|
|
series=series,
|
|
meta={"count": len(rows), "sites": [s.name for s in series]},
|
|
)
|
|
|
|
|
|
# --- Activity -----------------------------------------------------------------
|
|
|
|
|
|
def activity_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
merged = service.merged_activity(db, user_id, start, end)
|
|
days = calc.date_range(start, end)
|
|
fields = ("steps", "active_kcal", "total_kcal", "distance_m")
|
|
series: list[Series] = []
|
|
meta: dict = {}
|
|
for field in fields:
|
|
values: list[float | None] = []
|
|
for day in days:
|
|
row = merged.get(day)
|
|
value = getattr(row, field) if row else None
|
|
values.append(float(value) if value is not None else None)
|
|
series.append(
|
|
Series(
|
|
name=field,
|
|
type="bar" if field in {"steps", "active_kcal"} else "line",
|
|
points=[[_iso(d), v] for d, v in zip(days, values, strict=True)],
|
|
)
|
|
)
|
|
ma = calc.moving_average(values, 7)
|
|
series.append(
|
|
Series(
|
|
name=f"{field}_ma7",
|
|
type="line",
|
|
points=[[_iso(d), _round(v)] for d, v in zip(days, ma, strict=True)],
|
|
)
|
|
)
|
|
tracked = [v for v in values if v is not None]
|
|
meta[f"avg_{field}"] = _round(sum(tracked) / len(tracked)) if tracked else None
|
|
meta[f"total_{field}"] = _round(sum(tracked)) if tracked else None
|
|
meta["tracked_days"] = len(merged)
|
|
meta["days"] = len(days)
|
|
return StatsResponse(from_=start, to=end, unit="mixed", series=series, meta=meta)
|
|
|
|
|
|
def workout_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
workouts = service.workouts_between(db, user_id, start, end, tz)
|
|
weekly: dict[dt.date, dict[str, float]] = {}
|
|
by_sport: dict[str, dict[str, float]] = {}
|
|
for workout in workouts:
|
|
day = service.local_day_of(workout.started_at, tz)
|
|
week_start = day - dt.timedelta(days=day.weekday())
|
|
bucket = weekly.setdefault(
|
|
week_start,
|
|
{
|
|
"sessions_count": 0.0,
|
|
"total_kcal": 0.0,
|
|
"total_distance_m": 0.0,
|
|
"total_duration_min": 0.0,
|
|
},
|
|
)
|
|
bucket["sessions_count"] += 1
|
|
bucket["total_kcal"] += float(workout.kcal or 0)
|
|
bucket["total_distance_m"] += float(workout.distance_m or 0)
|
|
bucket["total_duration_min"] += service.workout_duration_s(workout) / 60
|
|
sport = workout.sport_type.value
|
|
sbucket = by_sport.setdefault(
|
|
sport, {"sessions": 0.0, "duration_min": 0.0, "kcal": 0.0}
|
|
)
|
|
sbucket["sessions"] += 1
|
|
sbucket["duration_min"] += service.workout_duration_s(workout) / 60
|
|
sbucket["kcal"] += float(workout.kcal or 0)
|
|
|
|
weeks = sorted(weekly)
|
|
series = [
|
|
Series(
|
|
name=name,
|
|
type="bar",
|
|
points=[[_iso(week), _round(weekly[week][name])] for week in weeks],
|
|
)
|
|
for name in (
|
|
"sessions_count",
|
|
"total_kcal",
|
|
"total_distance_m",
|
|
"total_duration_min",
|
|
)
|
|
]
|
|
series.append(
|
|
Series(
|
|
name="by_sport_duration_min",
|
|
type="pie",
|
|
points=[
|
|
[sport, _round(values["duration_min"])]
|
|
for sport, values in sorted(
|
|
by_sport.items(),
|
|
key=lambda item: item[1]["duration_min"],
|
|
reverse=True,
|
|
)
|
|
],
|
|
)
|
|
)
|
|
total_duration = sum(service.workout_duration_s(w) for w in workouts) / 60
|
|
meta = {
|
|
"sessions": len(workouts),
|
|
"total_duration_min": _round(total_duration),
|
|
"total_kcal": _round(sum(float(w.kcal or 0) for w in workouts)),
|
|
"total_distance_m": _round(sum(float(w.distance_m or 0) for w in workouts)),
|
|
"by_sport": by_sport,
|
|
}
|
|
return StatsResponse(from_=start, to=end, unit="mixed", series=series, meta=meta)
|
|
|
|
|
|
# --- Energy balance -----------------------------------------------------------
|
|
|
|
|
|
def energy_balance_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
profile = service.get_profile(db, user_id)
|
|
models = build_daily_models(db, user_id, start, end, tz, profile=profile)
|
|
days = [m.day for m in models]
|
|
|
|
cumulative: list[float | None] = []
|
|
running = 0.0
|
|
for model in models:
|
|
if model.balance_kcal is not None:
|
|
running += model.balance_kcal
|
|
cumulative.append(round(running, 1))
|
|
|
|
series = [
|
|
Series(
|
|
name="intake_kcal",
|
|
type="bar",
|
|
points=[[_iso(m.day), _round(m.intake_kcal)] for m in models],
|
|
),
|
|
Series(
|
|
name="tdee_kcal",
|
|
type="line",
|
|
points=[[_iso(m.day), _round(m.tdee_kcal)] for m in models],
|
|
),
|
|
Series(
|
|
name="balance_kcal",
|
|
type="bar",
|
|
points=[[_iso(m.day), _round(m.balance_kcal)] for m in models],
|
|
),
|
|
Series(
|
|
name="budget_kcal",
|
|
type="line",
|
|
points=[[_iso(m.day), _round(m.budget_kcal)] for m in models],
|
|
),
|
|
Series(
|
|
name="cumulative_balance_kcal",
|
|
type="line",
|
|
points=[[_iso(d), v] for d, v in zip(days, cumulative, strict=True)],
|
|
),
|
|
]
|
|
|
|
tracked = [
|
|
(m.intake_kcal, m.tdee_kcal)
|
|
for m in models
|
|
if m.intake_kcal is not None and m.tdee_kcal is not None
|
|
]
|
|
trend_all = calc.weight_trend(service.daily_weights(db, user_id, tz, until=end))
|
|
calibration = calc.tdee_calibration(
|
|
tracked,
|
|
calc.trend_at_day(trend_all, start),
|
|
calc.trend_at_day(trend_all, end),
|
|
)
|
|
meta = {
|
|
"tdee_methods": {
|
|
_iso(m.day): m.tdee_method for m in models if m.tdee_method is not None
|
|
},
|
|
"cumulative_balance_kcal": _round(running),
|
|
"cumulative_kg_equivalent": _round(running / calc.KCAL_PER_KG_FAT, 2),
|
|
# Daily deficit aimed at by the active goal on the last day of the range
|
|
# (the UI draws it as the target line of the balance chart).
|
|
"deficit_target_kcal": _round(models[-1].deficit_target_kcal)
|
|
if models
|
|
else None,
|
|
"tracked_days": calibration.tracked_days,
|
|
"days": len(models),
|
|
"profile_missing": profile is None,
|
|
"calibration_status": calibration.status,
|
|
"expected_change_kg": _round(calibration.expected_change_kg, 2),
|
|
"actual_change_kg": _round(calibration.actual_change_kg, 2),
|
|
"gap_kg": _round(calibration.gap_kg, 2),
|
|
"tdee_adaptive_kcal": _round(calibration.tdee_adaptive_kcal),
|
|
"tdee_correction_kcal": _round(calibration.tdee_correction_kcal),
|
|
}
|
|
return StatsResponse(from_=start, to=end, unit="kcal", series=series, meta=meta)
|
|
|
|
|
|
# --- Nutrition ----------------------------------------------------------------
|
|
|
|
|
|
def nutrition_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
models = build_daily_models(db, user_id, start, end, tz)
|
|
budgets = {m.day: m.budget_kcal for m in models}
|
|
totals = service.intake_by_day(db, user_id, start, end, tz)
|
|
meals = service.meal_kcal_by_day(db, user_id, start, end, tz)
|
|
days = calc.date_range(start, end)
|
|
|
|
series = [
|
|
Series(
|
|
name="kcal",
|
|
type="bar",
|
|
points=[
|
|
[_iso(d), _round(totals[d]["kcal"]) if d in totals else None]
|
|
for d in days
|
|
],
|
|
),
|
|
Series(
|
|
name="budget_kcal",
|
|
type="line",
|
|
points=[[_iso(d), _round(budgets.get(d))] for d in days],
|
|
),
|
|
]
|
|
for macro, factor in MACRO_KCAL.items():
|
|
series.append(
|
|
Series(
|
|
name=f"{macro}_kcal",
|
|
type="bar",
|
|
points=[
|
|
[
|
|
_iso(d),
|
|
_round(totals[d][macro] * factor) if d in totals else None,
|
|
]
|
|
for d in days
|
|
],
|
|
)
|
|
)
|
|
series.append(
|
|
Series(
|
|
name=macro,
|
|
type="bar",
|
|
points=[
|
|
[_iso(d), _round(totals[d][macro]) if d in totals else None]
|
|
for d in days
|
|
],
|
|
)
|
|
)
|
|
for meal in MealType:
|
|
series.append(
|
|
Series(
|
|
name=f"meal_{meal.value}_kcal",
|
|
type="bar",
|
|
points=[
|
|
[
|
|
_iso(d),
|
|
_round(meals[d][meal.value]) if d in meals else None,
|
|
]
|
|
for d in days
|
|
],
|
|
)
|
|
)
|
|
ranked = service.top_foods(db, user_id, start, end, tz)
|
|
series.append(
|
|
Series(
|
|
name="top_foods_kcal",
|
|
type="bar",
|
|
points=[[item["name"], _round(item["kcal"])] for item in ranked],
|
|
)
|
|
)
|
|
|
|
tracked = [totals[d] for d in days if d in totals]
|
|
tracked_kcal = [t["kcal"] for t in tracked]
|
|
macro_totals = {macro: sum(t[macro] for t in tracked) for macro in MACRO_KCAL}
|
|
macro_kcal_total = sum(macro_totals[m] * MACRO_KCAL[m] for m in MACRO_KCAL)
|
|
gaps = [
|
|
totals[d]["kcal"] - budgets[d]
|
|
for d in days
|
|
if d in totals and budgets.get(d) is not None
|
|
]
|
|
meta = {
|
|
"tracked_days": len(tracked),
|
|
"days": len(days),
|
|
"avg_kcal": _round(sum(tracked_kcal) / len(tracked_kcal))
|
|
if tracked_kcal
|
|
else None,
|
|
"avg_protein_g": _round(macro_totals["protein_g"] / len(tracked))
|
|
if tracked
|
|
else None,
|
|
"avg_carbs_g": _round(macro_totals["carbs_g"] / len(tracked))
|
|
if tracked
|
|
else None,
|
|
"avg_fat_g": _round(macro_totals["fat_g"] / len(tracked)) if tracked else None,
|
|
"macro_split_pct": {
|
|
macro: _round(
|
|
100 * macro_totals[macro] * MACRO_KCAL[macro] / macro_kcal_total
|
|
)
|
|
for macro in MACRO_KCAL
|
|
}
|
|
if macro_kcal_total > 0
|
|
else {},
|
|
"days_in_budget": sum(1 for gap in gaps if gap <= 0),
|
|
"days_with_budget": len(gaps),
|
|
"avg_gap_kcal": _round(sum(gaps) / len(gaps)) if gaps else None,
|
|
# Rounded like every other kcal figure: raw float sums otherwise leak
|
|
# binary artefacts (3667.9000000000005) straight into the UI.
|
|
"top_foods": [{**item, "kcal": _round(item["kcal"])} for item in ranked],
|
|
}
|
|
return StatsResponse(from_=start, to=end, unit="kcal", series=series, meta=meta)
|
|
|
|
|
|
def nutrition_days(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> list[NutritionDayRead]:
|
|
models = build_daily_models(db, user_id, start, end, tz)
|
|
totals = service.intake_by_day(db, user_id, start, end, tz)
|
|
out: list[NutritionDayRead] = []
|
|
for model in models:
|
|
bucket = totals.get(model.day)
|
|
vs_budget = (
|
|
bucket["kcal"] - model.budget_kcal
|
|
if bucket is not None and model.budget_kcal is not None
|
|
else None
|
|
)
|
|
out.append(
|
|
NutritionDayRead(
|
|
date=model.day,
|
|
kcal=_round(bucket["kcal"]) if bucket else None,
|
|
protein_g=_round(bucket["protein_g"]) if bucket else None,
|
|
carbs_g=_round(bucket["carbs_g"]) if bucket else None,
|
|
fat_g=_round(bucket["fat_g"]) if bucket else None,
|
|
fiber_g=_round(bucket["fiber_g"]) if bucket else None,
|
|
budget_kcal=_round(model.budget_kcal),
|
|
vs_budget_kcal=_round(vs_budget),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def nutrition_day_detail(
|
|
db: Session, user_id: int, day: dt.date, tz: ZoneInfo
|
|
) -> NutritionDayDetail:
|
|
from app.modules.health.schemas import FoodEntryRead
|
|
|
|
entries = service.food_entries_between(db, user_id, day, day, tz)
|
|
meals: dict[str, MealTotals] = {
|
|
meal.value: MealTotals(meal=meal) for meal in MealType
|
|
}
|
|
for entry in entries:
|
|
bucket = meals[entry.meal.value]
|
|
bucket.kcal += float(entry.kcal)
|
|
bucket.protein_g += float(entry.protein_g or 0)
|
|
bucket.carbs_g += float(entry.carbs_g or 0)
|
|
bucket.fat_g += float(entry.fat_g or 0)
|
|
bucket.entries.append(FoodEntryRead.model_validate(entry))
|
|
for bucket in meals.values():
|
|
bucket.kcal = round(bucket.kcal, 1)
|
|
bucket.protein_g = round(bucket.protein_g, 1)
|
|
bucket.carbs_g = round(bucket.carbs_g, 1)
|
|
bucket.fat_g = round(bucket.fat_g, 1)
|
|
totals = nutrition_days(db, user_id, day, day, tz)[0]
|
|
profile = service.get_profile(db, user_id)
|
|
water = sum(
|
|
row.volume_ml for row in service.water_between(db, user_id, day, day, tz)
|
|
)
|
|
return NutritionDayDetail(
|
|
date=day,
|
|
totals=totals,
|
|
water_ml=water,
|
|
water_goal_ml=profile.water_goal_ml if profile else None,
|
|
meals=list(meals.values()),
|
|
)
|
|
|
|
|
|
def water_stats(
|
|
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
|
|
) -> StatsResponse:
|
|
rows = service.water_between(db, user_id, start, end, tz)
|
|
per_day: dict[dt.date, int] = {}
|
|
for row in rows:
|
|
day = service.local_day_of(row.drunk_at, tz)
|
|
per_day[day] = per_day.get(day, 0) + row.volume_ml
|
|
days = calc.date_range(start, end)
|
|
profile = service.get_profile(db, user_id)
|
|
goal_ml = profile.water_goal_ml if profile else None
|
|
series = [
|
|
Series(
|
|
name="volume_ml",
|
|
type="bar",
|
|
points=[[_iso(d), per_day.get(d, 0)] for d in days],
|
|
),
|
|
Series(
|
|
name="goal_ml",
|
|
type="line",
|
|
points=[[_iso(d), goal_ml] for d in days],
|
|
),
|
|
]
|
|
tracked = [v for v in per_day.values()]
|
|
meta = {
|
|
"goal_ml": goal_ml,
|
|
"avg_ml": _round(sum(tracked) / len(tracked)) if tracked else None,
|
|
"tracked_days": len(tracked),
|
|
"days_goal_reached": sum(1 for v in tracked if goal_ml and v >= goal_ml),
|
|
}
|
|
return StatsResponse(from_=start, to=end, unit="ml", series=series, meta=meta)
|
|
|
|
|
|
# --- Planning: today & adherence ----------------------------------------------
|
|
|
|
|
|
def today_view(db: Session, user_id: int, tz: ZoneInfo) -> TodayResponse:
|
|
today = service.today_local(tz)
|
|
start = today - dt.timedelta(days=STREAK_WINDOW_DAYS)
|
|
schedules = service.schedule_map(db, user_id)
|
|
done = service.done_days_by_kind(db, user_id, start, today, tz)
|
|
|
|
items: list[TodayItem] = []
|
|
streaks: dict[str, StreakRead] = {}
|
|
for kind in calc.SCHEDULE_KINDS:
|
|
schedule = schedules[kind]
|
|
days = calc.build_adherence(
|
|
start, today, schedule.weekdays, schedule.enabled, set(done[kind])
|
|
)
|
|
streak = calc.compute_streaks(days, today)
|
|
streaks[kind] = StreakRead(current=streak.current, best=streak.best)
|
|
items.append(
|
|
TodayItem(
|
|
kind=ScheduleKind(kind),
|
|
planned=calc.is_planned(schedule.weekdays, today, schedule.enabled),
|
|
done=today in done[kind],
|
|
value=_round(done[kind].get(today), 2),
|
|
)
|
|
)
|
|
return TodayResponse(date=today, items=items, streaks=streaks)
|
|
|
|
|
|
def adherence_stats(
|
|
db: Session,
|
|
user_id: int,
|
|
start: dt.date,
|
|
end: dt.date,
|
|
tz: ZoneInfo,
|
|
kind: str | None = None,
|
|
) -> AdherenceResponse:
|
|
today = service.today_local(tz)
|
|
schedules = service.schedule_map(db, user_id)
|
|
# Streaks look further back than the requested window on purpose.
|
|
streak_start = min(start, end - dt.timedelta(days=STREAK_WINDOW_DAYS))
|
|
done = service.done_days_by_kind(db, user_id, streak_start, end, tz)
|
|
|
|
kinds: list[AdherenceKind] = []
|
|
for name in calc.SCHEDULE_KINDS:
|
|
if kind and name != kind:
|
|
continue
|
|
schedule = schedules[name]
|
|
done_days = set(done[name])
|
|
window = calc.build_adherence(
|
|
start, end, schedule.weekdays, schedule.enabled, done_days
|
|
)
|
|
full = calc.build_adherence(
|
|
streak_start, end, schedule.weekdays, schedule.enabled, done_days
|
|
)
|
|
streak = calc.compute_streaks(full, today)
|
|
planned = [d for d in window if d.planned]
|
|
kinds.append(
|
|
AdherenceKind(
|
|
kind=ScheduleKind(name),
|
|
weekdays=list(schedule.weekdays or []),
|
|
enabled=schedule.enabled,
|
|
planned_days=len(planned),
|
|
done_days=sum(1 for d in planned if d.done),
|
|
missed_days=sum(1 for d in planned if d.missed),
|
|
adherence_pct=_round(calc.adherence_pct(window)),
|
|
streak=StreakRead(current=streak.current, best=streak.best),
|
|
days=[
|
|
AdherenceDay(
|
|
date=d.day, planned=d.planned, done=d.done, status=d.status
|
|
)
|
|
for d in window
|
|
],
|
|
)
|
|
)
|
|
|
|
# §8.1 envelope: one calendar-heatmap series per habit, points [day, code].
|
|
series = [
|
|
Series(
|
|
name=item.kind.value,
|
|
type="heatmap",
|
|
points=[
|
|
[_iso(d.date), ADHERENCE_STATUS_CODES[d.status]] for d in item.days
|
|
],
|
|
)
|
|
for item in kinds
|
|
]
|
|
meta = {
|
|
"status_codes": ADHERENCE_STATUS_CODES,
|
|
"kinds": {
|
|
item.kind.value: {
|
|
"enabled": item.enabled,
|
|
"weekdays": item.weekdays,
|
|
"planned_days": item.planned_days,
|
|
"done_days": item.done_days,
|
|
"missed_days": item.missed_days,
|
|
"adherence_pct": item.adherence_pct,
|
|
"streak_current": item.streak.current,
|
|
"streak_best": item.streak.best,
|
|
}
|
|
for item in kinds
|
|
},
|
|
}
|
|
return AdherenceResponse(
|
|
from_=start, to=end, unit="day", series=series, meta=meta, kinds=kinds
|
|
)
|
|
|
|
|
|
# --- Dashboard ----------------------------------------------------------------
|
|
|
|
|
|
def dashboard(db: Session, user_id: int, tz: ZoneInfo) -> DashboardResponse:
|
|
today = service.today_local(tz)
|
|
start = today - dt.timedelta(days=29)
|
|
profile = service.get_profile(db, user_id)
|
|
goal = service.active_goal(db, user_id)
|
|
models = build_daily_models(
|
|
db, user_id, start, today, tz, profile=profile, goal=goal
|
|
)
|
|
current = models[-1] if models else DailyModel(day=today)
|
|
|
|
raw_all = service.daily_weights(db, user_id, tz, until=today)
|
|
trend_all = calc.weight_trend(raw_all)
|
|
trend_now = trend_all[-1][1] if trend_all else None
|
|
trend_7d_ago = calc.trend_at_day(trend_all, today - dt.timedelta(days=7))
|
|
last_entry = db.scalar(
|
|
service.weights_query(user_id, None, None, tz, "-measured_at").limit(1)
|
|
)
|
|
|
|
cumulative = sum(m.balance_kcal for m in models if m.balance_kcal is not None)
|
|
week_start = today - dt.timedelta(days=today.weekday())
|
|
workouts_week = service.workouts_between(db, user_id, week_start, today, tz)
|
|
water_today = sum(
|
|
row.volume_ml for row in service.water_between(db, user_id, today, today, tz)
|
|
)
|
|
|
|
projection = None
|
|
if goal is not None and trend_now is not None:
|
|
slope = calc.regression_slope(
|
|
[p for p in trend_all if p[0] > today - dt.timedelta(days=30)]
|
|
) or calc.regression_slope(
|
|
[p for p in trend_all if p[0] > today - dt.timedelta(days=14)]
|
|
)
|
|
result = calc.project_target_date(
|
|
trend_now, float(goal.target_weight_kg), slope, today
|
|
)
|
|
projection = ProjectionRead(status=result.status, date=result.date)
|
|
|
|
remaining = (
|
|
current.budget_kcal - current.intake_kcal
|
|
if current.budget_kcal is not None and current.intake_kcal is not None
|
|
else current.budget_kcal
|
|
)
|
|
return DashboardResponse(
|
|
date=today,
|
|
weight_kg=_round(float(last_entry.weight_kg), 2) if last_entry else None,
|
|
weight_measured_at=last_entry.measured_at if last_entry else None,
|
|
trend_weight_kg=_round(trend_now, 2),
|
|
trend_delta_7d_kg=_round(trend_now - trend_7d_ago, 2)
|
|
if trend_now is not None and trend_7d_ago is not None
|
|
else None,
|
|
bmi=_round(calc.bmi(trend_now, float(profile.height_cm)), 1)
|
|
if trend_now is not None and profile is not None
|
|
else None,
|
|
intake_kcal=_round(current.intake_kcal),
|
|
budget_kcal=_round(current.budget_kcal),
|
|
remaining_kcal=_round(remaining),
|
|
tdee_kcal=_round(current.tdee_kcal),
|
|
tdee_method=current.tdee_method,
|
|
balance_kcal=_round(current.balance_kcal),
|
|
cumulative_balance_30d_kcal=_round(cumulative),
|
|
steps=current.steps,
|
|
active_kcal=_round(current.active_kcal),
|
|
distance_m=current.distance_m,
|
|
water_ml=water_today,
|
|
water_goal_ml=profile.water_goal_ml if profile else None,
|
|
workouts_this_week=len(workouts_week),
|
|
goal=GoalRead.model_validate(goal) if goal else None,
|
|
projection=projection,
|
|
weight_series=[[_iso(d), w] for d, w in trend_all if d >= start],
|
|
today=today_view(db, user_id, tz),
|
|
)
|
|
|
|
|
|
def active_goal_view(db: Session, user_id: int, tz: ZoneInfo):
|
|
"""Active goal + budget of the day + projection + progress (§8.2)."""
|
|
from app.modules.health.schemas import ActiveGoalRead
|
|
|
|
goal = service.active_goal(db, user_id)
|
|
if goal is None:
|
|
return ActiveGoalRead()
|
|
today = service.today_local(tz)
|
|
profile = service.get_profile(db, user_id)
|
|
models = build_daily_models(
|
|
db, user_id, today, today, tz, profile=profile, goal=goal
|
|
)
|
|
model = models[-1] if models else DailyModel(day=today)
|
|
trend_all = calc.weight_trend(service.daily_weights(db, user_id, tz, until=today))
|
|
trend_now = trend_all[-1][1] if trend_all else None
|
|
|
|
budget = None
|
|
if model.budget_kcal is not None and model.tdee_kcal is not None:
|
|
budget = BudgetRead(
|
|
kcal=_round(model.budget_kcal),
|
|
# The deficit AIMED AT by the goal (rate x 7700 / 7), not the one the
|
|
# floored budget happens to leave — §5.3 BudgetResult.deficit_target.
|
|
deficit_target_kcal=_round(model.deficit_target_kcal or 0.0),
|
|
floor_applied=model.floor_applied,
|
|
rate_clamped=model.rate_clamped,
|
|
tdee_kcal=_round(model.tdee_kcal),
|
|
tdee_method=model.tdee_method or "estimated",
|
|
)
|
|
projection = None
|
|
if trend_now is not None:
|
|
slope = calc.regression_slope(
|
|
[p for p in trend_all if p[0] > today - dt.timedelta(days=30)]
|
|
)
|
|
result = calc.project_target_date(
|
|
trend_now, float(goal.target_weight_kg), slope, today
|
|
)
|
|
projection = ProjectionRead(status=result.status, date=result.date)
|
|
|
|
start_w = float(goal.start_weight_kg)
|
|
target_w = float(goal.target_weight_kg)
|
|
done_kg = remaining_kg = progress = None
|
|
if trend_now is not None:
|
|
done_kg = start_w - trend_now
|
|
remaining_kg = trend_now - target_w
|
|
span = start_w - target_w
|
|
progress = 100 * done_kg / span if span else None
|
|
progress = None if progress is None else max(0.0, min(100.0, progress))
|
|
return ActiveGoalRead(
|
|
goal=GoalRead.model_validate(goal),
|
|
budget=budget,
|
|
projection=projection,
|
|
trend_weight_kg=_round(trend_now, 2),
|
|
done_kg=_round(done_kg, 2),
|
|
remaining_kg=_round(remaining_kg, 2),
|
|
progress_pct=_round(progress),
|
|
)
|