From 7ac9752c3d8f2c1877f93a3e26e11da0cd201ad9 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 13:45:28 +0200 Subject: [PATCH] feat: enhance nutrition data processing and visualization with new correlation insights - Refactored the `calculate_lag_correlation` function to normalize lag payloads and improve correlation calculations for various nutrition metrics. - Introduced a new function `build_nutrition_correlation_heuristic_items` to generate heuristic insights based on merged nutrition data, enhancing user understanding of dietary impacts on weight and body composition. - Updated the `get_nutrition_history_viz_bundle` function to include daily calorie balance and protein vs. lean mass data, providing a comprehensive view of nutrition trends. - Enhanced the frontend to visualize calorie balance and protein vs. lean mass insights, improving the user experience with clear graphical representations of dietary correlations. --- backend/data_layer/correlations.py | 32 +- backend/data_layer/history_overview_viz.py | 240 +++++++++++ backend/data_layer/nutrition_body_merge.py | 64 +++ .../data_layer/nutrition_interpretation.py | 104 +++++ backend/data_layer/nutrition_viz.py | 76 ++++ backend/routers/charts.py | 19 + backend/routers/nutrition.py | 35 +- frontend/src/pages/History.jsx | 397 +++++++++++------- frontend/src/utils/api.js | 5 + 9 files changed, 777 insertions(+), 195 deletions(-) create mode 100644 backend/data_layer/history_overview_viz.py create mode 100644 backend/data_layer/nutrition_body_merge.py diff --git a/backend/data_layer/correlations.py b/backend/data_layer/correlations.py index 0cc73bf..5ab2ac2 100644 --- a/backend/data_layer/correlations.py +++ b/backend/data_layer/correlations.py @@ -40,16 +40,36 @@ def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_day 'data_points': N } """ - if var1 == 'energy' and var2 == 'weight': - return _correlate_energy_weight(profile_id, max_lag_days) - elif var1 == 'protein' and var2 == 'lbm': - return _correlate_protein_lbm(profile_id, max_lag_days) - elif var1 == 'training_load' and var2 in ['hrv', 'rhr']: - return _correlate_load_vitals(profile_id, var2, max_lag_days) + v1 = (var1 or "").strip().lower() + if v1 in ("energy", "energy_balance"): + v1n = "energy" + elif v1 in ("training_load", "load"): + v1n = "training_load" + elif v1 == "protein": + v1n = "protein" + else: + v1n = v1 + + if v1n == 'energy' and var2 == 'weight': + return _normalize_lag_payload(_correlate_energy_weight(profile_id, max_lag_days)) + elif v1n == 'protein' and var2 == 'lbm': + return _normalize_lag_payload(_correlate_protein_lbm(profile_id, max_lag_days)) + elif v1n == 'training_load' and var2 in ['hrv', 'rhr']: + return _normalize_lag_payload(_correlate_load_vitals(profile_id, var2, max_lag_days)) else: return None +def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]: + """Charts erwarten u. a. ``best_lag_days``; Layer liefert teils ``best_lag``.""" + if not raw: + return None + out = dict(raw) + if out.get("best_lag_days") is None and out.get("best_lag") is not None: + out["best_lag_days"] = out["best_lag"] + return out + + def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]: """ Correlate energy balance with weight change diff --git a/backend/data_layer/history_overview_viz.py b/backend/data_layer/history_overview_viz.py new file mode 100644 index 0000000..40627f9 --- /dev/null +++ b/backend/data_layer/history_overview_viz.py @@ -0,0 +1,240 @@ +""" +Layer 2b: Gesamtansicht «Verlauf» — komponiert nur Bundles aus body-, nutrition-, fitness-, recovery_viz. + +Issue #53: keine parallele Business-Logik; ein Router-Endpoint liefert diese Zusammenfassung. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from data_layer.body_viz import get_body_history_viz_bundle +from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers +from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle +from data_layer.nutrition_viz import get_nutrition_history_viz_bundle +from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle + + +def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]: + if not isinstance(tiles, list): + return [] + out: List[Dict[str, Any]] = [] + for t in tiles[:max_n]: + if not isinstance(t, dict): + continue + out.append( + { + "key": t.get("key"), + "category": t.get("category"), + "icon": t.get("icon"), + "value": t.get("value"), + "sublabel": t.get("sublabel"), + "status": t.get("status"), + "verdict": t.get("verdict"), + } + ) + return out + + +def _short_body_interpretation_tiles(tiles: Any, max_n: int = 3) -> List[Dict[str, Any]]: + """Körper-Interpretationskacheln (keine KPI-Kacheln).""" + if not isinstance(tiles, list): + return [] + out: List[Dict[str, Any]] = [] + for t in tiles[:max_n]: + if not isinstance(t, dict): + continue + det = str(t.get("detail") or "") + if len(det) > 140: + det = det[:137] + "…" + out.append( + { + "title": t.get("title") or t.get("category") or "Hinweis", + "detail": det, + "status": t.get("status"), + } + ) + return out + + +def _take_insights(items: Any, max_n: int = 2) -> List[Dict[str, Any]]: + if not isinstance(items, list): + return [] + out: List[Dict[str, Any]] = [] + for it in items[:max_n]: + if not isinstance(it, dict): + continue + out.append( + { + "title": it.get("title") or it.get("title_de"), + "body": it.get("body") or it.get("detail") or it.get("message"), + "tone": it.get("tone") or it.get("status"), + } + ) + return out + + +def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: + """ + Kompakte Übersicht für den ersten Reiter «Gesamtansicht»: KPI-Kurzformen + Lag-Korrelationen (C1–C4). + """ + eff = max(7, min(int(days), 9999)) + body = get_body_history_viz_bundle(profile_id, eff) + nutr = get_nutrition_history_viz_bundle(profile_id, eff) + fit = get_fitness_dashboard_viz_bundle(profile_id, eff) + rec = get_recovery_dashboard_viz_bundle(profile_id, eff) + + c1 = calculate_lag_correlation(profile_id, "energy_balance", "weight", 14) + c2 = calculate_lag_correlation(profile_id, "protein", "lbm", 14) + c3_hrv = calculate_lag_correlation(profile_id, "load", "hrv", 14) + c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14) + c3 = None + if c3_hrv and c3_rhr: + c3 = ( + c3_hrv + if abs(float(c3_hrv.get("correlation") or 0)) >= abs(float(c3_rhr.get("correlation") or 0)) + else c3_rhr + ) + if c3 is c3_hrv: + c3 = dict(c3) + c3["metric"] = "HRV" + else: + c3 = dict(c3_rhr) + c3["metric"] = "RHR" + elif c3_hrv: + c3 = dict(c3_hrv) + c3["metric"] = "HRV" + elif c3_rhr: + c3 = dict(c3_rhr) + c3["metric"] = "RHR" + + drivers = calculate_top_drivers(profile_id) + + b_sum = body.get("summary") if isinstance(body.get("summary"), dict) else {} + last_w = b_sum.get("weight_kg") + + fs = fit.get("summary") if isinstance(fit.get("summary"), dict) else {} + if fit.get("has_activity_entries"): + ac = int(fs.get("activity_count") or 0) + fitness_line = f"{ac} Trainingseinheiten im gewählten Fenster" + else: + fitness_line = fit.get("message") or "Keine Trainingsdaten" + + drv_list = drivers if isinstance(drivers, list) else [] + + return { + "days_requested": days, + "effective_window_days": eff, + "confidence": _overview_confidence(body, nutr, fit, rec), + "sections": [ + { + "id": "body", + "title": "Körper", + "tab_id": "body", + "summary_line": ( + f"Letztes Gewicht: {last_w} kg" + if last_w is not None + else "Keine Gewichtsdaten im Fenster" + ), + "interpretation_short": _short_body_interpretation_tiles(body.get("interpretation_tiles"), 3), + }, + { + "id": "nutrition", + "title": "Ernährung", + "tab_id": "nutrition", + "summary_line": ( + f"Ø {round(float((nutr.get('summary') or {}).get('kcal_avg') or 0))} kcal/Tag" + if nutr.get("has_nutrition_entries") + else (nutr.get("message") or "Keine Ernährungsdaten") + ), + "kpi_short": _take_kpis(nutr.get("kpi_tiles"), 4), + "heuristic_short": (nutr.get("nutrition_correlation_heuristics") or [])[:2], + }, + { + "id": "fitness", + "title": "Fitness", + "tab_id": "activity", + "summary_line": fitness_line, + "kpi_short": _take_kpis(fit.get("kpi_tiles"), 4), + "insights_short": _take_insights(fit.get("progress_insights"), 2), + }, + { + "id": "recovery", + "title": "Erholung", + "tab_id": "activity", + "summary_line": "Schlaf & Vitalwerte" + if rec.get("has_recovery_data") + else (rec.get("message") or "Keine Erholungsdaten"), + "kpi_short": _take_kpis(rec.get("kpi_tiles"), 4), + "insights_short": _take_insights(rec.get("progress_insights"), 2), + }, + ], + "lag_correlations": { + "weight_energy": _compact_lag("C1 Energiebilanz ↔ Gewicht", c1), + "protein_lbm": _compact_lag("C2 Protein ↔ Magermasse", c2), + "load_vitals": _compact_lag( + f"C3 Last ↔ {(c3 or {}).get('metric') or 'Vital'}", + c3, + extra_keys=("metric",), + ), + "recovery_performance": { + "label": "C4 Top-Treiber (Einflussfaktoren)", + "drivers": drv_list[:8], + }, + }, + "meta": { + "layer_1": "composed_metrics", + "layer_2b": "history_overview_viz", + "issue": "53-history-overview", + "sources": { + "body": "body_viz", + "nutrition": "nutrition_viz", + "fitness": "fitness_viz", + "recovery": "recovery_viz", + "lag": "correlations.calculate_lag_correlation", + "drivers": "correlations.calculate_top_drivers", + }, + }, + } + + +def _overview_confidence(b: Dict, n: Dict, f: Dict, r: Dict) -> str: + scores = [] + for x in (b, n, f, r): + c = x.get("confidence") + if c == "high": + scores.append(3) + elif c == "medium": + scores.append(2) + elif c == "low": + scores.append(1) + else: + scores.append(0) + s = sum(scores) / max(len(scores), 1) + if s >= 2.5: + return "high" + if s >= 1.5: + return "medium" + return "low" + + +def _compact_lag( + label: str, + payload: Optional[Dict[str, Any]], + extra_keys: tuple = (), +) -> Dict[str, Any]: + if not payload: + return {"label": label, "available": False} + out: Dict[str, Any] = { + "label": label, + "available": payload.get("correlation") is not None, + "correlation": payload.get("correlation"), + "best_lag_days": payload.get("best_lag_days", payload.get("best_lag")), + "confidence": payload.get("confidence"), + "interpretation": payload.get("interpretation", ""), + "data_points": payload.get("data_points"), + } + for k in extra_keys: + if k in payload: + out[k] = payload[k] + return out diff --git a/backend/data_layer/nutrition_body_merge.py b/backend/data_layer/nutrition_body_merge.py new file mode 100644 index 0000000..3263c45 --- /dev/null +++ b/backend/data_layer/nutrition_body_merge.py @@ -0,0 +1,64 @@ +""" +Layer 1 Hilfslogik: Ernährung + Gewicht + Caliper (forward-filled Magermasse). + +Genutzt von Layer 2b (nutrition_viz) und vom Router GET /api/nutrition/correlations. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from db import get_db, get_cursor, r2d +from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map + + +def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]: + """ + Pro Kalendertag: Makros aus nutrition_log, Gewicht, forward-filled Caliper (lean_mass, bf%). + Gleiche Semantik wie bisher ``GET /api/nutrition/correlations``. + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,)) + nutr = {r["date"]: r2d(r) for r in cur.fetchall()} + cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,)) + wlog = {r["date"]: r["weight"] for r in cur.fetchall()} + cur.execute( + "SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date", + (profile_id,), + ) + cals = sorted([r2d(r) for r in cur.fetchall()], key=lambda x: x["date"]) + + all_dates = sorted(set(list(nutr.keys()) + list(wlog.keys()))) + mi = 0 + last_cal: Dict[str, Any] = {} + cal_by_date: Dict[Any, Dict[str, Any]] = {} + for d in all_dates: + while mi < len(cals) and cals[mi]["date"] <= d: + last_cal = cals[mi] + mi += 1 + if last_cal: + cal_by_date[d] = last_cal + + result: List[Dict[str, Any]] = [] + for d in all_dates: + if d not in nutr and d not in wlog: + continue + row: Dict[str, Any] = {"date": d} + if d in nutr: + for k in ("kcal", "protein_g", "fat_g", "carbs_g"): + v = nutr[d].get(k) + row[k] = float(v) if v is not None else None + if d in wlog: + row["weight"] = float(wlog[d]) + if d in cal_by_date: + lm = cal_by_date[d].get("lean_mass") + bf = cal_by_date[d].get("body_fat_pct") + if bf is not None and lm is None: + wkg = nearest_weight_kg_from_map(wlog, d) + if wkg is not None: + lm, _fat = compute_lean_fat_kg(wkg, float(bf)) + row["lean_mass"] = float(lm) if lm is not None else None + row["body_fat_pct"] = float(bf) if bf is not None else None + result.append(row) + return result diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py index 4178f8e..34de0eb 100644 --- a/backend/data_layer/nutrition_interpretation.py +++ b/backend/data_layer/nutrition_interpretation.py @@ -217,3 +217,107 @@ def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[ {"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)}, {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)}, ] + + +def build_nutrition_correlation_heuristic_items( + merged_rows: List[Dict[str, Any]], + tdee_kcal: float, + protein_target_low_g: float, +) -> List[Dict[str, Any]]: + """ + Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx, + TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend. + """ + filtered = [ + r + for r in merged_rows + if r.get("kcal") is not None and r.get("weight") is not None + ] + if len(filtered) < 5: + return [] + + td = float(tdee_kcal) + latest_w = float(filtered[-1].get("weight") or 0) or 80.0 + pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6)) + + items: List[Dict[str, Any]] = [] + + if len(filtered) >= 14: + high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200] + low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200] + if len(high_k) >= 3 and len(low_k) >= 3: + avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k) + avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k) + avg_wh_r = round(avg_wh * 10) / 10 + avg_wl_r = round(avg_wl * 10) / 10 + items.append( + { + "icon": "📊", + "status": "good" if avg_wl < avg_wh else "warn", + "title": ( + f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss" + if avg_wl < avg_wh + else "Kein klarer Kalorieneffekt auf Gewicht erkennbar" + ), + "detail": ( + f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · " + f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg" + ), + } + ) + + prot_vs_lean = [ + d + for d in filtered + if d.get("protein_g") is not None and d.get("lean_mass") is not None + ] + if len(prot_vs_lean) >= 3: + high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low] + low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low] + if len(high_p) >= 2 and len(low_p) >= 2: + avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p) + avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p) + avg_lh_r = round(avg_lh * 10) / 10 + avg_ll_r = round(avg_ll * 10) / 10 + items.append( + { + "icon": "🥩", + "status": "good" if avg_lh >= avg_ll else "warn", + "title": ( + f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg" + ), + "detail": ( + f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen." + ), + } + ) + + balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None] + avg_balance = int(round(sum(balances) / len(balances))) if balances else 0 + ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance) + if avg_balance < -100: + ic, st = "✅", "good" + elif avg_balance > 200: + ic, st = "⬆️", "warn" if avg_balance > 300 else "good" + else: + ic, st = "➡️", "good" + + if avg_balance < -500: + bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen." + elif avg_balance < -100: + bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt." + elif avg_balance > 300: + bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich." + else: + bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben." + + items.append( + { + "icon": ic, + "status": st, + "title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag", + "detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}", + } + ) + + return items diff --git a/backend/data_layer/nutrition_viz.py b/backend/data_layer/nutrition_viz.py index 8891cf6..4a7ef6b 100644 --- a/backend/data_layer/nutrition_viz.py +++ b/backend/data_layer/nutrition_viz.py @@ -10,9 +10,11 @@ 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_metrics import ( @@ -112,6 +114,58 @@ def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Di 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]]: @@ -187,6 +241,9 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "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": [], "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, } @@ -239,6 +296,19 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An 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) @@ -286,6 +356,12 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "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, "meta": { "layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz", diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 1a285fe..eed8e92 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -35,6 +35,7 @@ from data_layer.body_viz import get_body_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle +from data_layer.history_overview_viz import get_history_overview_viz_bundle from data_layer.recovery_chart_payloads import ( build_recovery_score_chart_payload, build_hrv_rhr_baseline_chart_payload, @@ -336,6 +337,24 @@ def get_recovery_dashboard_viz( return serialize_dates(bundle) +@router.get("/history-overview-viz") +def get_history_overview_viz( + days: int = Query( + default=30, + ge=7, + le=9999, + description="Analysefenster in Tagen (komponiert Körper/Ernährung/Fitness/Erholung)", + ), + session: dict = Depends(require_auth), +) -> Dict: + """ + Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1–C4 (Metadaten). + """ + profile_id = session["profile_id"] + bundle = get_history_overview_viz_bundle(profile_id, days) + return serialize_dates(bundle) + + @router.get("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index c9d0157..6496935 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -16,7 +16,7 @@ from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage -from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map +from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) logger = logging.getLogger(__name__) @@ -179,38 +179,9 @@ def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default= @router.get("/correlations") def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Get nutrition data correlated with weight and body fat.""" + """Get nutrition data correlated with weight and body fat (Layer 1 Merge, siehe nutrition_body_merge).""" pid = get_pid(x_profile_id) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,)) - nutr={r['date']:r2d(r) for r in cur.fetchall()} - cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,)) - wlog={r['date']:r['weight'] for r in cur.fetchall()} - cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,)) - cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date']) - all_dates=sorted(set(list(nutr)+list(wlog))) - mi,last_cal,cal_by_date=0,{},{} - for d in all_dates: - while mi + {balDaily.length > 0 && tdeeRef != null && ( +
+
+ Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal) +
+
+ Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer). +
+ + ({ ...d, date: fmtDate(d.date) }))} + margin={{ top: 4, right: 8, bottom: 0, left: -16 }} + > + + + + + [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']} + /> + + + + +
+ )} + + {plmPts.length >= 3 && ( +
+
+ Protein vs. Magermasse (Caliper, forward-filled) +
+
+ Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar. +
+ + ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}> + + + + + {plm.protein_target_low_g > 0 && ( + + )} + [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']} + /> + + + + +
+ )} + + {nutHeur.length > 0 && ( +
+
Ernährung — Kurz-Einordnung
+ {nutHeur.map((item, i) => ( +
+
+ {item.icon || '•'} +
+
{item.title}
+
{item.detail}
+
+
+
+ ))} +
+ )} +
Makroverteilung täglich (g) · Fokus Protein @@ -1148,174 +1242,160 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA ) } -// ── Correlation Section ─────────────────────────────────────────────────────── -function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) { - const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight) - if (filtered.length < 5) return ( - +function LagCorrelationMetaCard({ title, block }) { + if (!block) return null + const ok = block.available + return ( +
+
{title}
+ {!ok ? ( +
Nicht genug Daten für diese Auswertung.
+ ) : ( + <> +
+ r ≈ {block.correlation != null ? Number(block.correlation).toFixed(3) : '—'} + {block.best_lag_days != null ? ` · Lag ${block.best_lag_days} Tage` : ''} + {block.metric ? ` · ${block.metric}` : ''} + {block.confidence ? ` · ${block.confidence}` : ''} +
+ {block.interpretation ? ( +
{block.interpretation}
+ ) : null} + + )} +
) +} - const sex = profile?.sex||'m' - const height = profile?.height||178 - const latestW = filtered[filtered.length-1]?.weight||80 - const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35 - const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161 - const tdee = Math.round(bmr*1.4) // light activity baseline +// ── Gesamtansicht (Layer 2b: GET /charts/history-overview-viz) ───────────────── +function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { + const navigate = useNavigate() + const [period, setPeriod] = useState(30) + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + const [loading, setLoading] = useState(true) - // Protein vs Lean Mass (only days with both) - const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) - .map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) - - // Chart 3: Activity kcal vs Weight change - const actVsW = filtered.filter(d=>d.weight) - .map((d,i,arr)=>{ - const prev = arr[i-1] - return { - date: fmtDate(d.date), - weight: d.weight, - weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null, - kcal: d.kcal||0, - } - }).filter(d=>d.weightDelta!==null) - - // Chart 4: Calorie balance (intake - estimated TDEE) - const balance = filtered.map(d=>({ - date: fmtDate(d.date), - balance: Math.round((d.kcal||0) - tdee), - })) - const balWithAvg = rollingAvg(balance,'balance') - const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length) - - // ── Correlation insights ── - const corrInsights = [] - - // 1. Kcal → Weight correlation - if (filtered.length >= 14) { - const highKcal = filtered.filter(d=>d.kcal>tdee+200) - const lowKcal = filtered.filter(d=>d.kcal=3 && lowKcal.length>=3) { - const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10 - const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10 - corrInsights.push({ - icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn', - title: avgWLow < avgWHigh - ? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss` - : `Kein klarer Kalorieneffekt auf Gewicht erkennbar`, - detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`, + useEffect(() => { + let cancelled = false + const daysReq = period === 9999 ? 3650 : period + setLoading(true) + api.getHistoryOverviewViz(daysReq) + .then((d) => { + if (!cancelled) { + setData(d) + setErr(null) + } }) + .catch((e) => { + if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true } + }, [period]) + + if (loading) { + return ( +
+ + +
+
+ ) + } + if (err) { + return ( +
+ +
{err}
+
+ ) } - // 2. Protein → Lean mass - if (protVsLean.length >= 3) { - const ptLow = Math.round(latestW*1.6) - const highProt = protVsLean.filter(d=>d.protein>=ptLow) - const lowProt = protVsLean.filter(d=>d.protein=2 && lowProt.length>=2) { - const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10 - const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10 - corrInsights.push({ - icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn', - title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`, - detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`, - }) - } - } - - // 3. Avg balance - corrInsights.push({ - icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️', - status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good', - title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`, - detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${ - avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.': - avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.': - avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.': - 'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`, - }) + const lag = data?.lag_correlations || {} + const c4 = lag.recovery_performance + const sections = data?.sections || [] return (
- - + +

- Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis). + Kurzüberblick aus denselben Data-Layer-Bundles wie die Reiter Körper bis Erholung (Issue 53). Lag-Korrelationen C1–C4 + stammen aus correlations.py / Chart-Endpunkte.

- {/* Chart: Calorie balance */} -
-
- ⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal) -
- - - - - - - [`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/> - - - - -
- Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag -
-
- - {/* Chart 3: Protein vs Lean Mass */} - {protVsLean.length >= 3 && ( -
-
- 🥩 Protein vs. Magermasse + {sections.map((sec) => ( +
+
+
{sec.title}
+
- - - - - - - - [`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/> - - - - -
- — Protein g/Tag · ● Magermasse kg -
-
- )} - - {/* Correlation insights */} - {corrInsights.length > 0 && ( -
-
KORRELATIONSAUSSAGEN
- {corrInsights.map((item,i) => ( -
-
- {item.icon} -
-
{item.title}
-
{item.detail}
-
-
+
{sec.summary_line}
+ {(sec.interpretation_short || []).map((it, i) => ( +
+ {it.title} +
{it.detail}
+
+ ))} + {(sec.kpi_short || []).map((k, i) => ( +
+ {k.icon} {k.category} + {' · '} + {k.value} + {k.sublabel ? — {k.sublabel} : null} +
+ ))} + {(sec.heuristic_short || []).map((h, i) => ( +
+ {h.title} +
{h.detail}
+
+ ))} + {(sec.insights_short || []).map((ins, i) => ( +
+ {ins.title} +
{ins.body}
))} -
- ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen. -
- )} + ))} - +
+ Lag-Korrelationen (C1–C3) +
+ + + + +
+ Einflussfaktoren (C4) +
+
+ {c4?.drivers?.length ? ( + c4.drivers.map((d, i) => ( +
+ {d.factor} + ({d.status}) +
{d.reason}
+
+ )) + ) : ( +
Keine Treiber-Daten.
+ )} +
+ +
) } @@ -1440,23 +1520,22 @@ function PhotoGrid() { // ── Main ────────────────────────────────────────────────────────────────────── const TABS = [ + { id:'overview', label:'📊 Gesamt' }, { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, { id:'activity', label:'🏋️ Fitness' }, - { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, ] export default function History() { const { activeProfile } = useProfile() // Issue #31: Get global quality filter const location = useLocation?.() || {} - const [tab, setTab] = useState((location.state?.tab)||'body') + const [tab, setTab] = useState((location.state?.tab) || 'overview') const [weights, setWeights] = useState([]) const [calipers, setCalipers] = useState([]) const [circs, setCircs] = useState([]) const [nutrition, setNutrition] = useState([]) const [activities, setActivities] = useState([]) - const [corrData, setCorrData] = useState([]) const [insights, setInsights] = useState([]) const [prompts, setPrompts] = useState([]) const [profile, setProfile] = useState(null) @@ -1466,11 +1545,11 @@ export default function History() { const loadAll = () => Promise.all([ api.listWeight(365), api.listCaliper(), api.listCirc(), api.listNutrition(90), api.listActivity(25_000), - api.nutritionCorrelations(), api.latestInsights(), api.getProfile(), + api.latestInsights(), api.getProfile(), api.listPrompts(), - ]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{ + ]).then(([w,ca,ci,n,a,ins,p,pr])=>{ setWeights(w); setCalipers(ca); setCircs(ci) - setNutrition(n); setActivities(a); setCorrData(corr) + setNutrition(n); setActivities(a) setInsights(Array.isArray(ins)?ins:[]); setProfile(p) setPrompts(Array.isArray(pr)?pr:[]) setLoading(false) @@ -1486,6 +1565,10 @@ export default function History() { setTab('activity') return } + if (t === 'correlation') { + setTab('nutrition') + return + } if (t && TABS.some(x => x.id === t)) setTab(t) }, [location.state?.tab]) @@ -1530,10 +1613,10 @@ export default function History() {
+ {tab==='overview' && } {tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } - {tab==='correlation' && } {tab==='photos' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 18ba7e9..505e354 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -643,6 +643,11 @@ export const api = { getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`), /** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */ getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`), + getHistoryOverviewViz: (days=30) => req(`/charts/history-overview-viz?days=${days}`), + getWeightEnergyCorrelationChart: (maxLag=14) => req(`/charts/weight-energy-correlation?max_lag=${maxLag}`), + getLbmProteinCorrelationChart: (maxLag=14) => req(`/charts/lbm-protein-correlation?max_lag=${maxLag}`), + getLoadVitalsCorrelationChart: (maxLag=14) => req(`/charts/load-vitals-correlation?max_lag=${maxLag}`), + getRecoveryPerformanceChart: () => req('/charts/recovery-performance'), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),