Files
lifetrack/apps/api/app/modules/health/stats.py
T
MeeJayandClaude Opus 5 fa3db7ff22 Fix 20+ defects found by real Docker/PostgreSQL deployment
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>
2026-08-14 11:40:05 +02:00

961 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
)