Merge pull request 'Überartbeitung History - neue Gesamtübersicht' (#99) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s

Reviewed-on: #99
This commit is contained in:
Lars 2026-04-20 14:39:54 +02:00
commit 41d809c68c
10 changed files with 1001 additions and 197 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

@ -6,7 +6,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional, Set from typing import Any, Dict, Optional, Set
from db import get_db, get_cursor from db import get_db, get_cursor
@ -333,14 +333,31 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
}, },
} }
labels = [] labels: list[str] = []
debt_values = [] debt_values: list[float] = []
for r in visible: for r in visible:
rd = r.get("date") rd = r.get("date")
end_d = rd.date() if isinstance(rd, datetime) else rd end_d = rd.date() if isinstance(rd, datetime) else rd
labels.append(end_d.isoformat() if hasattr(end_d, "isoformat") else str(end_d)) if not isinstance(end_d, date):
continue
labels.append(end_d.isoformat())
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d)) debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
# KPI nutzt immer Fensterende = heute; die Kurve endete bisher am Datum der letzten Schlaf-Zeile
# (z. B. gestern) → anderes 14-Tage-Fenster. Letzter Punkt = exakt KPI-Wert, Datum = heute.
today = datetime.now().date()
if labels and debt_values:
try:
last_d = date.fromisoformat(labels[-1])
except (TypeError, ValueError):
last_d = None
if last_d is not None:
if last_d < today:
labels.append(today.isoformat())
debt_values.append(float(current_debt))
elif last_d == today:
debt_values[-1] = float(current_debt)
return { return {
"chart_type": "line", "chart_type": "line",
"data": { "data": {
@ -360,13 +377,14 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
"metadata": serialize_dates( "metadata": serialize_dates(
{ {
"confidence": calculate_confidence(len(visible), days, "general"), "confidence": calculate_confidence(len(visible), days, "general"),
"data_points": len(visible), "data_points": len(labels),
"current_debt_hours": round(float(current_debt), 1), "current_debt_hours": round(float(current_debt), 1),
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT, "sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS, "rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. " "note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster " f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
"(jeder Punkt = Fensterende an dem Datum). Ziel aktuell nicht in den Profileinstellungen änderbar.", "Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
} }
), ),
} }

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

@ -4,7 +4,8 @@ import { useProfile } from '../context/ProfileContext'
import { import {
LineChart, Line, BarChart, Bar, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart ReferenceLine, PieChart, Pie, Cell, ComposedChart,
ScatterChart, Scatter,
} from 'recharts' } from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
@ -966,6 +967,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 (
@ -985,6 +991,9 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}> <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg). <strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p> </p>
<NutritionGoalsStrip grouped={groupedGoals} /> <NutritionGoalsStrip grouped={groupedGoals} />
@ -999,6 +1008,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,173 +1246,359 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
) )
} }
// Correlation Section function overviewSectionTone(sec) {
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) { const kpis = sec.kpi_short || []
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight) if (kpis.some((k) => k.status === 'bad')) return 'bad'
if (filtered.length < 5) return ( if (kpis.some((k) => k.status === 'warn')) return 'warn'
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/> const interp = sec.interpretation_short || []
) if (interp.some((x) => x.status === 'bad')) return 'bad'
if (interp.some((x) => x.status === 'warn')) return 'warn'
const sex = profile?.sex||'m' const heur = sec.heuristic_short || []
const height = profile?.height||178 if (heur.some((h) => h.status === 'warn')) return 'warn'
const latestW = filtered[filtered.length-1]?.weight||80 return 'good'
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
// 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) function overviewConfidenceUi(conf) {
const balance = filtered.map(d=>({ if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
date: fmtDate(d.date), if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
balance: Math.round((d.kcal||0) - tdee), return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
}
function chartJsScatterPoints(payload) {
const raw = payload?.data?.datasets?.[0]?.data || []
if (!Array.isArray(raw)) return []
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
}
function driverBarFromStatus(st) {
const s = String(st || '').toLowerCase()
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
return { v: 0.15, fill: '#6B7280' }
}
function chartJsBarRows(payload, fallbackDrivers) {
const labels = payload?.data?.labels || []
const values = payload?.data?.datasets?.[0]?.data || []
const colors = payload?.data?.datasets?.[0]?.backgroundColor
if (labels.length && values.length) {
return labels.map((name, i) => ({
name: name.length > 42 ? `${name.slice(0, 40)}` : name,
value: Number(values[i]),
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
})) }))
const balWithAvg = rollingAvg(balance,'balance') }
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length) if (fallbackDrivers?.length) {
return fallbackDrivers.map((d) => {
// Correlation insights const { v, fill } = driverBarFromStatus(d.status)
const corrInsights = [] return {
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}` : String(d.factor || '—'),
// 1. Kcal Weight correlation value: v,
if (filtered.length >= 14) { fill,
const highKcal = filtered.filter(d=>d.kcal>tdee+200) subtitle: d.reason,
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`,
}) })
} }
return []
} }
// 2. Protein Lean mass function CorrelationScatterTile({ title, accent, payload }) {
if (protVsLean.length >= 3) { const meta = payload?.metadata || {}
const ptLow = Math.round(latestW*1.6) const pts = chartJsScatterPoints(payload)
const highProt = protVsLean.filter(d=>d.protein>=ptLow) const hasChart = pts.length > 0 && meta.correlation != null
const lowProt = protVsLean.filter(d=>d.protein<ptLow) const r = Number(meta.correlation)
if (highProt.length>=2 && lowProt.length>=2) { const strength =
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10 !Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
corrInsights.push({ return (
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn', <div
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`, className="card"
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`, style={{
}) marginBottom: 0,
} padding: 10,
borderLeft: `4px solid ${getStatusColor(strength)}`,
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
{meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''}
{meta.metric ? ` · ${meta.metric}` : ''}
{meta.confidence ? ` · ${meta.confidence}` : ''}
</div>
{!hasChart ? (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
) : (
<ResponsiveContainer width="100%" height={118}>
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
<Scatter name="r" data={pts} fill={accent} />
</ScatterChart>
</ResponsiveContainer>
)}
{meta.interpretation ? (
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
) : null}
</div>
)
} }
// 3. Avg balance function DriversImpactTile({ payload, driversFallback }) {
corrInsights.push({ const meta = payload?.metadata || {}
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️', const rows = chartJsBarRows(payload, driversFallback)
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good', if (!rows.length) {
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`, return (
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${ <div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
avgBalance<-500?'Starkes Defizit Muskelerhalt durch ausreichend Protein sicherstellen.': <div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
avgBalance<-100?'Moderates Defizit ideal für Fettabbau bei Muskelerhalt.': <div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
avgBalance>300?'Kalorienüberschuss günstig für Muskelaufbau, Fettzunahme möglich.': </div>
'Nahezu ausgeglichen Gewicht sollte stabil bleiben.'}`, )
}
const h = Math.min(220, Math.max(96, rows.length * 34))
return (
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<ResponsiveContainer width="100%" height={h}>
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
<Tooltip
content={({ active, payload: pp }) => {
if (!active || !pp?.length) return null
const p = pp[0].payload
return (
<div
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
padding: '8px 10px',
borderRadius: 8,
fontSize: 11,
maxWidth: 280,
}}
>
<div style={{ fontWeight: 600 }}>{p.name}</div>
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
</div>
)
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{rows.map((e, i) => (
<Cell key={i} fill={e.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1C4)
function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const navigate = useNavigate()
const [period, setPeriod] = useState(30)
const [bundle, setBundle] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const daysReq = period === 9999 ? 3650 : period
setLoading(true)
Promise.all([
api.getHistoryOverviewViz(daysReq),
api.getWeightEnergyCorrelationChart(14),
api.getLbmProteinCorrelationChart(14),
api.getLoadVitalsCorrelationChart(14),
api.getRecoveryPerformanceChart(),
])
.then(([overview, chartC1, chartC2, chartC3, chartC4]) => {
if (!cancelled) {
setBundle({ overview, chartC1, chartC2, chartC3, chartC4 })
setErr(null)
}
}) })
.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>
)
}
const data = bundle?.overview
const chartC1 = bundle?.chartC1
const chartC2 = bundle?.chartC2
const chartC3 = bundle?.chartC3
const chartC4 = bundle?.chartC4
const lag = data?.lag_correlations || {}
const c4drivers = lag.recovery_performance?.drivers || []
const sections = data?.sections || []
const confUi = overviewConfidenceUi(data?.confidence)
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 }}> <div
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf Ernährung</strong> (gleiche Datenbasis). style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 10,
marginBottom: 14,
padding: '10px 12px',
borderRadius: 12,
border: '1px solid var(--border)',
background: getStatusBg(confUi.tone),
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
</div>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
Ernährung
</button>
. Die Kacheln C1C4 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
</p> </p>
{/* Chart: Calorie balance */} <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
<div className="card" style={{marginBottom:12}}> {sections.map((sec) => {
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> const tone = overviewSectionTone(sec)
Kalorienbilanz (Aufnahme TDEE {tdee} kcal) const stripe = getStatusColor(tone)
</div> const badgeBg = getStatusBg(tone)
<ResponsiveContainer width="100%" height={160}> return (
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}> <div
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> key={sec.id}
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} style={{
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/> borderRadius: 12,
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> border: '1px solid var(--border)',
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/> borderLeft: `5px solid ${stripe}`,
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}} background: 'var(--surface)',
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/> padding: '12px 12px 12px 14px',
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/> boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/> }}
</LineChart> >
</ResponsiveContainer> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag <span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 30,
height: 30,
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
color: stripe,
background: badgeBg,
}}
>
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
</span>
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
</div> </div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
>
Öffnen
</button>
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
{/* Chart 3: Protein vs Lean Mass */} {(sec.kpi_short || []).length > 0 && (
{protVsLean.length >= 3 && ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
<div className="card" style={{marginBottom:12}}> {(sec.kpi_short || []).map((k, i) => (
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> <div
🥩 Protein vs. Magermasse key={i}
</div> style={{
<ResponsiveContainer width="100%" height={160}> padding: '8px 10px',
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}> borderRadius: 10,
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> background: getStatusBg(k.status || 'good'),
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> border: `1px solid ${getStatusColor(k.status || 'good')}55`,
<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']}/> >
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5} <div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/> <div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}} {k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
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:4,fill:'#7F77DD'}} name="lean"/>
</LineChart>
</ResponsiveContainer>
<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>
</div>
</div>
)}
{/* Correlation insights */}
{corrInsights.length > 0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
{corrInsights.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 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>
)} )}
{(sec.interpretation_short || []).map((it, i) => (
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
</div>
))}
{(sec.heuristic_short || []).map((h, i) => (
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
</div>
))}
{(sec.insights_short || []).map((ins, i) => (
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<strong>{ins.title}</strong>
<div>{ins.body}</div>
</div>
))}
</div>
)
})}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (C1C3)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
<CorrelationScatterTile title="C1 Energiebilanz ↔ Gewicht" accent="#1D9E75" payload={chartC1} />
<CorrelationScatterTile title="C2 Protein ↔ Magermasse" accent="#3B82F6" payload={chartC2} />
<CorrelationScatterTile title="C3 Last ↔ HRV/RHR" accent="#F59E0B" payload={chartC3} />
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} /> <InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div> </div>
) )
@ -1440,23 +1724,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 +1749,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 +1769,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 +1817,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}`),