Merge pull request 'Überartbeitung History - neue Gesamtübersicht' (#99) from develop into main
Reviewed-on: #99
This commit is contained in:
commit
41d809c68c
|
|
@ -40,16 +40,36 @@ def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_day
|
|||
'data_points': N
|
||||
}
|
||||
"""
|
||||
if var1 == 'energy' and var2 == 'weight':
|
||||
return _correlate_energy_weight(profile_id, max_lag_days)
|
||||
elif var1 == 'protein' and var2 == 'lbm':
|
||||
return _correlate_protein_lbm(profile_id, max_lag_days)
|
||||
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
||||
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
||||
v1 = (var1 or "").strip().lower()
|
||||
if v1 in ("energy", "energy_balance"):
|
||||
v1n = "energy"
|
||||
elif v1 in ("training_load", "load"):
|
||||
v1n = "training_load"
|
||||
elif v1 == "protein":
|
||||
v1n = "protein"
|
||||
else:
|
||||
v1n = v1
|
||||
|
||||
if v1n == 'energy' and var2 == 'weight':
|
||||
return _normalize_lag_payload(_correlate_energy_weight(profile_id, max_lag_days))
|
||||
elif v1n == 'protein' and var2 == 'lbm':
|
||||
return _normalize_lag_payload(_correlate_protein_lbm(profile_id, max_lag_days))
|
||||
elif v1n == 'training_load' and var2 in ['hrv', 'rhr']:
|
||||
return _normalize_lag_payload(_correlate_load_vitals(profile_id, var2, max_lag_days))
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]:
|
||||
"""Charts erwarten u. a. ``best_lag_days``; Layer liefert teils ``best_lag``."""
|
||||
if not raw:
|
||||
return None
|
||||
out = dict(raw)
|
||||
if out.get("best_lag_days") is None and out.get("best_lag") is not None:
|
||||
out["best_lag_days"] = out["best_lag"]
|
||||
return out
|
||||
|
||||
|
||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||
"""
|
||||
Correlate energy balance with weight change
|
||||
|
|
|
|||
240
backend/data_layer/history_overview_viz.py
Normal file
240
backend/data_layer/history_overview_viz.py
Normal 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 (C1–C4).
|
||||
"""
|
||||
eff = max(7, min(int(days), 9999))
|
||||
body = get_body_history_viz_bundle(profile_id, eff)
|
||||
nutr = get_nutrition_history_viz_bundle(profile_id, eff)
|
||||
fit = get_fitness_dashboard_viz_bundle(profile_id, eff)
|
||||
rec = get_recovery_dashboard_viz_bundle(profile_id, eff)
|
||||
|
||||
c1 = calculate_lag_correlation(profile_id, "energy_balance", "weight", 14)
|
||||
c2 = calculate_lag_correlation(profile_id, "protein", "lbm", 14)
|
||||
c3_hrv = calculate_lag_correlation(profile_id, "load", "hrv", 14)
|
||||
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
||||
c3 = None
|
||||
if c3_hrv and c3_rhr:
|
||||
c3 = (
|
||||
c3_hrv
|
||||
if abs(float(c3_hrv.get("correlation") or 0)) >= abs(float(c3_rhr.get("correlation") or 0))
|
||||
else c3_rhr
|
||||
)
|
||||
if c3 is c3_hrv:
|
||||
c3 = dict(c3)
|
||||
c3["metric"] = "HRV"
|
||||
else:
|
||||
c3 = dict(c3_rhr)
|
||||
c3["metric"] = "RHR"
|
||||
elif c3_hrv:
|
||||
c3 = dict(c3_hrv)
|
||||
c3["metric"] = "HRV"
|
||||
elif c3_rhr:
|
||||
c3 = dict(c3_rhr)
|
||||
c3["metric"] = "RHR"
|
||||
|
||||
drivers = calculate_top_drivers(profile_id)
|
||||
|
||||
b_sum = body.get("summary") if isinstance(body.get("summary"), dict) else {}
|
||||
last_w = b_sum.get("weight_kg")
|
||||
|
||||
fs = fit.get("summary") if isinstance(fit.get("summary"), dict) else {}
|
||||
if fit.get("has_activity_entries"):
|
||||
ac = int(fs.get("activity_count") or 0)
|
||||
fitness_line = f"{ac} Trainingseinheiten im gewählten Fenster"
|
||||
else:
|
||||
fitness_line = fit.get("message") or "Keine Trainingsdaten"
|
||||
|
||||
drv_list = drivers if isinstance(drivers, list) else []
|
||||
|
||||
return {
|
||||
"days_requested": days,
|
||||
"effective_window_days": eff,
|
||||
"confidence": _overview_confidence(body, nutr, fit, rec),
|
||||
"sections": [
|
||||
{
|
||||
"id": "body",
|
||||
"title": "Körper",
|
||||
"tab_id": "body",
|
||||
"summary_line": (
|
||||
f"Letztes Gewicht: {last_w} kg"
|
||||
if last_w is not None
|
||||
else "Keine Gewichtsdaten im Fenster"
|
||||
),
|
||||
"interpretation_short": _short_body_interpretation_tiles(body.get("interpretation_tiles"), 3),
|
||||
},
|
||||
{
|
||||
"id": "nutrition",
|
||||
"title": "Ernährung",
|
||||
"tab_id": "nutrition",
|
||||
"summary_line": (
|
||||
f"Ø {round(float((nutr.get('summary') or {}).get('kcal_avg') or 0))} kcal/Tag"
|
||||
if nutr.get("has_nutrition_entries")
|
||||
else (nutr.get("message") or "Keine Ernährungsdaten")
|
||||
),
|
||||
"kpi_short": _take_kpis(nutr.get("kpi_tiles"), 4),
|
||||
"heuristic_short": (nutr.get("nutrition_correlation_heuristics") or [])[:2],
|
||||
},
|
||||
{
|
||||
"id": "fitness",
|
||||
"title": "Fitness",
|
||||
"tab_id": "activity",
|
||||
"summary_line": fitness_line,
|
||||
"kpi_short": _take_kpis(fit.get("kpi_tiles"), 4),
|
||||
"insights_short": _take_insights(fit.get("progress_insights"), 2),
|
||||
},
|
||||
{
|
||||
"id": "recovery",
|
||||
"title": "Erholung",
|
||||
"tab_id": "activity",
|
||||
"summary_line": "Schlaf & Vitalwerte"
|
||||
if rec.get("has_recovery_data")
|
||||
else (rec.get("message") or "Keine Erholungsdaten"),
|
||||
"kpi_short": _take_kpis(rec.get("kpi_tiles"), 4),
|
||||
"insights_short": _take_insights(rec.get("progress_insights"), 2),
|
||||
},
|
||||
],
|
||||
"lag_correlations": {
|
||||
"weight_energy": _compact_lag("C1 Energiebilanz ↔ Gewicht", c1),
|
||||
"protein_lbm": _compact_lag("C2 Protein ↔ Magermasse", c2),
|
||||
"load_vitals": _compact_lag(
|
||||
f"C3 Last ↔ {(c3 or {}).get('metric') or 'Vital'}",
|
||||
c3,
|
||||
extra_keys=("metric",),
|
||||
),
|
||||
"recovery_performance": {
|
||||
"label": "C4 Top-Treiber (Einflussfaktoren)",
|
||||
"drivers": drv_list[:8],
|
||||
},
|
||||
},
|
||||
"meta": {
|
||||
"layer_1": "composed_metrics",
|
||||
"layer_2b": "history_overview_viz",
|
||||
"issue": "53-history-overview",
|
||||
"sources": {
|
||||
"body": "body_viz",
|
||||
"nutrition": "nutrition_viz",
|
||||
"fitness": "fitness_viz",
|
||||
"recovery": "recovery_viz",
|
||||
"lag": "correlations.calculate_lag_correlation",
|
||||
"drivers": "correlations.calculate_top_drivers",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overview_confidence(b: Dict, n: Dict, f: Dict, r: Dict) -> str:
|
||||
scores = []
|
||||
for x in (b, n, f, r):
|
||||
c = x.get("confidence")
|
||||
if c == "high":
|
||||
scores.append(3)
|
||||
elif c == "medium":
|
||||
scores.append(2)
|
||||
elif c == "low":
|
||||
scores.append(1)
|
||||
else:
|
||||
scores.append(0)
|
||||
s = sum(scores) / max(len(scores), 1)
|
||||
if s >= 2.5:
|
||||
return "high"
|
||||
if s >= 1.5:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _compact_lag(
|
||||
label: str,
|
||||
payload: Optional[Dict[str, Any]],
|
||||
extra_keys: tuple = (),
|
||||
) -> Dict[str, Any]:
|
||||
if not payload:
|
||||
return {"label": label, "available": False}
|
||||
out: Dict[str, Any] = {
|
||||
"label": label,
|
||||
"available": payload.get("correlation") is not None,
|
||||
"correlation": payload.get("correlation"),
|
||||
"best_lag_days": payload.get("best_lag_days", payload.get("best_lag")),
|
||||
"confidence": payload.get("confidence"),
|
||||
"interpretation": payload.get("interpretation", ""),
|
||||
"data_points": payload.get("data_points"),
|
||||
}
|
||||
for k in extra_keys:
|
||||
if k in payload:
|
||||
out[k] = payload[k]
|
||||
return out
|
||||
64
backend/data_layer/nutrition_body_merge.py
Normal file
64
backend/data_layer/nutrition_body_merge.py
Normal 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
|
||||
|
|
@ -217,3 +217,107 @@ def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[
|
|||
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
|
||||
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)},
|
||||
]
|
||||
|
||||
|
||||
def build_nutrition_correlation_heuristic_items(
|
||||
merged_rows: List[Dict[str, Any]],
|
||||
tdee_kcal: float,
|
||||
protein_target_low_g: float,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx,
|
||||
TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend.
|
||||
"""
|
||||
filtered = [
|
||||
r
|
||||
for r in merged_rows
|
||||
if r.get("kcal") is not None and r.get("weight") is not None
|
||||
]
|
||||
if len(filtered) < 5:
|
||||
return []
|
||||
|
||||
td = float(tdee_kcal)
|
||||
latest_w = float(filtered[-1].get("weight") or 0) or 80.0
|
||||
pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6))
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
if len(filtered) >= 14:
|
||||
high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200]
|
||||
low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200]
|
||||
if len(high_k) >= 3 and len(low_k) >= 3:
|
||||
avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k)
|
||||
avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k)
|
||||
avg_wh_r = round(avg_wh * 10) / 10
|
||||
avg_wl_r = round(avg_wl * 10) / 10
|
||||
items.append(
|
||||
{
|
||||
"icon": "📊",
|
||||
"status": "good" if avg_wl < avg_wh else "warn",
|
||||
"title": (
|
||||
f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss"
|
||||
if avg_wl < avg_wh
|
||||
else "Kein klarer Kalorieneffekt auf Gewicht erkennbar"
|
||||
),
|
||||
"detail": (
|
||||
f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · "
|
||||
f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
prot_vs_lean = [
|
||||
d
|
||||
for d in filtered
|
||||
if d.get("protein_g") is not None and d.get("lean_mass") is not None
|
||||
]
|
||||
if len(prot_vs_lean) >= 3:
|
||||
high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low]
|
||||
low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low]
|
||||
if len(high_p) >= 2 and len(low_p) >= 2:
|
||||
avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p)
|
||||
avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p)
|
||||
avg_lh_r = round(avg_lh * 10) / 10
|
||||
avg_ll_r = round(avg_ll * 10) / 10
|
||||
items.append(
|
||||
{
|
||||
"icon": "🥩",
|
||||
"status": "good" if avg_lh >= avg_ll else "warn",
|
||||
"title": (
|
||||
f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg"
|
||||
),
|
||||
"detail": (
|
||||
f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None]
|
||||
avg_balance = int(round(sum(balances) / len(balances))) if balances else 0
|
||||
ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance)
|
||||
if avg_balance < -100:
|
||||
ic, st = "✅", "good"
|
||||
elif avg_balance > 200:
|
||||
ic, st = "⬆️", "warn" if avg_balance > 300 else "good"
|
||||
else:
|
||||
ic, st = "➡️", "good"
|
||||
|
||||
if avg_balance < -500:
|
||||
bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen."
|
||||
elif avg_balance < -100:
|
||||
bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt."
|
||||
elif avg_balance > 300:
|
||||
bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich."
|
||||
else:
|
||||
bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben."
|
||||
|
||||
items.append(
|
||||
{
|
||||
"icon": ic,
|
||||
"status": st,
|
||||
"title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag",
|
||||
"detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ from datetime import date, datetime, timedelta
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
||||
from data_layer.nutrition_interpretation import (
|
||||
build_energy_availability_kpi_tile,
|
||||
build_macro_donut_from_averages,
|
||||
build_nutrition_correlation_heuristic_items,
|
||||
build_nutrition_history_kpi_tiles,
|
||||
)
|
||||
from data_layer.nutrition_metrics import (
|
||||
|
|
@ -112,6 +114,58 @@ def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Di
|
|||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _filter_merged_rows_by_cutoff(
|
||||
merged: List[Dict[str, Any]], cutoff: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not cutoff:
|
||||
return list(merged)
|
||||
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
|
||||
|
||||
|
||||
def _calorie_balance_daily_series(
|
||||
merged_filtered: List[Dict[str, Any]], tdee: float
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for r in merged_filtered:
|
||||
if r.get("kcal") is None:
|
||||
continue
|
||||
ds = _iso(r.get("date"))
|
||||
if not ds:
|
||||
continue
|
||||
bal = round(float(r["kcal"]) - float(tdee))
|
||||
rows.append({"date": ds, "balance_kcal": bal})
|
||||
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for x in rolled:
|
||||
out.append(
|
||||
{
|
||||
"date": x["date"],
|
||||
"balance_kcal": x.get("balance_kcal"),
|
||||
"balance_kcal_avg": x.get("balance_kcal_avg"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in merged_filtered:
|
||||
if r.get("protein_g") is None or r.get("lean_mass") is None:
|
||||
continue
|
||||
ds = _iso(r.get("date"))
|
||||
if not ds:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"date": ds,
|
||||
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
|
||||
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _kcal_weight_points_for_window(
|
||||
profile_id: str, cutoff: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
|
@ -187,6 +241,9 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"energy_balance_meta": {},
|
||||
"interpretation_tiles": [],
|
||||
"energy_availability_warning": None,
|
||||
"calorie_balance_daily": [],
|
||||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
||||
"nutrition_correlation_heuristics": [],
|
||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +296,19 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
||||
|
||||
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
|
||||
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
|
||||
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
|
||||
calorie_balance_daily: List[Dict[str, Any]] = (
|
||||
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
|
||||
)
|
||||
pl_points = _protein_lean_mass_points(merged_win)
|
||||
nutrition_correlation_heuristics = (
|
||||
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
|
||||
if tdee_eff > 0
|
||||
else []
|
||||
)
|
||||
|
||||
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
||||
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
||||
|
||||
|
|
@ -286,6 +356,12 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"confidence": energy_meta.get("confidence"),
|
||||
"data_points": energy_meta.get("data_points"),
|
||||
},
|
||||
"calorie_balance_daily": calorie_balance_daily,
|
||||
"protein_vs_lean_mass": {
|
||||
"points": pl_points,
|
||||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
||||
},
|
||||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
||||
"meta": {
|
||||
"layer_1": "nutrition_metrics",
|
||||
"layer_2b": "nutrition_viz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
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 = []
|
||||
debt_values = []
|
||||
labels: list[str] = []
|
||||
debt_values: list[float] = []
|
||||
for r in visible:
|
||||
rd = r.get("date")
|
||||
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))
|
||||
|
||||
# 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 {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
|
|
@ -360,13 +377,14 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
|
|||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": calculate_confidence(len(visible), days, "general"),
|
||||
"data_points": len(visible),
|
||||
"data_points": len(labels),
|
||||
"current_debt_hours": round(float(current_debt), 1),
|
||||
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
||||
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
||||
"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 "
|
||||
"(jeder Punkt = Fensterende an dem Datum). Ziel aktuell nicht in den Profileinstellungen änderbar.",
|
||||
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
|
||||
"Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
|
||||
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from data_layer.body_viz import get_body_history_viz_bundle
|
|||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
||||
from data_layer.history_overview_viz import get_history_overview_viz_bundle
|
||||
from data_layer.recovery_chart_payloads import (
|
||||
build_recovery_score_chart_payload,
|
||||
build_hrv_rhr_baseline_chart_payload,
|
||||
|
|
@ -336,6 +337,24 @@ def get_recovery_dashboard_viz(
|
|||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/history-overview-viz")
|
||||
def get_history_overview_viz(
|
||||
days: int = Query(
|
||||
default=30,
|
||||
ge=7,
|
||||
le=9999,
|
||||
description="Analysefenster in Tagen (komponiert Körper/Ernährung/Fitness/Erholung)",
|
||||
),
|
||||
session: dict = Depends(require_auth),
|
||||
) -> Dict:
|
||||
"""
|
||||
Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1–C4 (Metadaten).
|
||||
"""
|
||||
profile_id = session["profile_id"]
|
||||
bundle = get_history_overview_viz_bundle(profile_id, days)
|
||||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/circumferences")
|
||||
def get_circumferences_chart(
|
||||
max_age_days: int = Query(default=90, ge=7, le=365),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from db import get_db, get_cursor, r2d
|
|||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map
|
||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
||||
|
||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -179,38 +179,9 @@ def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=
|
|||
|
||||
@router.get("/correlations")
|
||||
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get nutrition data correlated with weight and body fat."""
|
||||
"""Get nutrition data correlated with weight and body fat (Layer 1 Merge, siehe nutrition_body_merge)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||
nutr={r['date']:r2d(r) for r in cur.fetchall()}
|
||||
cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||
wlog={r['date']:r['weight'] for r in cur.fetchall()}
|
||||
cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||
cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
|
||||
all_dates=sorted(set(list(nutr)+list(wlog)))
|
||||
mi,last_cal,cal_by_date=0,{},{}
|
||||
for d in all_dates:
|
||||
while mi<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
|
||||
return build_merged_daily_nutrition_body_rows(pid)
|
||||
|
||||
|
||||
@router.get("/weekly")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useProfile } from '../context/ProfileContext'
|
|||
import {
|
||||
LineChart, Line, BarChart, Bar,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
||||
ReferenceLine, PieChart, Pie, Cell, ComposedChart,
|
||||
ScatterChart, Scatter,
|
||||
} from 'recharts'
|
||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
|
@ -966,6 +967,11 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
const weeklyMacro = viz.weekly_macro_chart
|
||||
const wmLoading = false
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -985,6 +991,9 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
<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).{' '}
|
||||
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen Mifflin–St 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>
|
||||
|
||||
<NutritionGoalsStrip grouped={groupedGoals} />
|
||||
|
|
@ -999,6 +1008,95 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
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 style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Makroverteilung täglich (g) · Fokus Protein
|
||||
|
|
@ -1148,174 +1246,360 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
)
|
||||
}
|
||||
|
||||
// ── Correlation Section ───────────────────────────────────────────────────────
|
||||
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
||||
if (filtered.length < 5) return (
|
||||
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
||||
)
|
||||
function overviewSectionTone(sec) {
|
||||
const kpis = sec.kpi_short || []
|
||||
if (kpis.some((k) => k.status === 'bad')) return 'bad'
|
||||
if (kpis.some((k) => k.status === 'warn')) return 'warn'
|
||||
const interp = sec.interpretation_short || []
|
||||
if (interp.some((x) => x.status === 'bad')) return 'bad'
|
||||
if (interp.some((x) => x.status === 'warn')) return 'warn'
|
||||
const heur = sec.heuristic_short || []
|
||||
if (heur.some((h) => h.status === 'warn')) return 'warn'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
const sex = profile?.sex||'m'
|
||||
const height = profile?.height||178
|
||||
const latestW = filtered[filtered.length-1]?.weight||80
|
||||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
|
||||
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
|
||||
const tdee = Math.round(bmr*1.4) // light activity baseline
|
||||
function overviewConfidenceUi(conf) {
|
||||
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
|
||||
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
|
||||
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
|
||||
}
|
||||
|
||||
// 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}))
|
||||
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) }))
|
||||
}
|
||||
|
||||
// Chart 3: Activity kcal vs Weight change
|
||||
const actVsW = filtered.filter(d=>d.weight)
|
||||
.map((d,i,arr)=>{
|
||||
const prev = arr[i-1]
|
||||
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',
|
||||
}))
|
||||
}
|
||||
if (fallbackDrivers?.length) {
|
||||
return fallbackDrivers.map((d) => {
|
||||
const { v, fill } = driverBarFromStatus(d.status)
|
||||
return {
|
||||
date: fmtDate(d.date),
|
||||
weight: d.weight,
|
||||
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
|
||||
kcal: d.kcal||0,
|
||||
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'),
|
||||
value: v,
|
||||
fill,
|
||||
subtitle: d.reason,
|
||||
}
|
||||
}).filter(d=>d.weightDelta!==null)
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 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)
|
||||
function CorrelationScatterTile({ title, accent, payload }) {
|
||||
const meta = payload?.metadata || {}
|
||||
const pts = chartJsScatterPoints(payload)
|
||||
const hasChart = pts.length > 0 && meta.correlation != null
|
||||
const r = Number(meta.correlation)
|
||||
const strength =
|
||||
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
||||
|
||||
// ── Correlation insights ──
|
||||
const corrInsights = []
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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`,
|
||||
function DriversImpactTile({ payload, driversFallback }) {
|
||||
const meta = payload?.metadata || {}
|
||||
const rows = chartJsBarRows(payload, driversFallback)
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 C1–C4) ──────────────────
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Protein → Lean mass
|
||||
if (protVsLean.length >= 3) {
|
||||
const ptLow = Math.round(latestW*1.6)
|
||||
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
|
||||
const lowProt = protVsLean.filter(d=>d.protein<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.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const data = bundle?.overview
|
||||
const chartC1 = bundle?.chartC1
|
||||
const chartC2 = bundle?.chartC2
|
||||
const chartC3 = bundle?.chartC3
|
||||
const chartC4 = bundle?.chartC4
|
||||
|
||||
// 3. Avg balance
|
||||
corrInsights.push({
|
||||
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
|
||||
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
|
||||
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
|
||||
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
|
||||
avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.':
|
||||
avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.':
|
||||
avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.':
|
||||
'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`,
|
||||
})
|
||||
const lag = data?.lag_correlations || {}
|
||||
const c4drivers = lag.recovery_performance?.drivers || []
|
||||
const sections = data?.sections || []
|
||||
const confUi = overviewConfidenceUi(data?.confidence)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🔗 Korrelationen"/>
|
||||
<SectionHeader title="📊 Gesamtansicht" />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
|
||||
<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).
|
||||
</p>
|
||||
|
||||
{/* Chart: Calorie balance */}
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||||
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={balWithAvg} 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(balWithAvg.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_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
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={protVsLean} 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']}/>
|
||||
<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'}}/>
|
||||
<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: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>
|
||||
)}
|
||||
<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 C1–C4 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
|
||||
</p>
|
||||
|
||||
{/* 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 style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
|
||||
{sections.map((sec) => {
|
||||
const tone = overviewSectionTone(sec)
|
||||
const stripe = getStatusColor(tone)
|
||||
const badgeBg = getStatusBg(tone)
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `5px solid ${stripe}`,
|
||||
background: 'var(--surface)',
|
||||
padding: '12px 12px 12px 14px',
|
||||
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<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>
|
||||
<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: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 style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
{(sec.kpi_short || []).length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
|
||||
{(sec.kpi_short || []).map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
background: getStatusBg(k.status || 'good'),
|
||||
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
|
||||
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
|
||||
</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 (C1–C3)</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} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1440,23 +1724,22 @@ function PhotoGrid() {
|
|||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
const TABS = [
|
||||
{ id:'overview', label:'📊 Gesamt' },
|
||||
{ id:'body', label:'⚖️ Körper' },
|
||||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||||
{ id:'activity', label:'🏋️ Fitness' },
|
||||
{ id:'correlation', label:'🔗 Korrelation' },
|
||||
{ id:'photos', label:'📷 Fotos' },
|
||||
]
|
||||
|
||||
export default function History() {
|
||||
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
||||
const location = useLocation?.() || {}
|
||||
const [tab, setTab] = useState((location.state?.tab)||'body')
|
||||
const [tab, setTab] = useState((location.state?.tab) || 'overview')
|
||||
const [weights, setWeights] = useState([])
|
||||
const [calipers, setCalipers] = useState([])
|
||||
const [circs, setCircs] = useState([])
|
||||
const [nutrition, setNutrition] = useState([])
|
||||
const [activities, setActivities] = useState([])
|
||||
const [corrData, setCorrData] = useState([])
|
||||
const [insights, setInsights] = useState([])
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [profile, setProfile] = useState(null)
|
||||
|
|
@ -1466,11 +1749,11 @@ export default function History() {
|
|||
const loadAll = () => Promise.all([
|
||||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||||
api.listNutrition(90), api.listActivity(25_000),
|
||||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
||||
api.latestInsights(), api.getProfile(),
|
||||
api.listPrompts(),
|
||||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
||||
]).then(([w,ca,ci,n,a,ins,p,pr])=>{
|
||||
setWeights(w); setCalipers(ca); setCircs(ci)
|
||||
setNutrition(n); setActivities(a); setCorrData(corr)
|
||||
setNutrition(n); setActivities(a)
|
||||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||||
setPrompts(Array.isArray(pr)?pr:[])
|
||||
setLoading(false)
|
||||
|
|
@ -1486,6 +1769,10 @@ export default function History() {
|
|||
setTab('activity')
|
||||
return
|
||||
}
|
||||
if (t === 'correlation') {
|
||||
setTab('nutrition')
|
||||
return
|
||||
}
|
||||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||||
}, [location.state?.tab])
|
||||
|
||||
|
|
@ -1530,10 +1817,10 @@ export default function History() {
|
|||
</div>
|
||||
</nav>
|
||||
<div className="history-content">
|
||||
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||
{tab==='photos' && <PhotoGrid/>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -643,6 +643,11 @@ export const api = {
|
|||
getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`),
|
||||
/** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */
|
||||
getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`),
|
||||
getHistoryOverviewViz: (days=30) => req(`/charts/history-overview-viz?days=${days}`),
|
||||
getWeightEnergyCorrelationChart: (maxLag=14) => req(`/charts/weight-energy-correlation?max_lag=${maxLag}`),
|
||||
getLbmProteinCorrelationChart: (maxLag=14) => req(`/charts/lbm-protein-correlation?max_lag=${maxLag}`),
|
||||
getLoadVitalsCorrelationChart: (maxLag=14) => req(`/charts/load-vitals-correlation?max_lag=${maxLag}`),
|
||||
getRecoveryPerformanceChart: () => req('/charts/recovery-performance'),
|
||||
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
||||
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
||||
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user