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