""" Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53). Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter. """ from __future__ import annotations from datetime import date, datetime, timedelta from typing import Any, Dict, List, Optional from db import get_db, get_cursor, r2d from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows from data_layer.nutrition_interpretation import ( build_energy_availability_kpi_tile, build_macro_donut_from_averages, build_nutrition_correlation_heuristic_items, build_nutrition_history_kpi_tiles, ) from data_layer.nutrition_chart_payloads import ( build_energy_balance_chart_payload, build_nutrition_adherence_score_payload, build_protein_adequacy_chart_payload, ) from data_layer.nutrition_metrics import ( estimate_tdee_kcal_from_latest_weight, get_energy_availability_warning_payload, get_energy_balance_data, get_nutrition_average_data, get_protein_targets_data, get_weekly_macro_distribution_chart_data, ) from data_layer.utils import safe_float def _cutoff_sql(days: int) -> Optional[str]: if days >= 9999: return None return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") def _iso(d: Any) -> Optional[str]: if d is None: return None if hasattr(d, "isoformat"): return d.isoformat()[:10] return str(d)[:10] def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] for i, d in enumerate(rows): sl = rows[max(0, i - window + 1) : i + 1] vals: List[float] = [] for x in sl: v = safe_float(x.get(key)) if v is not None: vals.append(v) if not vals: out.append({**d, f"{key}_avg": None}) continue avg = round(sum(vals) / len(vals), 1) out.append({**d, f"{key}_avg": avg}) return out def _has_nutrition_entries(profile_id: str) -> bool: with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1", (profile_id,), ) return cur.fetchone() is not None def _last_nutrition_date(profile_id: str) -> Optional[str]: with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s", (profile_id,), ) row = cur.fetchone() if not row or row["d"] is None: return None return _iso(row["d"]) def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]: with get_db() as conn: cur = get_cursor(conn) if cutoff: cur.execute( """SELECT date, COALESCE(SUM(kcal), 0)::float AS kcal, COALESCE(SUM(protein_g), 0)::float AS protein_g, COALESCE(SUM(carbs_g), 0)::float AS carbs_g, COALESCE(SUM(fat_g), 0)::float AS fat_g FROM nutrition_log WHERE profile_id=%s AND date >= %s GROUP BY date ORDER BY date ASC""", (profile_id, cutoff), ) else: cur.execute( """SELECT date, COALESCE(SUM(kcal), 0)::float AS kcal, COALESCE(SUM(protein_g), 0)::float AS protein_g, COALESCE(SUM(carbs_g), 0)::float AS carbs_g, COALESCE(SUM(fat_g), 0)::float AS fat_g FROM nutrition_log WHERE profile_id=%s GROUP BY date ORDER BY date ASC""", (profile_id,), ) return [r2d(r) for r in cur.fetchall()] def _filter_merged_rows_by_cutoff( merged: List[Dict[str, Any]], cutoff: Optional[str] ) -> List[Dict[str, Any]]: if not cutoff: return list(merged) return [r for r in merged if str(r.get("date"))[:10] >= cutoff] def _calorie_balance_daily_series( merged_filtered: List[Dict[str, Any]], tdee: float ) -> List[Dict[str, Any]]: """Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight.""" rows: List[Dict[str, Any]] = [] for r in merged_filtered: if r.get("kcal") is None: continue ds = _iso(r.get("date")) if not ds: continue bal = round(float(r["kcal"]) - float(tdee)) rows.append({"date": ds, "balance_kcal": bal}) rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7) out: List[Dict[str, Any]] = [] for x in rolled: out.append( { "date": x["date"], "balance_kcal": x.get("balance_kcal"), "balance_kcal_avg": x.get("balance_kcal_avg"), } ) return out def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] for r in merged_filtered: if r.get("protein_g") is None or r.get("lean_mass") is None: continue ds = _iso(r.get("date")) if not ds: continue out.append( { "date": ds, "protein_g": round(safe_float(r.get("protein_g")) or 0, 1), "lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2), } ) return out def _kcal_weight_points_for_window( profile_id: str, cutoff: Optional[str] ) -> List[Dict[str, Any]]: """Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert.""" with get_db() as conn: cur = get_cursor(conn) if cutoff: cur.execute( """SELECT date, SUM(kcal)::float AS kcal FROM nutrition_log WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL GROUP BY date""", (profile_id, cutoff), ) else: cur.execute( """SELECT date, SUM(kcal)::float AS kcal FROM nutrition_log WHERE profile_id=%s AND kcal IS NOT NULL GROUP BY date""", (profile_id,), ) nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() } if cutoff: cur.execute( "SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date", (profile_id, cutoff), ) else: cur.execute( "SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,), ) wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None } common = sorted(set(nk) & set(wk)) raw: List[Dict[str, Any]] = [] for ds in common: raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]}) rolled = _rolling_avg(raw, "kcal", 7) out: List[Dict[str, Any]] = [] for r in rolled: out.append( { "date": r["date"], "kcal": r.get("kcal"), "weight": r.get("weight"), "kcal_avg": r.get("kcal_avg"), } ) return out def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: """ Layer 2b Bundle für Verlauf «Ernährung». days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen). """ if not _has_nutrition_entries(profile_id): return { "confidence": "insufficient", "has_nutrition_entries": False, "message": "Noch keine Ernährungsdaten", "kpi_tiles": [], "summary": {}, "daily_macros": [], "donut_avg_pct": None, "kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0}, "weekly_macro_chart": {}, "tdee_reference_kcal": None, "energy_balance_meta": {}, "interpretation_tiles": [], "energy_availability_warning": None, "calorie_balance_daily": [], "protein_vs_lean_mass": {"points": [], "protein_target_low_g": None}, "nutrition_correlation_heuristics": [], "chart_payloads": {}, "chart_payloads_days": None, "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, } all_history = days >= 9999 eff_days = 3650 if all_history else max(7, min(int(days), 3650)) cutoff = _cutoff_sql(days) chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history) targets = get_protein_targets_data(profile_id) energy_days = eff_days if not all_history else min(9999, 3650) energy_meta = get_energy_balance_data(profile_id, energy_days) tdee = estimate_tdee_kcal_from_latest_weight(profile_id) if tdee is None: tdee = safe_float(energy_meta.get("estimated_tdee")) or None else: tdee = float(tdee) daily_rows = _fetch_daily_macro_totals(profile_id, cutoff) daily_macros: List[Dict[str, Any]] = [] for r in daily_rows: daily_macros.append( { "date": _iso(r["date"]), "kcal": round(safe_float(r.get("kcal")) or 0), "Protein": round(safe_float(r.get("protein_g")) or 0), "KH": round(safe_float(r.get("carbs_g")) or 0), "Fett": round(safe_float(r.get("fat_g")) or 0), } ) date_span_label = "" if daily_macros: date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}" n_days = int(navg.get("data_points") or 0) kpi_tiles = build_nutrition_history_kpi_tiles( navg, targets, date_span_label or "—", max(1, n_days) ) ea_days = min(28, max(7, chart_days_for_pipeline)) ea_payload = get_energy_availability_warning_payload(profile_id, ea_days) ea_tile = build_energy_availability_kpi_tile(ea_payload) kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles) if ea_tile: kpi_tiles_out.append(ea_tile) donut = build_macro_donut_from_averages(navg) kw_points = _kcal_weight_points_for_window(profile_id, cutoff) pt_low = round(float(targets.get("protein_target_low") or 0)) merged_all = build_merged_daily_nutrition_body_rows(profile_id) merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff) tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0) calorie_balance_daily: List[Dict[str, Any]] = ( _calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else [] ) pl_points = _protein_lean_mass_points(merged_win) nutrition_correlation_heuristics = ( build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low)) if tdee_eff > 0 else [] ) weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7)) weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly) # E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf) days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90)) chart_payloads = { "energy_balance": build_energy_balance_chart_payload( profile_id, days_for_embedded_charts ), "protein_adequacy": build_protein_adequacy_chart_payload( profile_id, days_for_embedded_charts ), "nutrition_adherence": build_nutrition_adherence_score_payload( profile_id, days_for_embedded_charts ), } conf = navg.get("confidence") or "medium" if targets.get("confidence") == "insufficient": conf = "insufficient" return { "confidence": conf, "has_nutrition_entries": True, "days_requested": days, "effective_window_days": eff_days, "nutrition_charts_days": chart_days_for_pipeline, "weekly_macro_weeks_used": weeks_for_weekly, "last_updated": _last_nutrition_date(profile_id), "summary": { "kcal_avg": navg.get("kcal_avg"), "protein_avg": navg.get("protein_avg"), "carbs_avg": navg.get("carbs_avg"), "fat_avg": navg.get("fat_avg"), "data_points": navg.get("data_points"), "days_analyzed": navg.get("days_analyzed"), "protein_target_low": targets.get("protein_target_low"), "protein_target_high": targets.get("protein_target_high"), "reference_weight_kg": targets.get("current_weight"), }, "kpi_tiles": kpi_tiles_out, "interpretation_tiles": [], "energy_availability_warning": ea_payload, "daily_macros": daily_macros, "donut_avg_pct": donut, "protein_reference_line_g": pt_low, "kcal_vs_weight": { "points": kw_points, "tdee_reference_kcal": tdee, "common_days_count": len(kw_points), }, "weekly_macro_chart": weekly_chart, "tdee_reference_kcal": tdee, "energy_balance_meta": { "energy_balance": energy_meta.get("energy_balance"), "avg_intake": energy_meta.get("avg_intake"), "estimated_tdee": energy_meta.get("estimated_tdee"), "status": energy_meta.get("status"), "confidence": energy_meta.get("confidence"), "data_points": energy_meta.get("data_points"), }, "calorie_balance_daily": calorie_balance_daily, "protein_vs_lean_mass": { "points": pl_points, "protein_target_low_g": pt_low if pt_low > 0 else None, }, "nutrition_correlation_heuristics": nutrition_correlation_heuristics, "chart_payloads": chart_payloads, "chart_payloads_days": days_for_embedded_charts, "meta": { "layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz", "issue": "53-phase-0c", }, }