diff --git a/backend/data_layer/body_interpretation.py b/backend/data_layer/body_interpretation.py new file mode 100644 index 0000000..745c799 --- /dev/null +++ b/backend/data_layer/body_interpretation.py @@ -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,5–24,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 diff --git a/backend/data_layer/body_viz.py b/backend/data_layer/body_viz.py new file mode 100644 index 0000000..558490c --- /dev/null +++ b/backend/data_layer/body_viz.py @@ -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", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index d97139d..d985a36 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -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), diff --git a/frontend/src/app.css b/frontend/src/app.css index 899e532..af03b56 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 8a8e2b4..4d5cd2f 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -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 ( +
+
Kennzahlen
+
+ {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 ( +
+
+ {t.icon} +
+
{t.category}
+
{t.value}
+ {t.sublabel && ( +
{t.sublabel}
+ )} +
+
+
{t.verdict}
+
+
+
+ ) + })} +
+
+ ) +} + +function BodyGoalsStrip({ grouped }) { + const nav = useNavigate() + const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) + if (!goals.length) return null + return ( +
+
+
Körperbezogene Ziele
+ +
+
+ {goals.map(g => ( +
+
{g.name || g.label_de || g.goal_type}
+
+
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''} +
+
+ ))} +
+
+ ) +} + function InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] @@ -151,260 +381,315 @@ 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 sex = profile?.sex||'m' - const height = profile?.height||178 + const [groupedGoals, setGroupedGoals] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoading, setVizLoading] = useState(true) + const [vizError, setVizError] = useState(null) - 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) + const sex = profile?.sex || 'm' - const hasWeight = filtW.length >= 2 - const hasCal = filtCal.length >= 1 - const hasCir = filtCir.length >= 1 + useEffect(() => { + let cancelled = false + api.listGoalsGrouped() + .then(g => { if (!cancelled) setGroupedGoals(g) }) + .catch(() => { if (!cancelled) setGroupedGoals({}) }) + return () => { cancelled = true } + }, []) - if (!hasWeight && !hasCal && !hasCir) return ( -
- - - -
- ) + 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]) - // ── 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 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 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 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 + const bfCd = (cal?.series || []).map(s => ({ + date: fmtDate(s.date), + bf: s.body_fat_pct, })) - // ── 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 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) - // ── Rules ── - const combined = { - ...(latestCal||{}), - c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip, - weight:latestW2?.weight + 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 ( +
+ +
+
+ ) + } + if (vizError) { + return ( +
+ +
{vizError}
+
+ ) + } + if (!hasAnyData) { + return ( +
+ + + +
+ ) } - const rules = getInterpretation(combined, profile, prevCal||null) return (
- - + + - {/* Summary stats */} -
- {latestW2 &&
-
{latestW2.weight} kg
-
Aktuell
-
} - {latestCal?.body_fat_pct &&
-
{latestCal.body_fat_pct}%
-
KF {bfCat?.label}
-
} - {latestCal?.lean_mass &&
-
{latestCal.lean_mass} kg
-
Mager
-
} - {whr &&
-
{whr}
-
WHR
-
} - {whtr &&
-
{whtr}
-
WHtR
-
} -
+ + +

+ Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Aktivität. +

+ + {viz?.meta?.layer_2a_alignment && ( +
+ {viz.meta.layer_2a_alignment} +
+ )} + + + + {vizLoading && ( +
Aktualisiere…
+ )} - {/* Weight chart – 3 lines like WeightScreen */} {hasWeight && ( -
-
-
- Gewicht · {filtW.length} Einträge +
+
+
+ Gewicht · {w?.data_points || 0} Einträge
-
- - - - - {avgAll && } - {profile?.goal_weight && } - [`${v} kg`,n==='weight'?'Täglich':n==='avg7'?'Ø 7 Tage':'Ø 14 Tage']}/> - - - + + + + + {avgAll != null && ( + + )} + {goalW != null && ( + + )} + [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} /> + + + -
- ● Täglich - Ø 7T - Ø 14T - Ø Gesamt +
+ ● Täglich + Ø 7T + Ø 14T + Ø Gesamt
- - {/* Trend tiles */} - {trendPeriods.length>0 && ( -
- {trendPeriods.map(({label,diff})=>( -
0?'var(--warn)':'var(--border)'}`}}> -
0?'var(--warn)':'var(--text3)'}}> - {diff>0?'+':''}{diff} kg -
-
{label}
-
- ))} - {minW &&
-
{minW}
-
{maxW}
-
Min/Max
-
} -
- )}
)} - {/* KF + Magermasse chart */} - {bfCd.length>=2 && ( -
-
-
KF% + Magermasse
- + {bfCd.length >= 2 && ( +
+
+
Körperfett (Caliper)
+
- - - - - - [`${v}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/> - {profile?.goal_bf_pct && } - - + + + + + [`${v}%`, 'KF%']} /> + {goalBf != null && } + -
- KF% - Mager kg - {profile?.goal_bf_pct && Ziel KF} +
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
+
+ )} + + {propChartData.length >= 2 && ( +
+
+
+
Silhouette & Proportion
+
+ V-Taper (Brust − Taille) in cm. + {showBellyOnProp && <> Bauch (rechte Achse).} +
+
+ +
+ + + + + + {showBellyOnProp && } + { + 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] + }} + /> + + + {showBellyOnProp && } + + +
+ Brust − Taille + gleitender Mittelwert + {showBellyOnProp && Bauch (cm)}
)} - {/* Circ trend */} - {cirCd.length>=2 && ( -
-
-
Umfänge Verlauf
- + {idxOk && ( +
+
+
+
Relative Entwicklung der Umfänge
+
Index 100 = erste Messung im Zeitraum.
+
+
+ + + + + + + [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} /> + {idxSeries.some(d => d.chest_idx != null) && } + {idxSeries.some(d => d.waist_idx != null) && } + {idxSeries.some(d => d.belly_idx != null) && } + + +
+ Brust + Taille + Bauch +
+
+ )} + + {propChartData.length < 2 && cirCd.length >= 2 && ( +
+
+
Umfänge (Taille / Hüfte / Bauch)
+ +
+
Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.
- - - - - [`${v} cm`,n]}/> - - - {cirCd.some(d=>d.belly) && } + + + + + [`${v} cm`, n]} /> + + + {cirCd.some(d => d.belly) && }
)} - {/* WHR / WHtR detail */} - {(whr||whtr) && ( -
- {whr &&
-
{whr}
-
WHR
-
Taille ÷ Hüfte
-
Ziel <{sex==='m'?'0,90':'0,85'}
-
- {whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}
-
} - {whtr &&
-
{whtr}
-
WHtR
-
Taille ÷ Körpergröße
-
Ziel <0,50
-
- {whtr<0.5?'✓ Optimal':'⚠️ Erhöht'}
-
} -
- )} - - {rules.length>0 && ( -
-
BEWERTUNG
- {rules.map((item,i)=>)} -
- )} - - +
) } - // ── Nutrition Section ───────────────────────────────────────────────────────── function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) @@ -1125,7 +1410,7 @@ export default function History() {
- {tab==='body' && } + {tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3dd10a2..e6f9b38 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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}`),