Première exécution réelle de la stack (build des images, PostgreSQL 16, parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm, et 181 assertions rejouées après correction. Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était recalculé après le plancher calorique ; « dernière pesée » affichait deux valeurs différentes selon l'endpoint ; objectif protéines absent. Vape : durée de vie moyenne des résistances incluait la résistance en cours ; archiver la recette active la laissait active ; coût théorique inventé avant la date d'arrêt ; économies projetées dans le futur ; €/ml arrondi à 2 décimales écrasait le modèle de coût DIY. Finances : le sankey compensait crédits et débits non catégorisés ; rows_total excluait les lignes filtrées, faussant l'arithmétique du rapport d'import. Socle : les erreurs HTTP du framework fuitaient en anglais dans l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON français ; fins de ligne normalisées en LF. 348 tests pytest (+7), ruff, tsc et vite build au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
961 lines
35 KiB
Python
961 lines
35 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 collections import Counter
|
||
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)
|
||
plan_end = plan_end_date(goal) if goal is not None else None
|
||
|
||
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
|
||
)
|
||
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
|
||
)
|
||
# « Dernière pesée » must be the most recent weigh-in, like /health/dashboard
|
||
# — NOT raw_all[-1], which is the FIRST weigh-in of the most recent day (§5.5
|
||
# governs the series, not this KPI).
|
||
last_entry = service.latest_weight(db, user_id, tz, until=end)
|
||
meta = {
|
||
"trend_now_kg": _round(trend_now, 2),
|
||
"last_weight_kg": _round(float(last_entry.weight_kg), 2)
|
||
if last_entry is not None
|
||
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) if plan_end is not None 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),
|
||
)
|
||
methods = [m.tdee_method for m in models if m.tdee_method is not None]
|
||
meta = {
|
||
"tdee_methods": {
|
||
_iso(m.day): m.tdee_method for m in models if m.tdee_method is not None
|
||
},
|
||
# How the TDEE average was obtained (dominant method of the window) and
|
||
# the two figures the UI needs to spell it out ("BMR 1 750 × 1,55").
|
||
"tdee_mode": Counter(methods).most_common(1)[0][0] if methods else None,
|
||
"bmr_kcal": _round(
|
||
next((m.bmr_kcal for m in reversed(models) if m.bmr_kcal is not None), None)
|
||
),
|
||
"activity_factor": calc.ACTIVITY_FACTORS[profile.activity_level.value]
|
||
if profile is not None
|
||
else 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)
|
||
)
|
||
trend = calc.weight_trend(service.daily_weights(db, user_id, tz, until=day))
|
||
return NutritionDayDetail(
|
||
date=day,
|
||
totals=totals,
|
||
water_ml=water,
|
||
water_goal_ml=profile.water_goal_ml if profile else None,
|
||
protein_goal_g=calc.protein_goal_g(calc.trend_at_day(trend, day)),
|
||
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 = service.latest_weight(db, user_id, tz)
|
||
|
||
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),
|
||
)
|