feat: enhance nutrition data processing and visualization with new correlation insights
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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.
This commit is contained in:
Lars 2026-04-20 13:45:28 +02:00
parent 45fb506a5e
commit 7ac9752c3d
9 changed files with 777 additions and 195 deletions

View File

@ -40,16 +40,36 @@ def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_day
'data_points': N 'data_points': N
} }
""" """
if var1 == 'energy' and var2 == 'weight': v1 = (var1 or "").strip().lower()
return _correlate_energy_weight(profile_id, max_lag_days) if v1 in ("energy", "energy_balance"):
elif var1 == 'protein' and var2 == 'lbm': v1n = "energy"
return _correlate_protein_lbm(profile_id, max_lag_days) elif v1 in ("training_load", "load"):
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']: v1n = "training_load"
return _correlate_load_vitals(profile_id, var2, max_lag_days) 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: else:
return None 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]: def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
""" """
Correlate energy balance with weight change Correlate energy balance with weight change

View File

@ -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 (C1C4).
"""
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

View File

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

View File

@ -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": "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)}, {"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

View File

@ -10,9 +10,11 @@ from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from db import get_db, get_cursor, r2d 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 ( from data_layer.nutrition_interpretation import (
build_energy_availability_kpi_tile, build_energy_availability_kpi_tile,
build_macro_donut_from_averages, build_macro_donut_from_averages,
build_nutrition_correlation_heuristic_items,
build_nutrition_history_kpi_tiles, build_nutrition_history_kpi_tiles,
) )
from data_layer.nutrition_metrics import ( 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()] 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( def _kcal_weight_points_for_window(
profile_id: str, cutoff: Optional[str] profile_id: str, cutoff: Optional[str]
) -> List[Dict[str, Any]]: ) -> 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": {}, "energy_balance_meta": {},
"interpretation_tiles": [], "interpretation_tiles": [],
"energy_availability_warning": None, "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"}, "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) kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
pt_low = round(float(targets.get("protein_target_low") or 0)) 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)) 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) 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"), "confidence": energy_meta.get("confidence"),
"data_points": energy_meta.get("data_points"), "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": { "meta": {
"layer_1": "nutrition_metrics", "layer_1": "nutrition_metrics",
"layer_2b": "nutrition_viz", "layer_2b": "nutrition_viz",

View File

@ -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.nutrition_viz import get_nutrition_history_viz_bundle
from data_layer.fitness_viz import get_fitness_dashboard_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.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 ( from data_layer.recovery_chart_payloads import (
build_recovery_score_chart_payload, build_recovery_score_chart_payload,
build_hrv_rhr_baseline_chart_payload, build_hrv_rhr_baseline_chart_payload,
@ -336,6 +337,24 @@ def get_recovery_dashboard_viz(
return serialize_dates(bundle) 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 C1C4 (Metadaten).
"""
profile_id = session["profile_id"]
bundle = get_history_overview_viz_bundle(profile_id, days)
return serialize_dates(bundle)
@router.get("/circumferences") @router.get("/circumferences")
def get_circumferences_chart( def get_circumferences_chart(
max_age_days: int = Query(default=90, ge=7, le=365), max_age_days: int = Query(default=90, ge=7, le=365),

View File

@ -16,7 +16,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage 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"]) router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__) 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") @router.get("/correlations")
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): 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) pid = get_pid(x_profile_id)
with get_db() as conn: return build_merged_daily_nutrition_body_rows(pid)
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<len(cals) and cals[mi]['date']<=d: last_cal=cals[mi]; mi+=1
if last_cal: cal_by_date[d]=last_cal
result=[]
for d in all_dates:
if d not in nutr and d not in wlog: continue
row={'date':d}
if d in nutr: row.update({k:float(nutr[d][k]) if nutr[d][k] is not None else None for k in ['kcal','protein_g','fat_g','carbs_g']})
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
@router.get("/weekly") @router.get("/weekly")

View File

@ -966,6 +966,11 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
const weeklyMacro = viz.weekly_macro_chart const weeklyMacro = viz.weekly_macro_chart
const wmLoading = false const wmLoading = false
const wmError = null const wmError = null
const balDaily = viz.calorie_balance_daily || []
const plm = viz.protein_vs_lean_mass || {}
const plmPts = plm.points || []
const nutHeur = viz.nutrition_correlation_heuristics || []
const tdeeRef = viz.tdee_reference_kcal
if (!cdMacro.length || n === 0) { if (!cdMacro.length || n === 0) {
return ( return (
@ -999,6 +1004,95 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
allTime={period === 9999} allTime={period === 9999}
/> />
{balDaily.length > 0 && tdeeRef != null && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorienbilanz (Aufnahme TDEE ~{Math.round(tdeeRef)} kcal)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tagesbilanz und 7-Tage-Mittel gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{plmPts.length >= 3 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Protein vs. Magermasse (Caliper, forward-filled)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{plm.protein_target_low_g > 0 && (
<ReferenceLine
yAxisId="prot"
y={plm.protein_target_low_g}
stroke="#1D9E75"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']}
/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{nutHeur.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung Kurz-Einordnung</div>
{nutHeur.map((item, i) => (
<div
key={i}
style={{
padding: '10px 12px',
borderRadius: 8,
marginBottom: 6,
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
</div>
</div>
</div>
))}
</div>
)}
<div className="card" style={{ marginBottom: 12 }}> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein Makroverteilung täglich (g) · Fokus Protein
@ -1148,174 +1242,160 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
) )
} }
// Correlation Section function LagCorrelationMetaCard({ title, block }) {
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) { if (!block) return null
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight) const ok = block.available
if (filtered.length < 5) return ( return (
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/> <div className="card" style={{ marginBottom: 8, padding: '10px 12px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>{title}</div>
{!ok ? (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Nicht genug Daten für diese Auswertung.</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text1)' }}>
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}` : ''}
</div>
{block.interpretation ? (
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 6, lineHeight: 1.45 }}>{block.interpretation}</div>
) : null}
</>
)}
</div>
) )
}
const sex = profile?.sex||'m' // Gesamtansicht (Layer 2b: GET /charts/history-overview-viz)
const height = profile?.height||178 function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const latestW = filtered[filtered.length-1]?.weight||80 const navigate = useNavigate()
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35 const [period, setPeriod] = useState(30)
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161 const [data, setData] = useState(null)
const tdee = Math.round(bmr*1.4) // light activity baseline const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
// Protein vs Lean Mass (only days with both) useEffect(() => {
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) let cancelled = false
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) const daysReq = period === 9999 ? 3650 : period
setLoading(true)
// Chart 3: Activity kcal vs Weight change api.getHistoryOverviewViz(daysReq)
const actVsW = filtered.filter(d=>d.weight) .then((d) => {
.map((d,i,arr)=>{ if (!cancelled) {
const prev = arr[i-1] setData(d)
return { setErr(null)
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<tdee-200)
if (highKcal.length>=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`,
}) })
.catch((e) => {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
} }
}, [period])
if (loading) {
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>
</div>
)
} }
// 2. Protein Lean mass const lag = data?.lag_correlations || {}
if (protVsLean.length >= 3) { const c4 = lag.recovery_performance
const ptLow = Math.round(latestW*1.6) const sections = data?.sections || []
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
if (highProt.length>=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.'}`,
})
return ( return (
<div> <div>
<SectionHeader title="🔗 Korrelationen"/> <SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}> <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf Ernährung</strong> (gleiche Datenbasis). Kurzüberblick aus denselben Data-Layer-Bundles wie die Reiter Körper bis Erholung (Issue&nbsp;53). Lag-Korrelationen C1C4
stammen aus <code style={{ fontSize: 10 }}>correlations.py</code> / Chart-Endpunkte.
</p> </p>
{/* Chart: Calorie balance */} {sections.map((sec) => (
<div className="card" style={{marginBottom:12}}> <div key={sec.id} className="card" style={{ marginBottom: 12 }}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal) <div style={{ fontSize: 14, fontWeight: 700 }}>{sec.title}</div>
</div> <button
<ResponsiveContainer width="100%" height={160}> type="button"
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}> className="btn btn-secondary"
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/> >
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> Zum Reiter
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/> </button>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
</div>
</div>
{/* Chart 3: Protein vs Lean Mass */}
{protVsLean.length >= 3 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
🥩 Protein vs. Magermasse
</div> </div>
<ResponsiveContainer width="100%" height={160}> <div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>{sec.summary_line}</div>
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}> {(sec.interpretation_short || []).map((it, i) => (
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> <div key={i} style={{ fontSize: 12, marginBottom: 6, paddingLeft: 4, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> <strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/> <div style={{ color: 'var(--text2)', marginTop: 2 }}>{it.detail}</div>
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/> </div>
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5} ))}
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/> {(sec.kpi_short || []).map((k, i) => (
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}} <div key={i} style={{ fontSize: 12, marginBottom: 4 }}>
formatter={(v,n)=>[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/> <span style={{ color: getStatusColor(k.status || 'good') }}>{k.icon} {k.category}</span>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein"/> {' · '}
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean"/> <span style={{ fontWeight: 600 }}>{k.value}</span>
</LineChart> {k.sublabel ? <span style={{ color: 'var(--text3)', fontSize: 10 }}> {k.sublabel}</span> : null}
</ResponsiveContainer> </div>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}> ))}
<span style={{color:'#1D9E75'}}> Protein g/Tag</span> · <span style={{color:'#7F77DD'}}> Magermasse kg</span> {(sec.heuristic_short || []).map((h, i) => (
</div> <div key={i} style={{ fontSize: 12, marginTop: 6, color: 'var(--text2)' }}>
</div> <strong>{h.title}</strong>
)} <div style={{ fontSize: 11, marginTop: 2 }}>{h.detail}</div>
</div>
{/* Correlation insights */} ))}
{corrInsights.length > 0 && ( {(sec.insights_short || []).map((ins, i) => (
<div style={{marginBottom:12}}> <div key={i} style={{ fontSize: 12, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div> <strong>{ins.title}</strong>
{corrInsights.map((item,i) => ( <div>{ins.body}</div>
<div key={i} style={{padding:'10px 12px',borderRadius:8,marginBottom:6,
background:item.status==='good'?'var(--accent-light)':'var(--warn-bg)',
border:`1px solid ${item.status==='good'?'var(--accent)':'var(--warn)'}33`}}>
<div style={{display:'flex',gap:8,alignItems:'flex-start'}}>
<span style={{fontSize:16}}>{item.icon}</span>
<div>
<div style={{fontSize:13,fontWeight:600}}>{item.title}</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>{item.detail}</div>
</div>
</div>
</div> </div>
))} ))}
<div style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
borderRadius:6,marginTop:6}}>
TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
</div>
</div> </div>
)} ))}
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 8 }}>
Lag-Korrelationen (C1C3)
</div>
<LagCorrelationMetaCard title={lag.weight_energy?.label || 'C1'} block={lag.weight_energy} />
<LagCorrelationMetaCard title={lag.protein_lbm?.label || 'C2'} block={lag.protein_lbm} />
<LagCorrelationMetaCard title={lag.load_vitals?.label || 'C3'} block={lag.load_vitals} />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 12 }}>
Einflussfaktoren (C4)
</div>
<div className="card" style={{ marginBottom: 12, padding: '10px 12px' }}>
{c4?.drivers?.length ? (
c4.drivers.map((d, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 8, lineHeight: 1.45 }}>
<strong>{d.factor}</strong>
<span style={{ color: 'var(--text3)', marginLeft: 6 }}>({d.status})</span>
<div style={{ color: 'var(--text2)', marginTop: 2 }}>{d.reason}</div>
</div>
))
) : (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Keine Treiber-Daten.</div>
)}
</div>
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div> </div>
) )
} }
@ -1440,23 +1520,22 @@ function PhotoGrid() {
// Main // Main
const TABS = [ const TABS = [
{ id:'overview', label:'📊 Gesamt' },
{ id:'body', label:'⚖️ Körper' }, { id:'body', label:'⚖️ Körper' },
{ id:'nutrition', label:'🍽️ Ernährung' }, { id:'nutrition', label:'🍽️ Ernährung' },
{ id:'activity', label:'🏋️ Fitness' }, { id:'activity', label:'🏋️ Fitness' },
{ id:'correlation', label:'🔗 Korrelation' },
{ id:'photos', label:'📷 Fotos' }, { id:'photos', label:'📷 Fotos' },
] ]
export default function History() { export default function History() {
const { activeProfile } = useProfile() // Issue #31: Get global quality filter const { activeProfile } = useProfile() // Issue #31: Get global quality filter
const location = useLocation?.() || {} const location = useLocation?.() || {}
const [tab, setTab] = useState((location.state?.tab)||'body') const [tab, setTab] = useState((location.state?.tab) || 'overview')
const [weights, setWeights] = useState([]) const [weights, setWeights] = useState([])
const [calipers, setCalipers] = useState([]) const [calipers, setCalipers] = useState([])
const [circs, setCircs] = useState([]) const [circs, setCircs] = useState([])
const [nutrition, setNutrition] = useState([]) const [nutrition, setNutrition] = useState([])
const [activities, setActivities] = useState([]) const [activities, setActivities] = useState([])
const [corrData, setCorrData] = useState([])
const [insights, setInsights] = useState([]) const [insights, setInsights] = useState([])
const [prompts, setPrompts] = useState([]) const [prompts, setPrompts] = useState([])
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
@ -1466,11 +1545,11 @@ export default function History() {
const loadAll = () => Promise.all([ const loadAll = () => Promise.all([
api.listWeight(365), api.listCaliper(), api.listCirc(), api.listWeight(365), api.listCaliper(), api.listCirc(),
api.listNutrition(90), api.listActivity(25_000), api.listNutrition(90), api.listActivity(25_000),
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(), api.latestInsights(), api.getProfile(),
api.listPrompts(), 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) setWeights(w); setCalipers(ca); setCircs(ci)
setNutrition(n); setActivities(a); setCorrData(corr) setNutrition(n); setActivities(a)
setInsights(Array.isArray(ins)?ins:[]); setProfile(p) setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
setPrompts(Array.isArray(pr)?pr:[]) setPrompts(Array.isArray(pr)?pr:[])
setLoading(false) setLoading(false)
@ -1486,6 +1565,10 @@ export default function History() {
setTab('activity') setTab('activity')
return return
} }
if (t === 'correlation') {
setTab('nutrition')
return
}
if (t && TABS.some(x => x.id === t)) setTab(t) if (t && TABS.some(x => x.id === t)) setTab(t)
}, [location.state?.tab]) }, [location.state?.tab])
@ -1530,10 +1613,10 @@ export default function History() {
</div> </div>
</nav> </nav>
<div className="history-content"> <div className="history-content">
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
{tab==='body' && <BodySection profile={profile} {...sp}/>} {tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>} {tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>} {tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>} {tab==='photos' && <PhotoGrid/>}
</div> </div>
</div> </div>

View File

@ -643,6 +643,11 @@ export const api = {
getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`), getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`),
/** Layer 2b: Erholung — KPI, Insights, Charts R1R5 (recovery_metrics) */ /** Layer 2b: Erholung — KPI, Insights, Charts R1R5 (recovery_metrics) */
getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`), 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}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),