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
- 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.