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}
- 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 */} -