From 461c358dc2f06d4822b309d7461a4587d31c0b79 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 16:06:07 +0200 Subject: [PATCH] feat: add body history visualization endpoint and frontend integration - Introduced a new API endpoint `/body-history-viz` to retrieve body history visualization data. - Updated the frontend to fetch and display body history data in the `BodySection` component. - Enhanced the `EvaluationTileGrid` to include related placeholder keys for improved data interpretation. - Refactored existing logic to streamline data handling and improve user experience. --- backend/data_layer/body_interpretation.py | 330 +++++++++++++ backend/data_layer/body_viz.py | 440 +++++++++++++++++ backend/routers/charts.py | 25 + frontend/src/pages/History.jsx | 548 ++++++++++++---------- frontend/src/utils/api.js | 2 + 5 files changed, 1089 insertions(+), 256 deletions(-) create mode 100644 backend/data_layer/body_interpretation.py create mode 100644 backend/data_layer/body_viz.py 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..a73aca0 --- /dev/null +++ b/backend/data_layer/body_viz.py @@ -0,0 +1,440 @@ +""" +Layer 2b: Structured body history / Verlauf «Körper» bundle. + +Single source for Verlauf-UI: series + Kennzahlen + Interpretation tiles. +All queries use the same tables as Layer 1 / Layer 2a body placeholders. + +See: placeholder_registrations/body_metrics.py, body_extras.py +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from db import get_db, get_cursor, r2d +from data_layer.body_interpretation import get_body_interpretation_tiles +from data_layer.utils import safe_float + + +def _cutoff_sql(days: int) -> Optional[str]: + if days >= 9999: + return None + return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + +def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for i, d in enumerate(rows): + sl = rows[max(0, i - window + 1) : i + 1] + vals: List[float] = [] + for x in sl: + v = safe_float(x.get(key)) + if v is not None: + vals.append(v) + if not vals: + out.append({**d, f"{key}_avg": None}) + continue + avg = round(sum(vals) / len(vals), 1) + out.append({**d, f"{key}_avg": avg}) + return out + + +def _iso(d: Any) -> Optional[str]: + if d is None: + return None + if hasattr(d, "isoformat"): + return d.isoformat() + return str(d)[:10] + + +def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: + """ + Returns chart-ready series and interpretation tiles for the body history tab. + + Args: + profile_id: profiles.id + days: analysis window (use >= 9999 for full history) + + Tables: weight_log, caliper_log, circumference_log, profiles + """ + cutoff = _cutoff_sql(days) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, sex, height, dob, goal_weight, goal_bf_pct + FROM profiles WHERE id = %s + """, + (profile_id,), + ) + pr = r2d(cur.fetchone()) + if not pr: + return { + "confidence": "insufficient", + "message": "Profil nicht gefunden", + "profile": {}, + "weight": {}, + "caliper": {}, + "circumference": {}, + "interpretation_tiles": [], + "meta": {}, + } + + profile_ui = { + "sex": pr.get("sex") or "m", + "height": safe_float(pr.get("height")) or 178.0, + "goal_weight_kg": safe_float(pr.get("goal_weight")), + "goal_bf_pct": safe_float(pr.get("goal_bf_pct")), + } + + # ── Weight (same window as Verlauf-Filter) ──────────────────────────── + if cutoff: + cur.execute( + """ + SELECT date, weight FROM weight_log + WHERE profile_id = %s AND date >= %s + ORDER BY date ASC + """, + (profile_id, cutoff), + ) + else: + cur.execute( + """ + SELECT date, weight FROM weight_log + WHERE profile_id = %s + ORDER BY date ASC + """, + (profile_id,), + ) + wrows = [r2d(r) for r in cur.fetchall()] + w_points = [ + {"date": r["date"], "weight": safe_float(r["weight"])} + for r in wrows + if r.get("weight") is not None + ] + w_with_avg7 = _rolling_avg([dict(x) for x in w_points], "weight", 7) + w_with_avg14 = _rolling_avg([dict(x) for x in w_points], "weight", 14) + weight_series: List[Dict[str, Any]] = [] + for i, base in enumerate(w_points): + weight_series.append( + { + "date": _iso(base["date"]), + "weight": base["weight"], + "avg7": w_with_avg7[i].get("weight_avg") if i < len(w_with_avg7) else None, + "avg14": w_with_avg14[i].get("weight_avg") if i < len(w_with_avg14) else None, + } + ) + + ws = [p["weight"] for p in w_points if p.get("weight") is not None] + overall_avg = round(sum(ws) / len(ws), 1) if len(ws) else None + min_w = min(ws) if ws else None + max_w = max(ws) if ws else None + + today = datetime.now().date() + trend_periods: List[Dict[str, Any]] = [] + for span in (7, 30, 90): + cut = today - timedelta(days=span) + per = [p for p in w_points if p["date"] >= cut] + if len(per) >= 2: + diff = round(float(per[-1]["weight"]) - float(per[0]["weight"]), 1) + trend_periods.append({"label": f"{span}T", "diff_kg": diff, "count": len(per)}) + + # ── Caliper series ─────────────────────────────────────────────────── + if cutoff: + cur.execute( + """ + SELECT date, body_fat_pct, lean_mass, fat_mass + FROM caliper_log + WHERE profile_id = %s + AND body_fat_pct IS NOT NULL + AND date >= %s + ORDER BY date ASC + """, + (profile_id, cutoff), + ) + else: + cur.execute( + """ + SELECT date, body_fat_pct, lean_mass, fat_mass + FROM caliper_log + WHERE profile_id = %s AND body_fat_pct IS NOT NULL + ORDER BY date ASC + """, + (profile_id,), + ) + cal_rows = [r2d(r) for r in cur.fetchall()] + caliper_series = [ + { + "date": _iso(r["date"]), + "body_fat_pct": safe_float(r.get("body_fat_pct")), + "lean_mass": safe_float(r.get("lean_mass")), + } + for r in cal_rows + ] + + # Latest / prev caliper in window (for interpretation) + if cutoff: + cur.execute( + """ + SELECT date, body_fat_pct, lean_mass + FROM caliper_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC + LIMIT 2 + """, + (profile_id, cutoff), + ) + else: + cur.execute( + """ + SELECT date, body_fat_pct, lean_mass + FROM caliper_log + WHERE profile_id = %s + ORDER BY date DESC + LIMIT 2 + """, + (profile_id,), + ) + cal_latest_rows = [r2d(r) for r in cur.fetchall()] + latest_cal = cal_latest_rows[0] if cal_latest_rows else None + prev_cal = cal_latest_rows[1] if len(cal_latest_rows) > 1 else None + + # ── Circumference rows ─────────────────────────────────────────────── + if cutoff: + cur.execute( + """ + SELECT date, c_chest, c_waist, c_hip, c_belly + FROM circumference_log + WHERE profile_id = %s AND date >= %s + ORDER BY date ASC + """, + (profile_id, cutoff), + ) + else: + cur.execute( + """ + SELECT date, c_chest, c_waist, c_hip, c_belly + FROM circumference_log + WHERE profile_id = %s + ORDER BY date ASC + """, + (profile_id,), + ) + cir_rows = [r2d(r) for r in cur.fetchall()] + + if cutoff: + cur.execute( + """ + SELECT date, c_chest, c_waist, c_hip, c_belly + FROM circumference_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC + LIMIT 1 + """, + (profile_id, cutoff), + ) + else: + cur.execute( + """ + SELECT date, c_chest, c_waist, c_hip, c_belly + FROM circumference_log + WHERE profile_id = %s + ORDER BY date DESC + LIMIT 1 + """, + (profile_id,), + ) + latest_circ_row = r2d(cur.fetchone()) + + # Latest weight in window + latest_w = w_points[-1] if w_points else None + + # ── Proportion & index (computed from L1 rows only) ───────────────────── + prop_base: List[Dict[str, Any]] = [] + for r in cir_rows: + ch = safe_float(r.get("c_chest")) + wa = safe_float(r.get("c_waist")) + if ch is None or wa is None: + continue + belly = safe_float(r.get("c_belly")) + prop_base.append( + { + "date": _iso(r["date"]), + "v_taper_cm": round(ch - wa, 1), + "belly_cm": belly, + } + ) + prop_chart = _rolling_avg([dict(x) for x in prop_base], "v_taper_cm", 3) if len(prop_base) >= 2 else [] + for i, row in enumerate(prop_chart): + row["belly_cm"] = prop_base[i].get("belly_cm") + + fb_first: Dict[str, Optional[float]] = {"chest": None, "waist": None, "belly": None} + for r in cir_rows: + if fb_first["chest"] is None and r.get("c_chest") is not None: + fb_first["chest"] = safe_float(r["c_chest"]) + if fb_first["waist"] is None and r.get("c_waist") is not None: + fb_first["waist"] = safe_float(r["c_waist"]) + if fb_first["belly"] is None and r.get("c_belly") is not None: + fb_first["belly"] = safe_float(r["c_belly"]) + + index_series: List[Dict[str, Any]] = [] + for r in cir_rows: + idx_row: Dict[str, Any] = {"date": _iso(r["date"])} + cc = safe_float(r.get("c_chest")) + ww = safe_float(r.get("c_waist")) + bb = safe_float(r.get("c_belly")) + if cc is not None and fb_first["chest"]: + idx_row["chest_idx"] = round(cc / fb_first["chest"] * 100, 1) + else: + idx_row["chest_idx"] = None + if ww is not None and fb_first["waist"]: + idx_row["waist_idx"] = round(ww / fb_first["waist"] * 100, 1) + else: + idx_row["waist_idx"] = None + if bb is not None and fb_first["belly"]: + idx_row["belly_idx"] = round(bb / fb_first["belly"] * 100, 1) + else: + idx_row["belly_idx"] = None + index_series.append(idx_row) + + idx_nonempty = sum( + 1 + for row in index_series + if row.get("chest_idx") is not None + or row.get("waist_idx") is not None + or row.get("belly_idx") is not None + ) + + fallback_circ = [ + { + "date": _iso(r["date"]), + "waist": safe_float(r.get("c_waist")), + "hip": safe_float(r.get("c_hip")), + "belly": safe_float(r.get("c_belly")), + } + for r in cir_rows + if r.get("c_waist") or r.get("c_hip") or r.get("c_belly") + ] + + # ── Merge measurement for interpretation ──────────────────────────────── + measurement: Dict[str, Any] = {} + if latest_cal: + measurement.update( + { + "date": latest_cal.get("date"), + "body_fat_pct": safe_float(latest_cal.get("body_fat_pct")), + "lean_mass": safe_float(latest_cal.get("lean_mass")), + } + ) + if latest_circ_row: + measurement["c_waist"] = safe_float(latest_circ_row.get("c_waist")) + measurement["c_hip"] = safe_float(latest_circ_row.get("c_hip")) + measurement["c_belly"] = safe_float(latest_circ_row.get("c_belly")) + if latest_w: + measurement["weight"] = safe_float(latest_w.get("weight")) + + prev_for_interp = None + if prev_cal: + prev_for_interp = { + "date": prev_cal.get("date"), + "body_fat_pct": safe_float(prev_cal.get("body_fat_pct")), + "lean_mass": safe_float(prev_cal.get("lean_mass")), + } + + tiles = get_body_interpretation_tiles(measurement, profile_ui, prev_for_interp) + + last_dates: List[date] = [] + if w_points: + last_dates.append(w_points[-1]["date"]) + if latest_cal and latest_cal.get("date"): + d = latest_cal["date"] + if isinstance(d, str): + d = datetime.fromisoformat(d[:10]).date() + last_dates.append(d) + if latest_circ_row and latest_circ_row.get("date"): + d = latest_circ_row["date"] + if isinstance(d, str): + d = datetime.fromisoformat(d[:10]).date() + last_dates.append(d) + last_updated = max(last_dates).isoformat() if last_dates else None + + bf_cat = None + if measurement.get("body_fat_pct") is not None: + # simple label bucket (aligned with frontend BF_CATEGORIES order) + bf = float(measurement["body_fat_pct"]) + sex = profile_ui["sex"] + if sex == "f": + labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"] + bounds = [14, 21, 25, 32, 1000] + else: + labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"] + bounds = [6, 14, 18, 25, 1000] + for i, b in enumerate(bounds): + if bf <= b: + bf_cat = labels[i] + break + + summary = { + "weight_kg": measurement.get("weight"), + "body_fat_pct": measurement.get("body_fat_pct"), + "lean_mass_kg": measurement.get("lean_mass"), + "whr": ( + round(measurement["c_waist"] / measurement["c_hip"], 2) + if measurement.get("c_waist") and measurement.get("c_hip") + else None + ), + "whtr": ( + round(measurement["c_waist"] / profile_ui["height"], 2) + if measurement.get("c_waist") and profile_ui.get("height") + else None + ), + "ffmi": None, + "bf_category_label": bf_cat, + } + if measurement.get("lean_mass") and profile_ui.get("height"): + hm = float(profile_ui["height"]) / 100.0 + summary["ffmi"] = round(float(measurement["lean_mass"]) / (hm**2), 1) + + return { + "confidence": "high" if w_points or caliper_series or cir_rows else "insufficient", + "days_requested": days, + "last_updated": last_updated, + "profile": profile_ui, + "summary": summary, + "weight": { + "series": weight_series, + "overall_avg_kg": overall_avg, + "min_kg": min_w, + "max_kg": max_w, + "trend_periods": trend_periods, + "data_points": len(w_points), + "related_placeholder_keys": [ + "weight_aktuell", + "weight_trend", + "weight_7d_median", + "weight_28d_slope", + "weight_90d_slope", + ], + }, + "caliper": { + "series": caliper_series, + "data_points": len(caliper_series), + "related_placeholder_keys": ["caliper_summary", "fm_28d_change", "lbm_28d_change"], + }, + "circumference": { + "proportion_series": prop_chart, + "index_series": index_series, + "index_usable": idx_nonempty >= 2 and any(v for v in fb_first.values()), + "fallback_multiline": fallback_circ, + "has_chest_waist": len(prop_base) >= 2, + "related_placeholder_keys": ["circ_summary", "waist_hip_ratio", "waist_28d_delta"], + }, + "interpretation_tiles": tiles, + "meta": { + "layer_1": "data_layer.body_viz + data_layer.body_interpretation", + "layer_2b": "This bundle — sole numeric source for Verlauf Körper charts/tiles", + "layer_2a_alignment": "Tiles carry related_placeholder_keys; metrics from same tables as body_metrics placeholders", + }, + } 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/pages/History.jsx b/frontend/src/pages/History.jsx index 1c7d6c4..bdf4820 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -9,8 +9,8 @@ import { import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' -import { getBfCategory, calcDerived } from '../utils/calc' -import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' +import { getBfCategory } from '../utils/calc' +import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import NutritionCharts from '../components/NutritionCharts' @@ -112,7 +112,14 @@ function EvaluationTileGrid({ items }) { }}>{item.category}
{item.title}
{expanded && ( -
{item.detail}
+ <> +
{item.detail}
+ {item.related_placeholder_keys?.length > 0 && ( +
+ Layer 2a (Registry): {item.related_placeholder_keys.join(', ')} +
+ )} + )} {item.value && ( @@ -238,12 +245,15 @@ function PeriodSelector({ value, onChange }) { ) } -// ── Body Section (Weight + Composition combined) ────────────────────────────── -function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { +// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ── +function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(90) const [groupedGoals, setGroupedGoals] = useState(null) - const sex = profile?.sex||'m' - const height = profile?.height||178 + const [viz, setViz] = useState(null) + const [vizLoading, setVizLoading] = useState(true) + const [vizError, setVizError] = useState(null) + + const sex = profile?.sex || 'm' useEffect(() => { let cancelled = false @@ -253,326 +263,354 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l return () => { cancelled = true } }, []) - const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') - const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date)) - .filter(d=>period===9999||d.date>=cutoff) - const filtCal = (calipers||[]).filter(d=>period===9999||d.date>=cutoff) - const filtCir = (circs||[]).filter(d=>period===9999||d.date>=cutoff) + useEffect(() => { + let cancelled = false + setVizLoading(true) + setVizError(null) + api.getBodyHistoryViz(period) + .then(data => { + if (!cancelled) { + setViz(data) + setVizLoading(false) + } + }) + .catch(e => { + if (!cancelled) { + setVizError(e.message || 'Laden fehlgeschlagen') + setVizLoading(false) + } + }) + return () => { cancelled = true } + }, [period]) - const hasWeight = filtW.length >= 2 - const hasCal = filtCal.length >= 1 - const hasCir = filtCir.length >= 1 + const w = viz?.weight + const cal = viz?.caliper + const circ = viz?.circumference + const summary = viz?.summary || {} - if (!hasWeight && !hasCal && !hasCir) return ( -
- - - -
- ) - - // ── Weight chart ── - const withAvg = rollingAvg(filtW,'weight') - const withAvg14= rollingAvg(filtW,'weight',14) - const wCd = withAvg.map((d,i)=>({ - date:fmtDate(d.date), - weight:d.weight, - avg7: d.weight_avg, - avg14: withAvg14[i]?.weight_avg, + const wCd = (w?.series || []).map(row => ({ + date: fmtDate(row.date), + weight: row.weight, + avg7: row.avg7, + avg14: row.avg14, })) - const ws = filtW.map(w=>w.weight) - const minW = ws.length ? Math.min(...ws) : null - const maxW = ws.length ? Math.max(...ws) : null - const avgAll = ws.length ? Math.round(ws.reduce((a,b)=>a+b)/ws.length*10)/10 : null + const hasWeight = (w?.data_points || 0) >= 2 + const avgAll = w?.overall_avg_kg + const minW = w?.min_kg + const maxW = w?.max_kg + const trendPeriods = w?.trend_periods || [] - const trendPeriods = [7,30,90].map(days=>{ - const cut = dayjs().subtract(days,'day').format('YYYY-MM-DD') - const per = filtW.filter(d=>d.date>=cut) - if (per.length<2) return null - const diff = Math.round((per[per.length-1].weight-per[0].weight)*10)/10 - return {label:`${days}T`,diff,count:per.length} - }).filter(Boolean) - - // ── Caliper: nur KF% (Magermasse ist daraus abgeleitet — eigene zweite Achse entfällt) ── - const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({ - date:fmtDate(c.date), bf:c.body_fat_pct, - })) - const latestCal = filtCal[0] - const prevCal = filtCal[1] - const latestCir = filtCir[0] - const latestW2 = filtW[filtW.length-1] - const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null - - // ── Umfänge: chronologisch für Trends & Proportionen ── - const circChron = [...filtCir].sort((a,b)=>a.date.localeCompare(b.date)) - const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({ - date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly + const bfCd = (cal?.series || []).map(s => ({ + date: fmtDate(s.date), + bf: s.body_fat_pct, })) - const propBase = circChron - .filter(r => r.c_chest && r.c_waist) - .map(r => ({ - date: fmtDate(r.date), - vTaper: Math.round((r.c_chest - r.c_waist) * 10) / 10, - belly: r.c_belly != null ? Math.round(r.c_belly * 10) / 10 : null, - })) - const propChartData = propBase.length >= 2 ? rollingAvg(propBase, 'vTaper', 3) : [] - const showBellyOnProp = propChartData.some(d => d.belly != null) + const propChartData = (circ?.proportion_series || []).map(p => ({ + date: fmtDate(p.date), + vTaper: p.v_taper_cm, + vTaper_avg: p.v_taper_cm_avg, + belly: p.belly_cm, + })) + const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined) - const fbFirst = { chest: null, waist: null, belly: null } - for (const r of circChron) { - if (fbFirst.chest == null && r.c_chest) fbFirst.chest = r.c_chest - if (fbFirst.waist == null && r.c_waist) fbFirst.waist = r.c_waist - if (fbFirst.belly == null && r.c_belly) fbFirst.belly = r.c_belly - } - const idxSeries = circChron.map(r => ({ + const idxSeriesRaw = circ?.index_series || [] + const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) })) + const idxOk = circ?.index_usable + + const cirCd = (circ?.fallback_multiline || []).map(r => ({ date: fmtDate(r.date), - chestIdx: r.c_chest && fbFirst.chest ? Math.round((r.c_chest / fbFirst.chest) * 1000) / 10 : null, - waistIdx: r.c_waist && fbFirst.waist ? Math.round((r.c_waist / fbFirst.waist) * 1000) / 10 : null, - bellyIdx: r.c_belly && fbFirst.belly ? Math.round((r.c_belly / fbFirst.belly) * 1000) / 10 : null, + waist: r.waist, + hip: r.hip, + belly: r.belly, })) - const idxCount = idxSeries.filter(row => row.chestIdx != null || row.waistIdx != null || row.bellyIdx != null).length - const idxOk = idxCount >= 2 && (fbFirst.chest || fbFirst.waist || fbFirst.belly) - // ── Indicators ── - const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null - const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null + const whr = summary.whr + const whtr = summary.whtr + const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null + const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight + const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct - // ── Rules ── - const combined = { - ...(latestCal||{}), - c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip, - weight:latestW2?.weight + const rules = (viz?.interpretation_tiles || []).map(t => ({ + category: t.category, + icon: t.icon, + status: t.status, + title: t.title, + detail: t.detail, + value: t.value, + related_placeholder_keys: t.related_placeholder_keys, + })) + + const hasAnyData = + (w?.data_points > 0) || + (cal?.data_points > 0) || + (cirCd.length > 0) + + if (vizLoading && !viz) { + return ( +
+ +
+
+ ) + } + if (vizError) { + return ( +
+ +
{vizError}
+
+ ) + } + if (!hasAnyData) { + return ( +
+ + + +
+ ) } - const rules = getInterpretation(combined, profile, prevCal||null) - const derivedFFMI = calcDerived(combined, height)?.ffmi return (
- - + +

- Hinweis: Diese Seite bündelt Körpermaße und -zusammensetzung. Trainingsbedingte Fitness (Belastung, Leistung, Ausdauer) findest du unter{' '} - Verlauf → Aktivität — dort werden sportliche Trends ausgewertet, hier geht es um Silhouette, Zusammensetzung und Gesundheitsindikatoren. + Layer 2b: Diagramme und Bewertung stammen aus dem Backend-Bundle — dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '} + Sportliche Fitness: Verlauf → Aktivität.

- {/* 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
-
} - {derivedFFMI != null &&
-
{derivedFFMI}
-
FFMI
-
} - {whr &&
-
{whr}
-
WHR
-
} - {whtr &&
-
{whtr}
-
WHtR
-
} + {viz?.meta?.layer_2a_alignment && ( +
+ {viz.meta.layer_2a_alignment} +
+ )} + +
+ {summary.weight_kg != null && ( +
+
{summary.weight_kg} kg
+
Aktuell
+
+ )} + {summary.body_fat_pct != null && ( +
+
{summary.body_fat_pct}%
+
KF {bfCat?.label || summary.bf_category_label}
+
+ )} + {summary.lean_mass_kg != null && ( +
+
{summary.lean_mass_kg} kg
+
Mager
+
+ )} + {summary.ffmi != null && ( +
+
{summary.ffmi}
+
FFMI
+
+ )} + {whr != null && ( +
+
{whr}
+
WHR
+
+ )} + {whtr != null && ( +
+
{whtr}
+
WHtR
+
+ )}
- {/* Weight chart – 3 lines like WeightScreen */} + {vizLoading && ( +
Aktualisiere…
+ )} + {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 + {trendPeriods.length > 0 && ( +
+ {trendPeriods.map(({ label, diff_kg: diff }) => ( +
0 ? 'var(--warn)' : 'var(--border)'}` }}> +
0 ? 'var(--warn)' : 'var(--text3)' }}> + {diff > 0 ? '+' : ''}{diff} kg
-
{label}
+
{label}
))} - {minW &&
-
{minW}
-
{maxW}
-
Min/Max
-
} + {minW != null && ( +
+
{minW}
+
{maxW}
+
Min/Max
+
+ )}
)}
)} - {/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */} - {bfCd.length>=2 && ( -
-
-
Körperfett (Caliper)
- + {bfCd.length >= 2 && ( +
+
+
Körperfett (Caliper)
+
- - - - - [`${v}%`, 'KF%']}/> - {profile?.goal_bf_pct && } - + + + + + [`${v}%`, 'KF%']} /> + {goalBf != null && } + -
- Magermasse ergibt sich aus Gewicht und KF% — als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben. -
+
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
)} - {/* Proportion: V-Taper vs. Bauch (Brust−Taille vs. Bauchumfang) */} {propChartData.length >= 2 && ( -
-
+
+
-
Silhouette & Proportion
-
- V-Taper (Brust − Taille) in cm: größer bedeutet stärkere Schulter-/Brustentwicklung relativ zur Taille. - {showBellyOnProp && ( - <> Bauch (rechte Achse): steigender Trend hier deutet eher auf Zunahme zentralen Umfangs hin — unabhängig von sportlicher Brustentwicklung. - )} +
Silhouette & Proportion
+
+ V-Taper (Brust − Taille) in cm. + {showBellyOnProp && <> Bauch (rechte Achse).}
- +
- - - - - {showBellyOnProp && ( - - )} - + + + + {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 && ( - - )} + }} + /> + + + {showBellyOnProp && } -
- Brust − Taille - gleitender Mittelwert - {showBellyOnProp && Bauch (cm)} +
+ Brust − Taille + gleitender Mittelwert + {showBellyOnProp && Bauch (cm)}
)} - {/* Relative Umfangsänderung (Index erste Messung im Zeitraum = 100) */} {idxOk && ( -
-
+
+
-
Relative Entwicklung der Umfänge
-
- Index 100 = erste erfasste Messung im Zeitraum. So sind Trend und Richtung besser vergleichbar als absolute cm-Werte nebeneinander. -
+
Relative Entwicklung der Umfänge
+
Index 100 = erste Messung im Zeitraum.
- +
- - - - - - [`${v} Index`, n==='chestIdx'?'Brust':n==='waistIdx'?'Taille':'Bauch']}/> - {idxSeries.some(d=>d.chestIdx!=null) && } - {idxSeries.some(d=>d.waistIdx!=null) && } - {idxSeries.some(d=>d.bellyIdx!=null) && } + + + + + + [`${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 +
+ Brust + Taille + Bauch
)} - {/* Fallback: klassischer Taille/Hüfte/Bauch-Verlauf wenn keine Brust-Taille-Kombi */} - {propChartData.length < 2 && cirCd.length>=2 && ( -
-
-
Umfänge (Taille / Hüfte / Bauch)
- -
-
- Sobald Brust- und Taillenumfang gemeinsam erfasst sind, erscheint oben die Proportionen-Ansicht (V-Taper). + {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) && }
@@ -580,12 +618,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l - +
) } - // ── Nutrition Section ───────────────────────────────────────────────────────── function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) @@ -1306,7 +1342,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}`),