feat: add body history visualization endpoint and frontend integration
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced a new API endpoint `/body-history-viz` to retrieve body history visualization data.
- Updated the frontend to fetch and display body history data in the `BodySection` component.
- Enhanced the `EvaluationTileGrid` to include related placeholder keys for improved data interpretation.
- Refactored existing logic to streamline data handling and improve user experience.
This commit is contained in:
Lars 2026-04-19 16:06:07 +02:00
parent 157afd10b9
commit 461c358dc2
5 changed files with 1089 additions and 256 deletions

View File

@ -0,0 +1,330 @@
"""
Body interpretation tiles for Layer 2b (Verlauf UI).
Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext).
Uses the same thresholds; outputs structured tiles + related_placeholder_keys
for alignment with Layer 2a registry keys.
No formatting for KI structured dicts only.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
def _safe_float(v: Any) -> Optional[float]:
if v is None:
return None
try:
return round(float(v), 4)
except (TypeError, ValueError):
return None
def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]:
out: Dict[str, float] = {}
w = _safe_float(m.get("c_waist"))
h = _safe_float(m.get("c_hip"))
lean = _safe_float(m.get("lean_mass"))
if w and h:
out["whr"] = round(w / h, 2)
if w and height_cm:
out["whtr"] = round(w / height_cm, 2)
if lean and height_cm:
hm = height_cm / 100.0
out["ffmi"] = round(lean / (hm ** 2), 1)
return out
def _bf_status_ranges(sex: str) -> Dict[str, float]:
if sex == "f":
return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32}
return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25}
def get_body_interpretation_tiles(
measurement: Dict[str, Any],
profile: Dict[str, Any],
prev_measurement: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Returns interpretation tiles. Each tile includes related_placeholder_keys
pointing to Layer 2a registry keys fed by the same Layer-1 metrics.
"""
results: List[Dict[str, Any]] = []
sex = profile.get("sex") or "m"
height = _safe_float(profile.get("height")) or 178.0
m = measurement
derived = _calc_derived(m, height)
# ── Körperfett ──────────────────────────────────────────────────────────
bf = _safe_float(m.get("body_fat_pct"))
if bf is not None:
ranges = _bf_status_ranges(sex)
if bf <= ranges["essential"]:
msg = "Sehr niedriger Körperfettanteil"
detail = (
"Essenzielle Fettwerte nur für Leistungssportler geeignet, "
"auf Dauer nicht empfehlenswert."
)
status = "warn"
elif bf <= ranges["athletic"]:
msg = "Athletischer Körperfettanteil"
detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen."
status = "good"
elif bf <= ranges["fit"]:
msg = "Guter Körperfettanteil"
detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form."
status = "good"
elif bf <= ranges["avg"]:
msg = "Durchschnittlicher Körperfettanteil"
detail = (
"Im normalen Bereich. Verbesserung durch Kombination aus Kraft- "
"und Ausdauertraining möglich."
)
status = "warn"
else:
msg = "Erhöhter Körperfettanteil"
detail = (
"Über dem empfohlenen Bereich. Ernährungsumstellung und "
"regelmäßiges Training empfohlen."
)
status = "bad"
results.append(
{
"category": "Körperfett",
"icon": "🫧",
"status": status,
"title": msg,
"detail": detail,
"value": f"{bf}%",
"related_placeholder_keys": ["caliper_summary", "fm_28d_change"],
}
)
# ── WHR ─────────────────────────────────────────────────────────────────
whr = derived.get("whr")
if whr is not None:
limit = 0.90 if sex == "m" else 0.85
limit_high = 1.0 if sex == "m" else 0.95
if whr < limit:
status = "good"
title = "Günstige Fettverteilung"
detail = (
f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). "
"Birnenförmige Fettverteilung metabolisch günstig."
)
elif whr < limit_high:
status = "warn"
title = "Grenzwertiger WHR"
detail = (
f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). "
"Apfelförmige Tendenz Bauchfett reduzieren empfohlen."
)
else:
status = "bad"
title = "Erhöhtes Risiko durch Fettverteilung"
detail = (
f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes "
"kardiovaskuläres Risiko durch viszerales Fett."
)
results.append(
{
"category": "Fettverteilung",
"icon": "📐",
"status": status,
"title": title,
"detail": detail,
"value": str(whr),
"related_placeholder_keys": ["waist_hip_ratio", "circ_summary"],
}
)
# ── WHtR ────────────────────────────────────────────────────────────────
whtr = derived.get("whtr")
if whtr is not None:
if whtr < 0.40:
status = "warn"
title = "Sehr schlanke Taille"
detail = f"WHtR {whtr} möglicherweise zu wenig Körpermasse."
elif whtr < 0.50:
status = "good"
title = "Optimale Taillen-Größen-Relation"
detail = (
f"WHtR {whtr} im optimalen Bereich. Geringstes kardiovaskuläres Risiko."
)
elif whtr < 0.60:
status = "warn"
title = "Leicht erhöhter WHtR"
detail = f"WHtR {whtr} Ziel ist unter 0,50. Moderat erhöhtes Risiko."
else:
status = "bad"
title = "Stark erhöhter WHtR"
detail = (
f"WHtR {whtr} deutlich erhöhtes Risiko. Taille sollte weniger "
"als die Hälfte der Körpergröße betragen."
)
results.append(
{
"category": "Taille/Größe",
"icon": "📏",
"status": status,
"title": title,
"detail": detail,
"value": str(whtr),
"related_placeholder_keys": ["circ_summary", "waist_28d_delta"],
}
)
# ── FFMI ─────────────────────────────────────────────────────────────────
ffmi = derived.get("ffmi")
if ffmi is not None:
natural_limit = 25.0 if sex == "m" else 22.0
if ffmi < (18.0 if sex == "m" else 15.0):
status = "warn"
title = "Unterdurchschnittliche Muskelmasse"
detail = (
f"FFMI {ffmi} Krafttraining kann die Muskelmasse und den "
"Grundumsatz deutlich verbessern."
)
elif ffmi < (22.0 if sex == "m" else 19.0):
status = "good"
title = "Durchschnittliche Muskelmasse"
detail = f"FFMI {ffmi} gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar."
elif ffmi <= natural_limit:
status = "good"
title = "Überdurchschnittliche Muskelmasse"
detail = f"FFMI {ffmi} sehr gut. Oberes natürliches Spektrum für Kraftsportler."
else:
status = "warn"
title = "Außergewöhnlich hohe Muskelmasse"
detail = (
f"FFMI {ffmi} oberhalb der natürlichen Grenze (~{natural_limit}). "
"Selten ohne unterstützende Mittel erreichbar."
)
results.append(
{
"category": "Muskelmasse",
"icon": "💪",
"status": status,
"title": title,
"detail": detail,
"value": str(ffmi),
"related_placeholder_keys": ["lbm_28d_change", "caliper_summary"],
}
)
# ── BMI ───────────────────────────────────────────────────────────────────
w_kg = _safe_float(m.get("weight"))
if w_kg is not None and height > 0:
bmi = round(w_kg / ((height / 100.0) ** 2), 1)
if bmi < 18.5:
status = "warn"
title = "Untergewicht (BMI)"
detail = f"BMI {bmi} unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten."
elif bmi < 25:
status = "good"
title = "Normalgewicht (BMI)"
detail = f"BMI {bmi} im optimalen Bereich (18,524,9)."
elif bmi < 30:
status = "warn"
title = "Übergewicht (BMI)"
detail = (
f"BMI {bmi} leichtes Übergewicht. BMI allein ist wenig aussagekräftig "
"bei Muskelmasse Körperfett-% beachten."
)
else:
status = "bad"
title = "Adipositas (BMI)"
detail = f"BMI {bmi} deutliches Übergewicht. Ärztliche Beratung empfohlen."
results.append(
{
"category": "BMI",
"icon": "⚖️",
"status": status,
"title": title,
"detail": detail,
"value": str(bmi),
"related_placeholder_keys": ["bmi", "weight_aktuell"],
}
)
# ── Vergleich zur letzten Messung (Caliper) ───────────────────────────────
if prev_measurement:
p = prev_measurement
m_date = m.get("date")
p_date = p.get("date")
days = 0
if m_date and p_date:
if isinstance(m_date, str):
m_date = datetime.fromisoformat(m_date[:10]).date()
if isinstance(p_date, str):
p_date = datetime.fromisoformat(p_date[:10]).date()
if isinstance(m_date, date) and isinstance(p_date, date):
days = (m_date - p_date).days
changes: List[Dict[str, Any]] = []
if m.get("body_fat_pct") is not None and p.get("body_fat_pct") is not None:
diff = round(float(m["body_fat_pct"]) - float(p["body_fat_pct"]), 1)
if abs(diff) >= 0.3:
changes.append({"label": "Körperfett", "diff": diff, "unit": "%", "invert": True})
if m.get("weight") is not None and p.get("weight") is not None:
diff = round(float(m["weight"]) - float(p["weight"]), 1)
if abs(diff) >= 0.2:
changes.append({"label": "Gewicht", "diff": diff, "unit": "kg", "invert": True})
if m.get("lean_mass") is not None and p.get("lean_mass") is not None:
diff = round(float(m["lean_mass"]) - float(p["lean_mass"]), 1)
if abs(diff) >= 0.2:
changes.append({"label": "Magermasse", "diff": diff, "unit": "kg", "invert": False})
if m.get("c_waist") is not None and p.get("c_waist") is not None:
diff = round(float(m["c_waist"]) - float(p["c_waist"]), 1)
if abs(diff) >= 0.5:
changes.append({"label": "Taille", "diff": diff, "unit": "cm", "invert": True})
if m.get("c_belly") is not None and p.get("c_belly") is not None:
diff = round(float(m["c_belly"]) - float(p["c_belly"]), 1)
if abs(diff) >= 0.5:
changes.append({"label": "Bauch", "diff": diff, "unit": "cm", "invert": True})
if changes:
positive = [c for c in changes if (c["diff"] < 0 if c["invert"] else c["diff"] > 0)]
negative = [c for c in changes if (c["diff"] > 0 if c["invert"] else c["diff"] < 0)]
detail_parts = []
for c in changes:
sign = "+" if c["diff"] > 0 else ""
good = (c["diff"] < 0) if c["invert"] else (c["diff"] > 0)
detail_parts.append(
f"{c['label']}: {sign}{c['diff']} {c['unit']} {'' if good else ''}"
)
detail = " · ".join(detail_parts)
if len(positive) > len(negative):
st = "good"
title = "Positive Entwicklung seit letzter Messung"
elif len(negative) > len(positive):
st = "warn"
title = "Verschlechterung seit letzter Messung"
else:
st = "warn"
title = "Gemischte Entwicklung seit letzter Messung"
results.append(
{
"category": f"Seit letzter Messung ({days} Tage)",
"icon": "📊",
"status": st,
"title": title,
"detail": detail,
"value": f"{days}d",
"related_placeholder_keys": [
"caliper_summary",
"weight_trend",
"lbm_28d_change",
"waist_28d_delta",
],
}
)
return results

View File

@ -0,0 +1,440 @@
"""
Layer 2b: Structured body history / Verlauf «Körper» bundle.
Single source for Verlauf-UI: series + Kennzahlen + Interpretation tiles.
All queries use the same tables as Layer 1 / Layer 2a body placeholders.
See: placeholder_registrations/body_metrics.py, body_extras.py
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from db import get_db, get_cursor, r2d
from data_layer.body_interpretation import get_body_interpretation_tiles
from data_layer.utils import safe_float
def _cutoff_sql(days: int) -> Optional[str]:
if days >= 9999:
return None
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for i, d in enumerate(rows):
sl = rows[max(0, i - window + 1) : i + 1]
vals: List[float] = []
for x in sl:
v = safe_float(x.get(key))
if v is not None:
vals.append(v)
if not vals:
out.append({**d, f"{key}_avg": None})
continue
avg = round(sum(vals) / len(vals), 1)
out.append({**d, f"{key}_avg": avg})
return out
def _iso(d: Any) -> Optional[str]:
if d is None:
return None
if hasattr(d, "isoformat"):
return d.isoformat()
return str(d)[:10]
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Returns chart-ready series and interpretation tiles for the body history tab.
Args:
profile_id: profiles.id
days: analysis window (use >= 9999 for full history)
Tables: weight_log, caliper_log, circumference_log, profiles
"""
cutoff = _cutoff_sql(days)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, sex, height, dob, goal_weight, goal_bf_pct
FROM profiles WHERE id = %s
""",
(profile_id,),
)
pr = r2d(cur.fetchone())
if not pr:
return {
"confidence": "insufficient",
"message": "Profil nicht gefunden",
"profile": {},
"weight": {},
"caliper": {},
"circumference": {},
"interpretation_tiles": [],
"meta": {},
}
profile_ui = {
"sex": pr.get("sex") or "m",
"height": safe_float(pr.get("height")) or 178.0,
"goal_weight_kg": safe_float(pr.get("goal_weight")),
"goal_bf_pct": safe_float(pr.get("goal_bf_pct")),
}
# ── Weight (same window as Verlauf-Filter) ────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, weight FROM weight_log
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, weight FROM weight_log
WHERE profile_id = %s
ORDER BY date ASC
""",
(profile_id,),
)
wrows = [r2d(r) for r in cur.fetchall()]
w_points = [
{"date": r["date"], "weight": safe_float(r["weight"])}
for r in wrows
if r.get("weight") is not None
]
w_with_avg7 = _rolling_avg([dict(x) for x in w_points], "weight", 7)
w_with_avg14 = _rolling_avg([dict(x) for x in w_points], "weight", 14)
weight_series: List[Dict[str, Any]] = []
for i, base in enumerate(w_points):
weight_series.append(
{
"date": _iso(base["date"]),
"weight": base["weight"],
"avg7": w_with_avg7[i].get("weight_avg") if i < len(w_with_avg7) else None,
"avg14": w_with_avg14[i].get("weight_avg") if i < len(w_with_avg14) else None,
}
)
ws = [p["weight"] for p in w_points if p.get("weight") is not None]
overall_avg = round(sum(ws) / len(ws), 1) if len(ws) else None
min_w = min(ws) if ws else None
max_w = max(ws) if ws else None
today = datetime.now().date()
trend_periods: List[Dict[str, Any]] = []
for span in (7, 30, 90):
cut = today - timedelta(days=span)
per = [p for p in w_points if p["date"] >= cut]
if len(per) >= 2:
diff = round(float(per[-1]["weight"]) - float(per[0]["weight"]), 1)
trend_periods.append({"label": f"{span}T", "diff_kg": diff, "count": len(per)})
# ── Caliper series ───────────────────────────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass, fat_mass
FROM caliper_log
WHERE profile_id = %s
AND body_fat_pct IS NOT NULL
AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass, fat_mass
FROM caliper_log
WHERE profile_id = %s AND body_fat_pct IS NOT NULL
ORDER BY date ASC
""",
(profile_id,),
)
cal_rows = [r2d(r) for r in cur.fetchall()]
caliper_series = [
{
"date": _iso(r["date"]),
"body_fat_pct": safe_float(r.get("body_fat_pct")),
"lean_mass": safe_float(r.get("lean_mass")),
}
for r in cal_rows
]
# Latest / prev caliper in window (for interpretation)
if cutoff:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass
FROM caliper_log
WHERE profile_id = %s AND date >= %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass
FROM caliper_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id,),
)
cal_latest_rows = [r2d(r) for r in cur.fetchall()]
latest_cal = cal_latest_rows[0] if cal_latest_rows else None
prev_cal = cal_latest_rows[1] if len(cal_latest_rows) > 1 else None
# ── Circumference rows ───────────────────────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s
ORDER BY date ASC
""",
(profile_id,),
)
cir_rows = [r2d(r) for r in cur.fetchall()]
if cutoff:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s AND date >= %s
ORDER BY date DESC
LIMIT 1
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT 1
""",
(profile_id,),
)
latest_circ_row = r2d(cur.fetchone())
# Latest weight in window
latest_w = w_points[-1] if w_points else None
# ── Proportion & index (computed from L1 rows only) ─────────────────────
prop_base: List[Dict[str, Any]] = []
for r in cir_rows:
ch = safe_float(r.get("c_chest"))
wa = safe_float(r.get("c_waist"))
if ch is None or wa is None:
continue
belly = safe_float(r.get("c_belly"))
prop_base.append(
{
"date": _iso(r["date"]),
"v_taper_cm": round(ch - wa, 1),
"belly_cm": belly,
}
)
prop_chart = _rolling_avg([dict(x) for x in prop_base], "v_taper_cm", 3) if len(prop_base) >= 2 else []
for i, row in enumerate(prop_chart):
row["belly_cm"] = prop_base[i].get("belly_cm")
fb_first: Dict[str, Optional[float]] = {"chest": None, "waist": None, "belly": None}
for r in cir_rows:
if fb_first["chest"] is None and r.get("c_chest") is not None:
fb_first["chest"] = safe_float(r["c_chest"])
if fb_first["waist"] is None and r.get("c_waist") is not None:
fb_first["waist"] = safe_float(r["c_waist"])
if fb_first["belly"] is None and r.get("c_belly") is not None:
fb_first["belly"] = safe_float(r["c_belly"])
index_series: List[Dict[str, Any]] = []
for r in cir_rows:
idx_row: Dict[str, Any] = {"date": _iso(r["date"])}
cc = safe_float(r.get("c_chest"))
ww = safe_float(r.get("c_waist"))
bb = safe_float(r.get("c_belly"))
if cc is not None and fb_first["chest"]:
idx_row["chest_idx"] = round(cc / fb_first["chest"] * 100, 1)
else:
idx_row["chest_idx"] = None
if ww is not None and fb_first["waist"]:
idx_row["waist_idx"] = round(ww / fb_first["waist"] * 100, 1)
else:
idx_row["waist_idx"] = None
if bb is not None and fb_first["belly"]:
idx_row["belly_idx"] = round(bb / fb_first["belly"] * 100, 1)
else:
idx_row["belly_idx"] = None
index_series.append(idx_row)
idx_nonempty = sum(
1
for row in index_series
if row.get("chest_idx") is not None
or row.get("waist_idx") is not None
or row.get("belly_idx") is not None
)
fallback_circ = [
{
"date": _iso(r["date"]),
"waist": safe_float(r.get("c_waist")),
"hip": safe_float(r.get("c_hip")),
"belly": safe_float(r.get("c_belly")),
}
for r in cir_rows
if r.get("c_waist") or r.get("c_hip") or r.get("c_belly")
]
# ── Merge measurement for interpretation ────────────────────────────────
measurement: Dict[str, Any] = {}
if latest_cal:
measurement.update(
{
"date": latest_cal.get("date"),
"body_fat_pct": safe_float(latest_cal.get("body_fat_pct")),
"lean_mass": safe_float(latest_cal.get("lean_mass")),
}
)
if latest_circ_row:
measurement["c_waist"] = safe_float(latest_circ_row.get("c_waist"))
measurement["c_hip"] = safe_float(latest_circ_row.get("c_hip"))
measurement["c_belly"] = safe_float(latest_circ_row.get("c_belly"))
if latest_w:
measurement["weight"] = safe_float(latest_w.get("weight"))
prev_for_interp = None
if prev_cal:
prev_for_interp = {
"date": prev_cal.get("date"),
"body_fat_pct": safe_float(prev_cal.get("body_fat_pct")),
"lean_mass": safe_float(prev_cal.get("lean_mass")),
}
tiles = get_body_interpretation_tiles(measurement, profile_ui, prev_for_interp)
last_dates: List[date] = []
if w_points:
last_dates.append(w_points[-1]["date"])
if latest_cal and latest_cal.get("date"):
d = latest_cal["date"]
if isinstance(d, str):
d = datetime.fromisoformat(d[:10]).date()
last_dates.append(d)
if latest_circ_row and latest_circ_row.get("date"):
d = latest_circ_row["date"]
if isinstance(d, str):
d = datetime.fromisoformat(d[:10]).date()
last_dates.append(d)
last_updated = max(last_dates).isoformat() if last_dates else None
bf_cat = None
if measurement.get("body_fat_pct") is not None:
# simple label bucket (aligned with frontend BF_CATEGORIES order)
bf = float(measurement["body_fat_pct"])
sex = profile_ui["sex"]
if sex == "f":
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
bounds = [14, 21, 25, 32, 1000]
else:
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
bounds = [6, 14, 18, 25, 1000]
for i, b in enumerate(bounds):
if bf <= b:
bf_cat = labels[i]
break
summary = {
"weight_kg": measurement.get("weight"),
"body_fat_pct": measurement.get("body_fat_pct"),
"lean_mass_kg": measurement.get("lean_mass"),
"whr": (
round(measurement["c_waist"] / measurement["c_hip"], 2)
if measurement.get("c_waist") and measurement.get("c_hip")
else None
),
"whtr": (
round(measurement["c_waist"] / profile_ui["height"], 2)
if measurement.get("c_waist") and profile_ui.get("height")
else None
),
"ffmi": None,
"bf_category_label": bf_cat,
}
if measurement.get("lean_mass") and profile_ui.get("height"):
hm = float(profile_ui["height"]) / 100.0
summary["ffmi"] = round(float(measurement["lean_mass"]) / (hm**2), 1)
return {
"confidence": "high" if w_points or caliper_series or cir_rows else "insufficient",
"days_requested": days,
"last_updated": last_updated,
"profile": profile_ui,
"summary": summary,
"weight": {
"series": weight_series,
"overall_avg_kg": overall_avg,
"min_kg": min_w,
"max_kg": max_w,
"trend_periods": trend_periods,
"data_points": len(w_points),
"related_placeholder_keys": [
"weight_aktuell",
"weight_trend",
"weight_7d_median",
"weight_28d_slope",
"weight_90d_slope",
],
},
"caliper": {
"series": caliper_series,
"data_points": len(caliper_series),
"related_placeholder_keys": ["caliper_summary", "fm_28d_change", "lbm_28d_change"],
},
"circumference": {
"proportion_series": prop_chart,
"index_series": index_series,
"index_usable": idx_nonempty >= 2 and any(v for v in fb_first.values()),
"fallback_multiline": fallback_circ,
"has_chest_waist": len(prop_base) >= 2,
"related_placeholder_keys": ["circ_summary", "waist_hip_ratio", "waist_28d_delta"],
},
"interpretation_tiles": tiles,
"meta": {
"layer_1": "data_layer.body_viz + data_layer.body_interpretation",
"layer_2b": "This bundle — sole numeric source for Verlauf Körper charts/tiles",
"layer_2a_alignment": "Tiles carry related_placeholder_keys; metrics from same tables as body_metrics placeholders",
},
}

View File

@ -31,6 +31,7 @@ from data_layer.body_metrics import (
get_body_composition_data,
get_circumference_summary_data
)
from data_layer.body_viz import get_body_history_viz_bundle
from data_layer.nutrition_metrics import (
get_nutrition_average_data,
get_protein_targets_data,
@ -240,6 +241,30 @@ def get_body_composition_chart(
}
@router.get("/body-history-viz")
def get_body_history_viz(
days: int = Query(
default=90,
ge=7,
le=9999,
description="Analysefenster in Tagen (9999 = gesamte Historie im Rohdatensatz)",
),
session: dict = Depends(require_auth),
) -> Dict:
"""
Layer 2b: Ein Bundle für Verlauf «Körper» Charts, Kennzahlen, Bewertungskacheln.
Alle Reihen und Kennzahlen stammen aus Layer 1 (dieselben Tabellen wie die
Körper-Platzhalter / body_metrics). Interpretationskacheln sind mit
``related_placeholder_keys`` an Layer 2a ausgewiesen.
Frontend: ausschließlich Darstellung keine parallele Berechnung.
"""
profile_id = session["profile_id"]
bundle = get_body_history_viz_bundle(profile_id, days)
return serialize_dates(bundle)
@router.get("/circumferences")
def get_circumferences_chart(
max_age_days: int = Query(default=90, ge=7, le=365),

View File

@ -9,8 +9,8 @@ import {
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory, calcDerived } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import { getBfCategory } from '../utils/calc'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
@ -112,7 +112,14 @@ function EvaluationTileGrid({ items }) {
}}>{item.category}</div>
<div style={{ fontSize: 12, fontWeight: 600, lineHeight: 1.3, color: 'var(--text1)' }}>{item.title}</div>
{expanded && (
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
<>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
{item.related_placeholder_keys?.length > 0 && (
<div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 6, lineHeight: 1.35 }}>
Layer 2a (Registry): {item.related_placeholder_keys.join(', ')}
</div>
)}
</>
)}
</div>
{item.value && (
@ -238,12 +245,15 @@ function PeriodSelector({ value, onChange }) {
)
}
// Body Section (Weight + Composition combined)
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
// Body Section Layer 2b: Daten nur aus GET /api/charts/body-history-viz
function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(90)
const [groupedGoals, setGroupedGoals] = useState(null)
const sex = profile?.sex||'m'
const height = profile?.height||178
const [viz, setViz] = useState(null)
const [vizLoading, setVizLoading] = useState(true)
const [vizError, setVizError] = useState(null)
const sex = profile?.sex || 'm'
useEffect(() => {
let cancelled = false
@ -253,326 +263,354 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
return () => { cancelled = true }
}, [])
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
.filter(d=>period===9999||d.date>=cutoff)
const filtCal = (calipers||[]).filter(d=>period===9999||d.date>=cutoff)
const filtCir = (circs||[]).filter(d=>period===9999||d.date>=cutoff)
useEffect(() => {
let cancelled = false
setVizLoading(true)
setVizError(null)
api.getBodyHistoryViz(period)
.then(data => {
if (!cancelled) {
setViz(data)
setVizLoading(false)
}
})
.catch(e => {
if (!cancelled) {
setVizError(e.message || 'Laden fehlgeschlagen')
setVizLoading(false)
}
})
return () => { cancelled = true }
}, [period])
const hasWeight = filtW.length >= 2
const hasCal = filtCal.length >= 1
const hasCir = filtCir.length >= 1
const w = viz?.weight
const cal = viz?.caliper
const circ = viz?.circumference
const summary = viz?.summary || {}
if (!hasWeight && !hasCal && !hasCir) return (
<div>
<SectionHeader title="⚖️ Körper"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen"/>
</div>
)
// Weight chart
const withAvg = rollingAvg(filtW,'weight')
const withAvg14= rollingAvg(filtW,'weight',14)
const wCd = withAvg.map((d,i)=>({
date:fmtDate(d.date),
weight:d.weight,
avg7: d.weight_avg,
avg14: withAvg14[i]?.weight_avg,
const wCd = (w?.series || []).map(row => ({
date: fmtDate(row.date),
weight: row.weight,
avg7: row.avg7,
avg14: row.avg14,
}))
const ws = filtW.map(w=>w.weight)
const minW = ws.length ? Math.min(...ws) : null
const maxW = ws.length ? Math.max(...ws) : null
const avgAll = ws.length ? Math.round(ws.reduce((a,b)=>a+b)/ws.length*10)/10 : null
const hasWeight = (w?.data_points || 0) >= 2
const avgAll = w?.overall_avg_kg
const minW = w?.min_kg
const maxW = w?.max_kg
const trendPeriods = w?.trend_periods || []
const trendPeriods = [7,30,90].map(days=>{
const cut = dayjs().subtract(days,'day').format('YYYY-MM-DD')
const per = filtW.filter(d=>d.date>=cut)
if (per.length<2) return null
const diff = Math.round((per[per.length-1].weight-per[0].weight)*10)/10
return {label:`${days}T`,diff,count:per.length}
}).filter(Boolean)
// Caliper: nur KF% (Magermasse ist daraus abgeleitet eigene zweite Achse entfällt)
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
date:fmtDate(c.date), bf:c.body_fat_pct,
}))
const latestCal = filtCal[0]
const prevCal = filtCal[1]
const latestCir = filtCir[0]
const latestW2 = filtW[filtW.length-1]
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
// Umfänge: chronologisch für Trends & Proportionen
const circChron = [...filtCir].sort((a,b)=>a.date.localeCompare(b.date))
const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({
date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly
const bfCd = (cal?.series || []).map(s => ({
date: fmtDate(s.date),
bf: s.body_fat_pct,
}))
const propBase = circChron
.filter(r => r.c_chest && r.c_waist)
.map(r => ({
date: fmtDate(r.date),
vTaper: Math.round((r.c_chest - r.c_waist) * 10) / 10,
belly: r.c_belly != null ? Math.round(r.c_belly * 10) / 10 : null,
}))
const propChartData = propBase.length >= 2 ? rollingAvg(propBase, 'vTaper', 3) : []
const showBellyOnProp = propChartData.some(d => d.belly != null)
const propChartData = (circ?.proportion_series || []).map(p => ({
date: fmtDate(p.date),
vTaper: p.v_taper_cm,
vTaper_avg: p.v_taper_cm_avg,
belly: p.belly_cm,
}))
const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined)
const fbFirst = { chest: null, waist: null, belly: null }
for (const r of circChron) {
if (fbFirst.chest == null && r.c_chest) fbFirst.chest = r.c_chest
if (fbFirst.waist == null && r.c_waist) fbFirst.waist = r.c_waist
if (fbFirst.belly == null && r.c_belly) fbFirst.belly = r.c_belly
}
const idxSeries = circChron.map(r => ({
const idxSeriesRaw = circ?.index_series || []
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
const idxOk = circ?.index_usable
const cirCd = (circ?.fallback_multiline || []).map(r => ({
date: fmtDate(r.date),
chestIdx: r.c_chest && fbFirst.chest ? Math.round((r.c_chest / fbFirst.chest) * 1000) / 10 : null,
waistIdx: r.c_waist && fbFirst.waist ? Math.round((r.c_waist / fbFirst.waist) * 1000) / 10 : null,
bellyIdx: r.c_belly && fbFirst.belly ? Math.round((r.c_belly / fbFirst.belly) * 1000) / 10 : null,
waist: r.waist,
hip: r.hip,
belly: r.belly,
}))
const idxCount = idxSeries.filter(row => row.chestIdx != null || row.waistIdx != null || row.bellyIdx != null).length
const idxOk = idxCount >= 2 && (fbFirst.chest || fbFirst.waist || fbFirst.belly)
// Indicators
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
const whr = summary.whr
const whtr = summary.whtr
const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
// Rules
const combined = {
...(latestCal||{}),
c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip,
weight:latestW2?.weight
const rules = (viz?.interpretation_tiles || []).map(t => ({
category: t.category,
icon: t.icon,
status: t.status,
title: t.title,
detail: t.detail,
value: t.value,
related_placeholder_keys: t.related_placeholder_keys,
}))
const hasAnyData =
(w?.data_points > 0) ||
(cal?.data_points > 0) ||
(cirCd.length > 0)
if (vizLoading && !viz) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<div className="empty-state"><div className="spinner" /></div>
</div>
)
}
if (vizError) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
</div>
)
}
if (!hasAnyData) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<PeriodSelector value={period} onChange={setPeriod} />
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
</div>
)
}
const rules = getInterpretation(combined, profile, prevCal||null)
const derivedFFMI = calcDerived(combined, height)?.ffmi
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
<PeriodSelector value={period} onChange={setPeriod} />
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Hinweis: Diese Seite bündelt <strong>Körpermaße und -zusammensetzung</strong>. Trainingsbedingte Fitness (Belastung, Leistung, Ausdauer) findest du unter{' '}
<strong>Verlauf Aktivität</strong> dort werden sportliche Trends ausgewertet, hier geht es um Silhouette, Zusammensetzung und Gesundheitsindikatoren.
<strong>Layer 2b</strong>: Diagramme und Bewertung stammen aus dem Backend-Bundle dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '}
Sportliche Fitness: <strong>Verlauf Aktivität</strong>.
</p>
{/* Summary stats */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{latestW2 && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700}}>{latestW2.weight} kg</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Aktuell</div>
</div>}
{latestCal?.body_fat_pct && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:bfCat?.color}}>{latestCal.body_fat_pct}%</div>
<div style={{fontSize:9,color:'var(--text3)'}}>KF {bfCat?.label}</div>
</div>}
{latestCal?.lean_mass && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
</div>}
{derivedFFMI != null && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:'#378ADD'}}>{derivedFFMI}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>FFMI</div>
</div>}
{whr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:16,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>WHR</div>
</div>}
{whtr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:16,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>WHtR</div>
</div>}
{viz?.meta?.layer_2a_alignment && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
{summary.weight_kg != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{summary.weight_kg} kg</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Aktuell</div>
</div>
)}
{summary.body_fat_pct != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: bfCat?.color }}>{summary.body_fat_pct}%</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>KF {bfCat?.label || summary.bf_category_label}</div>
</div>
)}
{summary.lean_mass_kg != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1D9E75' }}>{summary.lean_mass_kg} kg</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Mager</div>
</div>
)}
{summary.ffmi != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#378ADD' }}>{summary.ffmi}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>FFMI</div>
</div>
)}
{whr != null && (
<div
style={{
flex: 1,
minWidth: 70,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 6px',
textAlign: 'center',
borderTop: `3px solid ${whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)'}`,
}}
>
<div style={{ fontSize: 16, fontWeight: 700, color: whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)' }}>{whr}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHR</div>
</div>
)}
{whtr != null && (
<div
style={{
flex: 1,
minWidth: 70,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 6px',
textAlign: 'center',
borderTop: `3px solid ${whtr < 0.5 ? 'var(--accent)' : 'var(--warn)'}`,
}}
>
<div style={{ fontSize: 16, fontWeight: 700, color: whtr < 0.5 ? 'var(--accent)' : 'var(--warn)' }}>{whtr}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHtR</div>
</div>
)}
</div>
{/* Weight chart 3 lines like WeightScreen */}
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
)}
{hasWeight && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>
Gewicht · {filtW.length} Einträge
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {w?.data_points || 0} Einträge
</div>
<button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>window.location.href='/weight'}>
Daten <ChevronRight size={10}/>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
Daten <ChevronRight size={10} />
</button>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={wCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(wCd.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
{avgAll && <ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}
label={{value:`Ø ${avgAll}`,fontSize:9,fill:'var(--text3)',position:'right'}}/>}
{profile?.goal_weight && <ReferenceLine y={profile.goal_weight} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5}
label={{value:`Ziel ${profile.goal_weight}kg`,fontSize:9,fill:'var(--accent)',position:'right'}}/>}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} kg`,n==='weight'?'Täglich':n==='avg7'?'Ø 7 Tage':'Ø 14 Tage']}/>
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5}
dot={{r:3,fill:'#378ADD',stroke:'#378ADD',strokeWidth:1}} activeDot={{r:5}} name="weight"/>
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5}
dot={false} name="avg7"/>
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2}
dot={false} strokeDasharray="6 3" name="avg14"/>
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(wCd.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{avgAll != null && (
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
)}
{goalW != null && (
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}kg`, fontSize: 9, fill: 'var(--accent)', position: 'right' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} name="weight" />
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD88',verticalAlign:'middle',marginRight:3}}/> Täglich</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Ø 7T</span>
<span style={{display:'inline-flex',alignItems:'center',gap:3}}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3"/></svg>Ø 14T</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'var(--text3)',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed'}}/>Ø Gesamt</span>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} /> Täglich</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />Ø 7T</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>Ø 14T</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
</div>
{/* Trend tiles */}
{trendPeriods.length>0 && (
<div style={{display:'flex',gap:6,marginTop:10}}>
{trendPeriods.map(({label,diff})=>(
<div key={label} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center',
borderTop:`3px solid ${diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--border)'}`}}>
<div style={{fontSize:15,fontWeight:700,color:diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--text3)'}}>
{diff>0?'+':''}{diff} kg
{trendPeriods.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
{trendPeriods.map(({ label, diff_kg: diff }) => (
<div key={label} style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center', borderTop: `3px solid ${diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--border)'}` }}>
<div style={{ fontSize: 15, fontWeight: 700, color: diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--text3)' }}>
{diff > 0 ? '+' : ''}{diff} kg
</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{label}</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>{label}</div>
</div>
))}
{minW && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center'}}>
<div style={{fontSize:12,color:'var(--accent)',fontWeight:600}}>{minW}</div>
<div style={{fontSize:12,color:'var(--warn)',fontWeight:600}}>{maxW}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Min/Max</div>
</div>}
{minW != null && (
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center' }}>
<div style={{ fontSize: 12, color: 'var(--accent)', fontWeight: 600 }}>{minW}</div>
<div style={{ fontSize: 12, color: 'var(--warn)', fontWeight: 600 }}>{maxW}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Min/Max</div>
</div>
)}
</div>
)}
</div>
)}
{/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */}
{bfCd.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Körperfett (Caliper)</div>
<NavToCaliper/>
{bfCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
<NavToCaliper />
</div>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={bfCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v) => [`${v}%`, 'KF%']}/>
{profile?.goal_bf_pct && <ReferenceLine y={profile.goal_bf_pct}
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
<LineChart data={bfCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={v => [`${v}%`, 'KF%']} />
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',marginTop:6,lineHeight:1.4}}>
Magermasse ergibt sich aus Gewicht und KF% als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben.
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% zweite Kurve entfällt.</div>
</div>
)}
{/* Proportion: V-Taper vs. Bauch (BrustTaille vs. Bauchumfang) */}
{propChartData.length >= 2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start',marginBottom:8,gap:8}}>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
<div>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Silhouette & Proportion</div>
<div style={{fontSize:10,color:'var(--text3)',lineHeight:1.45,marginTop:4}}>
<strong>V-Taper (Brust Taille)</strong> in cm: größer bedeutet stärkere Schulter-/Brustentwicklung relativ zur Taille.
{showBellyOnProp && (
<><strong> Bauch</strong> (rechte Achse): steigender Trend hier deutet eher auf Zunahme zentralen Umfangs hin unabhängig von sportlicher Brustentwicklung.</>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
<strong>V-Taper (Brust Taille)</strong> in cm.
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
</div>
</div>
<NavToCircum/>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={propChartData} margin={{top:4,right:showBellyOnProp?4:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="taper" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
{showBellyOnProp && (
<YAxis yAxisId="belly" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
)}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => {
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust Taille']
if (name === 'belly') return [`${v} cm`, 'Bauch']
return [v, name]
}}/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{r:3}} name="vTaper"/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg"/>
{showBellyOnProp && (
<Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{r:3}} connectNulls name="belly"/>
)}
}}
/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
</ComposedChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)',flexWrap:'wrap'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Brust Taille</span>
<span><span style={{display:'inline-flex',alignItems:'center',gap:3}}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4"/></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{display:'inline-block',width:12,height:2,background:'#D4537E',verticalAlign:'middle',marginRight:3}}/>Bauch (cm)</span>}
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust Taille</span>
<span><span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4" /></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
</div>
</div>
)}
{/* Relative Umfangsänderung (Index erste Messung im Zeitraum = 100) */}
{idxOk && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Relative Entwicklung der Umfänge</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4,lineHeight:1.4}}>
Index 100 = erste erfasste Messung im Zeitraum. So sind Trend und Richtung besser vergleichbar als absolute cm-Werte nebeneinander.
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
</div>
<NavToCircum/>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={idxSeries} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} Index`, n==='chestIdx'?'Brust':n==='waistIdx'?'Taille':'Bauch']}/>
{idxSeries.some(d=>d.chestIdx!=null) && <Line type="monotone" dataKey="chestIdx" stroke="#1D9E75" strokeWidth={2} dot={{r:2}} connectNulls name="chestIdx"/>}
{idxSeries.some(d=>d.waistIdx!=null) && <Line type="monotone" dataKey="waistIdx" stroke="#EF9F27" strokeWidth={2} dot={{r:2}} connectNulls name="waistIdx"/>}
{idxSeries.some(d=>d.bellyIdx!=null) && <Line type="monotone" dataKey="bellyIdx" stroke="#D4537E" strokeWidth={2} dot={{r:2}} connectNulls name="bellyIdx"/>}
<LineChart data={idxSeries} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
{idxSeries.some(d => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
{idxSeries.some(d => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
{idxSeries.some(d => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)',flexWrap:'wrap'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Brust</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Taille</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#D4537E',verticalAlign:'middle',marginRight:3}}/>Bauch</span>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
</div>
</div>
)}
{/* Fallback: klassischer Taille/Hüfte/Bauch-Verlauf wenn keine Brust-Taille-Kombi */}
{propChartData.length < 2 && cirCd.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginBottom:8,lineHeight:1.4}}>
Sobald Brust- und Taillenumfang gemeinsam erfasst sind, erscheint oben die Proportionen-Ansicht (V-Taper).
{propChartData.length < 2 && cirCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={cirCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} cm`,n]}/>
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{r:3}} name="Taille"/>
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{r:3}} name="Hüfte"/>
{cirCd.some(d=>d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{r:3}} name="Bauch"/>}
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
{cirCd.some(d => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
</LineChart>
</ResponsiveContainer>
</div>
@ -580,12 +618,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
<EvaluationTileGrid items={rules} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
onRequest={onRequest} loading={loadingSlug}/>
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)
}
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(30)
@ -1306,7 +1342,7 @@ export default function History() {
</div>
</nav>
<div className="history-content">
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>}

View File

@ -635,6 +635,8 @@ export const api = {
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
// Nutrition Charts (E1-E5)
/** Layer 2b: Verlauf Körper — Charts, Kennzahlen, Bewertung (einheitlich mit Platzhalter-Registry) */
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),