Files
lifetrack/apps/api/app/modules/health/stats.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

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),
)