Verlauf - Körper #93

Merged
Lars merged 4 commits from develop into main 2026-04-19 16:32:33 +02:00
6 changed files with 1343 additions and 212 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,468 @@
"""
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 2
""",
(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 2
""",
(profile_id,),
)
circ_latest_desc = [r2d(r) for r in cur.fetchall()]
latest_circ_row = circ_latest_desc[0] if circ_latest_desc else None
prev_circ_row = circ_latest_desc[1] if len(circ_latest_desc) > 1 else None
# 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"))
# Referenzdatum für „aktuell“: neueste verfügbare Quelle (Caliper > Umfang > Gewicht)
if not measurement.get("date"):
if latest_circ_row and latest_circ_row.get("date"):
measurement["date"] = latest_circ_row.get("date")
elif latest_w and latest_w.get("date"):
measurement["date"] = latest_w.get("date")
# Vorperiode: vorherige Caliper-Zeile + vorherige Umfangsmessung + vorheriges Gewicht (w_points[-2])
prev_for_interp: Optional[Dict[str, Any]] = {}
if prev_cal:
prev_for_interp["date"] = prev_cal.get("date")
prev_for_interp["body_fat_pct"] = safe_float(prev_cal.get("body_fat_pct"))
prev_for_interp["lean_mass"] = safe_float(prev_cal.get("lean_mass"))
if prev_circ_row:
prev_for_interp["c_waist"] = safe_float(prev_circ_row.get("c_waist"))
prev_for_interp["c_hip"] = safe_float(prev_circ_row.get("c_hip"))
prev_for_interp["c_belly"] = safe_float(prev_circ_row.get("c_belly"))
if not prev_for_interp.get("date") and prev_circ_row.get("date"):
prev_for_interp["date"] = prev_circ_row.get("date")
if len(w_points) >= 2:
prev_for_interp["weight"] = safe_float(w_points[-2].get("weight"))
if not prev_for_interp.get("date") and w_points[-2].get("date"):
prev_for_interp["date"] = w_points[-2].get("date")
if not prev_for_interp:
prev_for_interp = None
else:
# Mindestens ein vergleichbares Feld zur aktuellen Messung
has_cmp = any(
prev_for_interp.get(k) is not None
for k in ("body_fat_pct", "lean_mass", "weight", "c_waist", "c_belly")
)
if not has_cmp:
prev_for_interp = None
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

@ -199,6 +199,27 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */
.body-kpi-overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.body-kpi-card {
background: var(--surface2);
border-radius: 10px;
padding: 10px 10px 10px 12px;
border: 1px solid var(--border);
cursor: help;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.body-kpi-card:hover {
border-color: var(--border2);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
}
.history-page__title {
margin-bottom: 12px;
}

View File

@ -4,13 +4,13 @@ import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell
ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
@ -85,6 +85,236 @@ function RuleCard({ item }) {
)
}
function verdictShort(status) {
if (status === 'good') return 'Gut'
if (status === 'warn') return 'Hinweis'
return 'Achtung'
}
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */
function buildBodyKpiTiles({
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW,
}) {
const tiles = []
if (summary.weight_kg != null) {
const t90 = trendPeriods.find(t => t.label === '90T')
const t30 = trendPeriods.find(t => t.label === '30T')
const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg
let st = 'good'
let vs = 'Stabil'
if (d != null) {
if (d < -0.25) { st = 'good'; vs = 'Trend ↓' }
else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' }
else { st = 'good'; vs = 'Stabil' }
}
const trendBits = trendPeriods.length
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
: ''
const hoverBody = [
'Gewicht im gewählten Zeitraum (letzter Messwert).',
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
minW != null && maxW != null ? `Min. / Max.: ${minW} ${maxW} kg` : null,
trendBits ? `Änderung: ${trendBits}` : null,
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
].filter(Boolean).join('\n')
tiles.push({
key: 'weight',
category: 'Gewicht',
icon: '⚖️',
value: `${summary.weight_kg} kg`,
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
verdict: vs,
status: st,
hoverTop: 'Gewicht',
hoverBody,
keys: ['weight_aktuell', 'weight_trend'],
})
}
const kfRule = rules.find(r => r.category === 'Körperfett')
if (summary.body_fat_pct != null) {
tiles.push({
key: 'bf',
category: 'Körperfett',
icon: '🫧',
value: `${summary.body_fat_pct}%`,
valueColor: bfCat?.color,
sublabel: bfCat?.label || summary.bf_category_label || '',
verdict: verdictShort(kfRule?.status || 'good'),
status: kfRule?.status || 'good',
hoverTop: kfRule?.title || 'Körperfettanteil',
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const mmRule = rules.find(r => r.category === 'Muskelmasse')
if (summary.lean_mass_kg != null || summary.ffmi != null) {
const valParts = []
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
tiles.push({
key: 'lean_ffmi',
category: 'Magermasse',
icon: '💪',
value: valParts.join(' · ') || '—',
sublabel: 'Lean / FFMI',
verdict: mmRule ? verdictShort(mmRule.status) : '—',
status: mmRule?.status || 'good',
hoverTop: mmRule?.title || 'Muskelmasse',
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const bmiRule = rules.find(r => r.category === 'BMI')
if (bmiRule) {
tiles.push({
key: 'bmi',
category: 'BMI',
icon: '📋',
value: bmiRule.value || '—',
sublabel: 'Body-Mass-Index',
verdict: verdictShort(bmiRule.status),
status: bmiRule.status,
hoverTop: bmiRule.title,
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whrRule = rules.find(r => r.category === 'Fettverteilung')
if (summary.whr != null) {
const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85)
tiles.push({
key: 'whr',
category: 'Fettverteilung',
icon: '📐',
value: String(summary.whr),
sublabel: 'WHR · Taille ÷ Hüfte',
verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'),
status: whrRule?.status || (ok ? 'good' : 'warn'),
hoverTop: whrRule?.title || 'Waist-Hip-Ratio',
hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whtrRule = rules.find(r => r.category === 'Taille/Größe')
if (summary.whtr != null) {
const ok = summary.whtr < 0.5
tiles.push({
key: 'whtr',
category: 'Taille/Größe',
icon: '📏',
value: String(summary.whtr),
sublabel: 'WHtR · Taille ÷ Größe',
verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'),
status: whtrRule?.status || (ok ? 'good' : 'warn'),
hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio',
hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const lastRule = rules.find(r => r.category.startsWith('Seit letzter'))
if (lastRule) {
tiles.push({
key: 'delta',
category: 'Messvergleich',
icon: '📊',
value: lastRule.value || '—',
sublabel: 'seit Vorperiode',
verdict: verdictShort(lastRule.status),
status: lastRule.status,
hoverTop: lastRule.title,
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
return tiles
}
/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */
function BodyKpiOverview({ tiles }) {
if (!tiles?.length) return null
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
<div className="body-kpi-overview">
{tiles.map(t => {
const accent = getStatusColor(t.status)
const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n')
return (
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}` }}
title={tip}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
{t.sublabel && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<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örperbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
@ -151,140 +381,191 @@ 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 [viz, setViz] = useState(null)
const [vizLoading, setVizLoading] = useState(true)
const [vizError, setVizError] = useState(null)
const sex = profile?.sex || 'm'
const height = profile?.height||178
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
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
const hasWeight = filtW.length >= 2
const hasCal = filtCal.length >= 1
const hasCir = filtCir.length >= 1
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])
if (!hasWeight && !hasCal && !hasCir) return (
const w = viz?.weight
const cal = viz?.caliper
const circ = viz?.circumference
const summary = viz?.summary || {}
const wCd = (w?.series || []).map(row => ({
date: fmtDate(row.date),
weight: row.weight,
avg7: row.avg7,
avg14: row.avg14,
}))
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 bfCd = (cal?.series || []).map(s => ({
date: fmtDate(s.date),
bf: s.body_fat_pct,
}))
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 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),
waist: r.waist,
hip: r.hip,
belly: r.belly,
}))
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
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 kpiTiles = buildBodyKpiTiles({
summary,
rules,
trendPeriods,
minW,
maxW,
avgAll,
dataPoints: w?.data_points,
sex,
bfCat,
goalW,
})
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>
)
// 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 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 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 chart
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
date:fmtDate(c.date),bf:c.body_fat_pct,lean:c.lean_mass,fat:c.fat_mass
}))
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
// Circ chart
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
}))
// 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
// Rules
const combined = {
...(latestCal||{}),
c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip,
weight:latestW2?.weight
}
const rules = getInterpretation(combined, profile, prevCal||null)
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
<PeriodSelector value={period} onChange={setPeriod} />
{/* 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>}
{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>}
</div>
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Aktivität</strong>.
</p>
{viz?.meta?.layer_2a_alignment && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
<BodyKpiOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
)}
{/* Weight chart 3 lines like WeightScreen */}
{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
Gewicht · {w?.data_points || 0} Einträge
</div>
<button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>window.location.href='/weight'}>
<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)}/>
<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"/>
{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)' }}>
@ -293,73 +574,110 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
<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
</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>}
</div>
)}
</div>
)}
{/* KF + Magermasse chart */}
{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)'}}>KF% + Magermasse</div>
<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 yAxisId="bf" 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']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/>
{profile?.goal_bf_pct && <ReferenceLine yAxisId="bf" 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 yAxisId="bf" type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#1D9E75" strokeWidth={2} dot={{r:3,fill:'#1D9E75'}} name="lean"/>
<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={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3}}/>KF%</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Mager kg</span>
{profile?.goal_bf_pct && <span><span style={{display:'inline-block',width:14,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #D85A30'}}/>Ziel KF</span>}
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% zweite Kurve entfällt.</div>
</div>
)}
{propChartData.length >= 2 && (
<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.
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
</div>
</div>
<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 }}
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" />}
</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>
</div>
)}
{/* Circ trend */}
{cirCd.length>=2 && (
{idxOk && (
<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 Verlauf</div>
<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 />
</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 === '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>
</div>
)}
{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]}/>
<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" />}
@ -368,43 +686,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
</div>
)}
{/* WHR / WHtR detail */}
{(whr||whtr) && (
<div style={{display:'flex',gap:8,marginBottom:12}}>
{whr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Hüfte</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;{sex==='m'?'0,90':'0,85'}</div>
<div style={{fontSize:10,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>
{whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}</div>
</div>}
{whtr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHtR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Körpergröße</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;0,50</div>
<div style={{fontSize:10,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>
{whtr<0.5?' Optimal':' Erhöht'}</div>
</div>}
</div>
)}
{rules.length>0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{rules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
)}
<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)
@ -1125,7 +1410,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}`),