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 ( +
+ Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Aktivität. +
+ + {viz?.meta?.layer_2a_alignment && ( +