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
|
'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",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Any, Dict, Optional, Set
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
|
|
@ -333,14 +333,31 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = []
|
labels: list[str] = []
|
||||||
debt_values = []
|
debt_values: list[float] = []
|
||||||
for r in visible:
|
for r in visible:
|
||||||
rd = r.get("date")
|
rd = r.get("date")
|
||||||
end_d = rd.date() if isinstance(rd, datetime) else rd
|
end_d = rd.date() if isinstance(rd, datetime) else rd
|
||||||
labels.append(end_d.isoformat() if hasattr(end_d, "isoformat") else str(end_d))
|
if not isinstance(end_d, date):
|
||||||
|
continue
|
||||||
|
labels.append(end_d.isoformat())
|
||||||
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
|
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
|
||||||
|
|
||||||
|
# KPI nutzt immer Fensterende = heute; die Kurve endete bisher am Datum der letzten Schlaf-Zeile
|
||||||
|
# (z. B. gestern) → anderes 14-Tage-Fenster. Letzter Punkt = exakt KPI-Wert, Datum = heute.
|
||||||
|
today = datetime.now().date()
|
||||||
|
if labels and debt_values:
|
||||||
|
try:
|
||||||
|
last_d = date.fromisoformat(labels[-1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
last_d = None
|
||||||
|
if last_d is not None:
|
||||||
|
if last_d < today:
|
||||||
|
labels.append(today.isoformat())
|
||||||
|
debt_values.append(float(current_debt))
|
||||||
|
elif last_d == today:
|
||||||
|
debt_values[-1] = float(current_debt)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"chart_type": "line",
|
"chart_type": "line",
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -360,13 +377,14 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
|
||||||
"metadata": serialize_dates(
|
"metadata": serialize_dates(
|
||||||
{
|
{
|
||||||
"confidence": calculate_confidence(len(visible), days, "general"),
|
"confidence": calculate_confidence(len(visible), days, "general"),
|
||||||
"data_points": len(visible),
|
"data_points": len(labels),
|
||||||
"current_debt_hours": round(float(current_debt), 1),
|
"current_debt_hours": round(float(current_debt), 1),
|
||||||
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
||||||
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
||||||
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
|
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
|
||||||
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster "
|
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
|
||||||
"(jeder Punkt = Fensterende an dem Datum). Ziel aktuell nicht in den Profileinstellungen änderbar.",
|
"Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
|
||||||
|
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { useProfile } from '../context/ProfileContext'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar,
|
LineChart, Line, BarChart, Bar,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
ReferenceLine, PieChart, Pie, Cell, ComposedChart,
|
||||||
|
ScatterChart, Scatter,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
@ -966,6 +967,11 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
||||||
const weeklyMacro = viz.weekly_macro_chart
|
const weeklyMacro = viz.weekly_macro_chart
|
||||||
const wmLoading = false
|
const wmLoading = false
|
||||||
const wmError = null
|
const wmError = null
|
||||||
|
const balDaily = viz.calorie_balance_daily || []
|
||||||
|
const plm = viz.protein_vs_lean_mass || {}
|
||||||
|
const plmPts = plm.points || []
|
||||||
|
const nutHeur = viz.nutrition_correlation_heuristics || []
|
||||||
|
const tdeeRef = viz.tdee_reference_kcal
|
||||||
|
|
||||||
if (!cdMacro.length || n === 0) {
|
if (!cdMacro.length || n === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -985,6 +991,9 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
||||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||||
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
|
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
|
||||||
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
|
<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>
|
</p>
|
||||||
|
|
||||||
<NutritionGoalsStrip grouped={groupedGoals} />
|
<NutritionGoalsStrip grouped={groupedGoals} />
|
||||||
|
|
@ -999,6 +1008,95 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
||||||
allTime={period === 9999}
|
allTime={period === 9999}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{balDaily.length > 0 && tdeeRef != null && (
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||||
|
Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart
|
||||||
|
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
|
||||||
|
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
|
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
|
formatter={(v, n) => [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
|
||||||
|
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plmPts.length >= 3 && (
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Protein vs. Magermasse (Caliper, forward-filled)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||||
|
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
|
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
{plm.protein_target_low_g > 0 && (
|
||||||
|
<ReferenceLine
|
||||||
|
yAxisId="prot"
|
||||||
|
y={plm.protein_target_low_g}
|
||||||
|
stroke="#1D9E75"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
|
formatter={(v, n) => [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']}
|
||||||
|
/>
|
||||||
|
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
|
||||||
|
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nutHeur.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung — Kurz-Einordnung</div>
|
||||||
|
{nutHeur.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
|
||||||
|
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
Makroverteilung täglich (g) · Fokus Protein
|
Makroverteilung täglich (g) · Fokus Protein
|
||||||
|
|
@ -1148,173 +1246,359 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Correlation Section ───────────────────────────────────────────────────────
|
function overviewSectionTone(sec) {
|
||||||
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
const kpis = sec.kpi_short || []
|
||||||
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
if (kpis.some((k) => k.status === 'bad')) return 'bad'
|
||||||
if (filtered.length < 5) return (
|
if (kpis.some((k) => k.status === 'warn')) return 'warn'
|
||||||
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
const interp = sec.interpretation_short || []
|
||||||
)
|
if (interp.some((x) => x.status === 'bad')) return 'bad'
|
||||||
|
if (interp.some((x) => x.status === 'warn')) return 'warn'
|
||||||
const sex = profile?.sex||'m'
|
const heur = sec.heuristic_short || []
|
||||||
const height = profile?.height||178
|
if (heur.some((h) => h.status === 'warn')) return 'warn'
|
||||||
const latestW = filtered[filtered.length-1]?.weight||80
|
return 'good'
|
||||||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
|
|
||||||
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
|
|
||||||
const tdee = Math.round(bmr*1.4) // light activity baseline
|
|
||||||
|
|
||||||
// Protein vs Lean Mass (only days with both)
|
|
||||||
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
|
|
||||||
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
|
|
||||||
|
|
||||||
// Chart 3: Activity kcal vs Weight change
|
|
||||||
const actVsW = filtered.filter(d=>d.weight)
|
|
||||||
.map((d,i,arr)=>{
|
|
||||||
const prev = arr[i-1]
|
|
||||||
return {
|
|
||||||
date: fmtDate(d.date),
|
|
||||||
weight: d.weight,
|
|
||||||
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
|
|
||||||
kcal: d.kcal||0,
|
|
||||||
}
|
}
|
||||||
}).filter(d=>d.weightDelta!==null)
|
|
||||||
|
|
||||||
// Chart 4: Calorie balance (intake - estimated TDEE)
|
function overviewConfidenceUi(conf) {
|
||||||
const balance = filtered.map(d=>({
|
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
|
||||||
date: fmtDate(d.date),
|
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
|
||||||
balance: Math.round((d.kcal||0) - tdee),
|
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartJsScatterPoints(payload) {
|
||||||
|
const raw = payload?.data?.datasets?.[0]?.data || []
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverBarFromStatus(st) {
|
||||||
|
const s = String(st || '').toLowerCase()
|
||||||
|
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
||||||
|
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
|
||||||
|
return { v: 0.15, fill: '#6B7280' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartJsBarRows(payload, fallbackDrivers) {
|
||||||
|
const labels = payload?.data?.labels || []
|
||||||
|
const values = payload?.data?.datasets?.[0]?.data || []
|
||||||
|
const colors = payload?.data?.datasets?.[0]?.backgroundColor
|
||||||
|
if (labels.length && values.length) {
|
||||||
|
return labels.map((name, i) => ({
|
||||||
|
name: name.length > 42 ? `${name.slice(0, 40)}…` : name,
|
||||||
|
value: Number(values[i]),
|
||||||
|
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
|
||||||
}))
|
}))
|
||||||
const balWithAvg = rollingAvg(balance,'balance')
|
}
|
||||||
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length)
|
if (fallbackDrivers?.length) {
|
||||||
|
return fallbackDrivers.map((d) => {
|
||||||
// ── Correlation insights ──
|
const { v, fill } = driverBarFromStatus(d.status)
|
||||||
const corrInsights = []
|
return {
|
||||||
|
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'),
|
||||||
// 1. Kcal → Weight correlation
|
value: v,
|
||||||
if (filtered.length >= 14) {
|
fill,
|
||||||
const highKcal = filtered.filter(d=>d.kcal>tdee+200)
|
subtitle: d.reason,
|
||||||
const lowKcal = filtered.filter(d=>d.kcal<tdee-200)
|
}
|
||||||
if (highKcal.length>=3 && lowKcal.length>=3) {
|
|
||||||
const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10
|
|
||||||
const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10
|
|
||||||
corrInsights.push({
|
|
||||||
icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn',
|
|
||||||
title: avgWLow < avgWHigh
|
|
||||||
? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss`
|
|
||||||
: `Kein klarer Kalorieneffekt auf Gewicht erkennbar`,
|
|
||||||
detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Protein → Lean mass
|
function CorrelationScatterTile({ title, accent, payload }) {
|
||||||
if (protVsLean.length >= 3) {
|
const meta = payload?.metadata || {}
|
||||||
const ptLow = Math.round(latestW*1.6)
|
const pts = chartJsScatterPoints(payload)
|
||||||
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
|
const hasChart = pts.length > 0 && meta.correlation != null
|
||||||
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
|
const r = Number(meta.correlation)
|
||||||
if (highProt.length>=2 && lowProt.length>=2) {
|
const strength =
|
||||||
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10
|
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
||||||
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
|
|
||||||
corrInsights.push({
|
return (
|
||||||
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn',
|
<div
|
||||||
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`,
|
className="card"
|
||||||
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`,
|
style={{
|
||||||
})
|
marginBottom: 0,
|
||||||
}
|
padding: 10,
|
||||||
|
borderLeft: `4px solid ${getStatusColor(strength)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
||||||
|
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
|
||||||
|
{meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''}
|
||||||
|
{meta.metric ? ` · ${meta.metric}` : ''}
|
||||||
|
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
||||||
|
</div>
|
||||||
|
{!hasChart ? (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={118}>
|
||||||
|
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||||
|
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||||
|
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||||
|
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
|
||||||
|
<Scatter name="r" data={pts} fill={accent} />
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
{meta.interpretation ? (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Avg balance
|
function DriversImpactTile({ payload, driversFallback }) {
|
||||||
corrInsights.push({
|
const meta = payload?.metadata || {}
|
||||||
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
|
const rows = chartJsBarRows(payload, driversFallback)
|
||||||
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
|
if (!rows.length) {
|
||||||
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
|
return (
|
||||||
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
|
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
|
||||||
avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.':
|
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
|
||||||
avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.':
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
|
||||||
avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.':
|
</div>
|
||||||
'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`,
|
)
|
||||||
|
}
|
||||||
|
const h = Math.min(220, Math.max(96, rows.length * 34))
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
|
||||||
|
<ResponsiveContainer width="100%" height={h}>
|
||||||
|
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
|
||||||
|
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
|
||||||
|
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload: pp }) => {
|
||||||
|
if (!active || !pp?.length) return null
|
||||||
|
const p = pp[0].payload
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
maxWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{p.name}</div>
|
||||||
|
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||||
|
{rows.map((e, i) => (
|
||||||
|
<Cell key={i} fill={e.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gesamtansicht (Layer 2b: overview + Chart-Endpunkte 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = bundle?.overview
|
||||||
|
const chartC1 = bundle?.chartC1
|
||||||
|
const chartC2 = bundle?.chartC2
|
||||||
|
const chartC3 = bundle?.chartC3
|
||||||
|
const chartC4 = bundle?.chartC4
|
||||||
|
|
||||||
|
const lag = data?.lag_correlations || {}
|
||||||
|
const c4drivers = lag.recovery_performance?.drivers || []
|
||||||
|
const sections = data?.sections || []
|
||||||
|
const confUi = overviewConfidenceUi(data?.confidence)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="🔗 Korrelationen"/>
|
<SectionHeader title="📊 Gesamtansicht" />
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
|
||||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
<div
|
||||||
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf → Ernährung</strong> (gleiche Datenbasis).
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 14,
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: getStatusBg(confUi.tone),
|
||||||
|
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||||
|
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
|
||||||
|
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
|
||||||
|
Ernährung
|
||||||
|
</button>
|
||||||
|
. Die Kacheln C1–C4 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Chart: Calorie balance */}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
|
||||||
<div className="card" style={{marginBottom:12}}>
|
{sections.map((sec) => {
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
const tone = overviewSectionTone(sec)
|
||||||
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
const stripe = getStatusColor(tone)
|
||||||
</div>
|
const badgeBg = getStatusBg(tone)
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
return (
|
||||||
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
|
<div
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
key={sec.id}
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
style={{
|
||||||
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/>
|
borderRadius: 12,
|
||||||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
border: '1px solid var(--border)',
|
||||||
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/>
|
borderLeft: `5px solid ${stripe}`,
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
background: 'var(--surface)',
|
||||||
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
|
padding: '12px 12px 12px 14px',
|
||||||
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
|
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
|
||||||
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
|
}}
|
||||||
</LineChart>
|
>
|
||||||
</ResponsiveContainer>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
|
||||||
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: stripe,
|
||||||
|
background: badgeBg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
|
||||||
|
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
|
||||||
|
|
||||||
{/* Chart 3: Protein vs Lean Mass */}
|
{(sec.kpi_short || []).length > 0 && (
|
||||||
{protVsLean.length >= 3 && (
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
|
||||||
<div className="card" style={{marginBottom:12}}>
|
{(sec.kpi_short || []).map((k, i) => (
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
<div
|
||||||
🥩 Protein vs. Magermasse
|
key={i}
|
||||||
</div>
|
style={{
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
padding: '8px 10px',
|
||||||
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}>
|
borderRadius: 10,
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
background: getStatusBg(k.status || 'good'),
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
|
||||||
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
}}
|
||||||
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
>
|
||||||
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
|
||||||
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/>
|
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
|
||||||
formatter={(v,n)=>[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
|
|
||||||
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein"/>
|
|
||||||
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean"/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
|
||||||
<span style={{color:'#1D9E75'}}>— Protein g/Tag</span> · <span style={{color:'#7F77DD'}}>● Magermasse kg</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Correlation insights */}
|
|
||||||
{corrInsights.length > 0 && (
|
|
||||||
<div style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
|
|
||||||
{corrInsights.map((item,i) => (
|
|
||||||
<div key={i} style={{padding:'10px 12px',borderRadius:8,marginBottom:6,
|
|
||||||
background:item.status==='good'?'var(--accent-light)':'var(--warn-bg)',
|
|
||||||
border:`1px solid ${item.status==='good'?'var(--accent)':'var(--warn)'}33`}}>
|
|
||||||
<div style={{display:'flex',gap:8,alignItems:'flex-start'}}>
|
|
||||||
<span style={{fontSize:16}}>{item.icon}</span>
|
|
||||||
<div>
|
|
||||||
<div style={{fontSize:13,fontWeight:600}}>{item.title}</div>
|
|
||||||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>{item.detail}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
|
|
||||||
borderRadius:6,marginTop:6}}>
|
|
||||||
ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(sec.interpretation_short || []).map((it, i) => (
|
||||||
|
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
|
||||||
|
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(sec.heuristic_short || []).map((h, i) => (
|
||||||
|
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
|
||||||
|
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(sec.insights_short || []).map((ins, i) => (
|
||||||
|
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||||
|
<strong>{ins.title}</strong>
|
||||||
|
<div>{ins.body}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (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} />
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -1440,23 +1724,22 @@ function PhotoGrid() {
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ id:'overview', label:'📊 Gesamt' },
|
||||||
{ id:'body', label:'⚖️ Körper' },
|
{ id:'body', label:'⚖️ Körper' },
|
||||||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||||||
{ id:'activity', label:'🏋️ Fitness' },
|
{ id:'activity', label:'🏋️ Fitness' },
|
||||||
{ id:'correlation', label:'🔗 Korrelation' },
|
|
||||||
{ id:'photos', label:'📷 Fotos' },
|
{ id:'photos', label:'📷 Fotos' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
||||||
const location = useLocation?.() || {}
|
const location = useLocation?.() || {}
|
||||||
const [tab, setTab] = useState((location.state?.tab)||'body')
|
const [tab, setTab] = useState((location.state?.tab) || 'overview')
|
||||||
const [weights, setWeights] = useState([])
|
const [weights, setWeights] = useState([])
|
||||||
const [calipers, setCalipers] = useState([])
|
const [calipers, setCalipers] = useState([])
|
||||||
const [circs, setCircs] = useState([])
|
const [circs, setCircs] = useState([])
|
||||||
const [nutrition, setNutrition] = useState([])
|
const [nutrition, setNutrition] = useState([])
|
||||||
const [activities, setActivities] = useState([])
|
const [activities, setActivities] = useState([])
|
||||||
const [corrData, setCorrData] = useState([])
|
|
||||||
const [insights, setInsights] = useState([])
|
const [insights, setInsights] = useState([])
|
||||||
const [prompts, setPrompts] = useState([])
|
const [prompts, setPrompts] = useState([])
|
||||||
const [profile, setProfile] = useState(null)
|
const [profile, setProfile] = useState(null)
|
||||||
|
|
@ -1466,11 +1749,11 @@ export default function History() {
|
||||||
const loadAll = () => Promise.all([
|
const loadAll = () => Promise.all([
|
||||||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||||||
api.listNutrition(90), api.listActivity(25_000),
|
api.listNutrition(90), api.listActivity(25_000),
|
||||||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
api.latestInsights(), api.getProfile(),
|
||||||
api.listPrompts(),
|
api.listPrompts(),
|
||||||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
]).then(([w,ca,ci,n,a,ins,p,pr])=>{
|
||||||
setWeights(w); setCalipers(ca); setCircs(ci)
|
setWeights(w); setCalipers(ca); setCircs(ci)
|
||||||
setNutrition(n); setActivities(a); setCorrData(corr)
|
setNutrition(n); setActivities(a)
|
||||||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||||||
setPrompts(Array.isArray(pr)?pr:[])
|
setPrompts(Array.isArray(pr)?pr:[])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -1486,6 +1769,10 @@ export default function History() {
|
||||||
setTab('activity')
|
setTab('activity')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (t === 'correlation') {
|
||||||
|
setTab('nutrition')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||||||
}, [location.state?.tab])
|
}, [location.state?.tab])
|
||||||
|
|
||||||
|
|
@ -1530,10 +1817,10 @@ export default function History() {
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="history-content">
|
<div className="history-content">
|
||||||
|
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
||||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
|
||||||
{tab==='photos' && <PhotoGrid/>}
|
{tab==='photos' && <PhotoGrid/>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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