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