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.
This commit is contained in:
parent
157afd10b9
commit
461c358dc2
330
backend/data_layer/body_interpretation.py
Normal file
330
backend/data_layer/body_interpretation.py
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"""
|
||||
Body interpretation tiles for Layer 2b (Verlauf UI).
|
||||
|
||||
Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext).
|
||||
Uses the same thresholds; outputs structured tiles + related_placeholder_keys
|
||||
for alignment with Layer 2a registry keys.
|
||||
|
||||
No formatting for KI — structured dicts only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), 4)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]:
|
||||
out: Dict[str, float] = {}
|
||||
w = _safe_float(m.get("c_waist"))
|
||||
h = _safe_float(m.get("c_hip"))
|
||||
lean = _safe_float(m.get("lean_mass"))
|
||||
if w and h:
|
||||
out["whr"] = round(w / h, 2)
|
||||
if w and height_cm:
|
||||
out["whtr"] = round(w / height_cm, 2)
|
||||
if lean and height_cm:
|
||||
hm = height_cm / 100.0
|
||||
out["ffmi"] = round(lean / (hm ** 2), 1)
|
||||
return out
|
||||
|
||||
|
||||
def _bf_status_ranges(sex: str) -> Dict[str, float]:
|
||||
if sex == "f":
|
||||
return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32}
|
||||
return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25}
|
||||
|
||||
|
||||
def get_body_interpretation_tiles(
|
||||
measurement: Dict[str, Any],
|
||||
profile: Dict[str, Any],
|
||||
prev_measurement: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns interpretation tiles. Each tile includes related_placeholder_keys
|
||||
pointing to Layer 2a registry keys fed by the same Layer-1 metrics.
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
sex = profile.get("sex") or "m"
|
||||
height = _safe_float(profile.get("height")) or 178.0
|
||||
|
||||
m = measurement
|
||||
derived = _calc_derived(m, height)
|
||||
|
||||
# ── Körperfett ──────────────────────────────────────────────────────────
|
||||
bf = _safe_float(m.get("body_fat_pct"))
|
||||
if bf is not None:
|
||||
ranges = _bf_status_ranges(sex)
|
||||
if bf <= ranges["essential"]:
|
||||
msg = "Sehr niedriger Körperfettanteil"
|
||||
detail = (
|
||||
"Essenzielle Fettwerte – nur für Leistungssportler geeignet, "
|
||||
"auf Dauer nicht empfehlenswert."
|
||||
)
|
||||
status = "warn"
|
||||
elif bf <= ranges["athletic"]:
|
||||
msg = "Athletischer Körperfettanteil"
|
||||
detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen."
|
||||
status = "good"
|
||||
elif bf <= ranges["fit"]:
|
||||
msg = "Guter Körperfettanteil"
|
||||
detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form."
|
||||
status = "good"
|
||||
elif bf <= ranges["avg"]:
|
||||
msg = "Durchschnittlicher Körperfettanteil"
|
||||
detail = (
|
||||
"Im normalen Bereich. Verbesserung durch Kombination aus Kraft- "
|
||||
"und Ausdauertraining möglich."
|
||||
)
|
||||
status = "warn"
|
||||
else:
|
||||
msg = "Erhöhter Körperfettanteil"
|
||||
detail = (
|
||||
"Über dem empfohlenen Bereich. Ernährungsumstellung und "
|
||||
"regelmäßiges Training empfohlen."
|
||||
)
|
||||
status = "bad"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"category": "Körperfett",
|
||||
"icon": "🫧",
|
||||
"status": status,
|
||||
"title": msg,
|
||||
"detail": detail,
|
||||
"value": f"{bf}%",
|
||||
"related_placeholder_keys": ["caliper_summary", "fm_28d_change"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── WHR ─────────────────────────────────────────────────────────────────
|
||||
whr = derived.get("whr")
|
||||
if whr is not None:
|
||||
limit = 0.90 if sex == "m" else 0.85
|
||||
limit_high = 1.0 if sex == "m" else 0.95
|
||||
if whr < limit:
|
||||
status = "good"
|
||||
title = "Günstige Fettverteilung"
|
||||
detail = (
|
||||
f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). "
|
||||
"Birnenförmige Fettverteilung – metabolisch günstig."
|
||||
)
|
||||
elif whr < limit_high:
|
||||
status = "warn"
|
||||
title = "Grenzwertiger WHR"
|
||||
detail = (
|
||||
f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). "
|
||||
"Apfelförmige Tendenz – Bauchfett reduzieren empfohlen."
|
||||
)
|
||||
else:
|
||||
status = "bad"
|
||||
title = "Erhöhtes Risiko durch Fettverteilung"
|
||||
detail = (
|
||||
f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes "
|
||||
"kardiovaskuläres Risiko durch viszerales Fett."
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"category": "Fettverteilung",
|
||||
"icon": "📐",
|
||||
"status": status,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"value": str(whr),
|
||||
"related_placeholder_keys": ["waist_hip_ratio", "circ_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── WHtR ────────────────────────────────────────────────────────────────
|
||||
whtr = derived.get("whtr")
|
||||
if whtr is not None:
|
||||
if whtr < 0.40:
|
||||
status = "warn"
|
||||
title = "Sehr schlanke Taille"
|
||||
detail = f"WHtR {whtr} – möglicherweise zu wenig Körpermasse."
|
||||
elif whtr < 0.50:
|
||||
status = "good"
|
||||
title = "Optimale Taillen-Größen-Relation"
|
||||
detail = (
|
||||
f"WHtR {whtr} – im optimalen Bereich. Geringstes kardiovaskuläres Risiko."
|
||||
)
|
||||
elif whtr < 0.60:
|
||||
status = "warn"
|
||||
title = "Leicht erhöhter WHtR"
|
||||
detail = f"WHtR {whtr} – Ziel ist unter 0,50. Moderat erhöhtes Risiko."
|
||||
else:
|
||||
status = "bad"
|
||||
title = "Stark erhöhter WHtR"
|
||||
detail = (
|
||||
f"WHtR {whtr} – deutlich erhöhtes Risiko. Taille sollte weniger "
|
||||
"als die Hälfte der Körpergröße betragen."
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"category": "Taille/Größe",
|
||||
"icon": "📏",
|
||||
"status": status,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"value": str(whtr),
|
||||
"related_placeholder_keys": ["circ_summary", "waist_28d_delta"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── FFMI ─────────────────────────────────────────────────────────────────
|
||||
ffmi = derived.get("ffmi")
|
||||
if ffmi is not None:
|
||||
natural_limit = 25.0 if sex == "m" else 22.0
|
||||
if ffmi < (18.0 if sex == "m" else 15.0):
|
||||
status = "warn"
|
||||
title = "Unterdurchschnittliche Muskelmasse"
|
||||
detail = (
|
||||
f"FFMI {ffmi} – Krafttraining kann die Muskelmasse und den "
|
||||
"Grundumsatz deutlich verbessern."
|
||||
)
|
||||
elif ffmi < (22.0 if sex == "m" else 19.0):
|
||||
status = "good"
|
||||
title = "Durchschnittliche Muskelmasse"
|
||||
detail = f"FFMI {ffmi} – gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar."
|
||||
elif ffmi <= natural_limit:
|
||||
status = "good"
|
||||
title = "Überdurchschnittliche Muskelmasse"
|
||||
detail = f"FFMI {ffmi} – sehr gut. Oberes natürliches Spektrum für Kraftsportler."
|
||||
else:
|
||||
status = "warn"
|
||||
title = "Außergewöhnlich hohe Muskelmasse"
|
||||
detail = (
|
||||
f"FFMI {ffmi} – oberhalb der natürlichen Grenze (~{natural_limit}). "
|
||||
"Selten ohne unterstützende Mittel erreichbar."
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"category": "Muskelmasse",
|
||||
"icon": "💪",
|
||||
"status": status,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"value": str(ffmi),
|
||||
"related_placeholder_keys": ["lbm_28d_change", "caliper_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── BMI ───────────────────────────────────────────────────────────────────
|
||||
w_kg = _safe_float(m.get("weight"))
|
||||
if w_kg is not None and height > 0:
|
||||
bmi = round(w_kg / ((height / 100.0) ** 2), 1)
|
||||
if bmi < 18.5:
|
||||
status = "warn"
|
||||
title = "Untergewicht (BMI)"
|
||||
detail = f"BMI {bmi} – unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten."
|
||||
elif bmi < 25:
|
||||
status = "good"
|
||||
title = "Normalgewicht (BMI)"
|
||||
detail = f"BMI {bmi} – im optimalen Bereich (18,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
|
||||
440
backend/data_layer/body_viz.py
Normal file
440
backend/data_layer/body_viz.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, lineHeight: 1.3, color: 'var(--text1)' }}>{item.title}</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
|
||||
{item.related_placeholder_keys?.length > 0 && (
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 6, lineHeight: 1.35 }}>
|
||||
Layer 2a (Registry): {item.related_placeholder_keys.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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 [viz, setViz] = useState(null)
|
||||
const [vizLoading, setVizLoading] = useState(true)
|
||||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
const sex = profile?.sex || 'm'
|
||||
const height = profile?.height||178
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -253,172 +263,220 @@ 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 (
|
||||
const wCd = (w?.series || []).map(row => ({
|
||||
date: fmtDate(row.date),
|
||||
weight: row.weight,
|
||||
avg7: row.avg7,
|
||||
avg14: row.avg14,
|
||||
}))
|
||||
const hasWeight = (w?.data_points || 0) >= 2
|
||||
const avgAll = w?.overall_avg_kg
|
||||
const minW = w?.min_kg
|
||||
const maxW = w?.max_kg
|
||||
const trendPeriods = w?.trend_periods || []
|
||||
|
||||
const bfCd = (cal?.series || []).map(s => ({
|
||||
date: fmtDate(s.date),
|
||||
bf: s.body_fat_pct,
|
||||
}))
|
||||
|
||||
const propChartData = (circ?.proportion_series || []).map(p => ({
|
||||
date: fmtDate(p.date),
|
||||
vTaper: p.v_taper_cm,
|
||||
vTaper_avg: p.v_taper_cm_avg,
|
||||
belly: p.belly_cm,
|
||||
}))
|
||||
const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined)
|
||||
|
||||
const idxSeriesRaw = circ?.index_series || []
|
||||
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
|
||||
const idxOk = circ?.index_usable
|
||||
|
||||
const cirCd = (circ?.fallback_multiline || []).map(r => ({
|
||||
date: fmtDate(r.date),
|
||||
waist: r.waist,
|
||||
hip: r.hip,
|
||||
belly: r.belly,
|
||||
}))
|
||||
|
||||
const 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
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<div className="empty-state"><div className="spinner" /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (vizError) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Weight chart ──
|
||||
const withAvg = rollingAvg(filtW,'weight')
|
||||
const withAvg14= rollingAvg(filtW,'weight',14)
|
||||
const wCd = withAvg.map((d,i)=>({
|
||||
date:fmtDate(d.date),
|
||||
weight:d.weight,
|
||||
avg7: d.weight_avg,
|
||||
avg14: withAvg14[i]?.weight_avg,
|
||||
}))
|
||||
const ws = filtW.map(w=>w.weight)
|
||||
const minW = ws.length ? Math.min(...ws) : null
|
||||
const maxW = ws.length ? Math.max(...ws) : null
|
||||
const avgAll = ws.length ? Math.round(ws.reduce((a,b)=>a+b)/ws.length*10)/10 : null
|
||||
|
||||
const trendPeriods = [7,30,90].map(days=>{
|
||||
const cut = dayjs().subtract(days,'day').format('YYYY-MM-DD')
|
||||
const per = filtW.filter(d=>d.date>=cut)
|
||||
if (per.length<2) return null
|
||||
const diff = Math.round((per[per.length-1].weight-per[0].weight)*10)/10
|
||||
return {label:`${days}T`,diff,count:per.length}
|
||||
}).filter(Boolean)
|
||||
|
||||
// ── Caliper: 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 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 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 => ({
|
||||
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,
|
||||
}))
|
||||
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
|
||||
|
||||
// ── Rules ──
|
||||
const combined = {
|
||||
...(latestCal||{}),
|
||||
c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip,
|
||||
weight:latestW2?.weight
|
||||
}
|
||||
const rules = getInterpretation(combined, profile, prevCal||null)
|
||||
const derivedFFMI = calcDerived(combined, height)?.ffmi
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
|
||||
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
|
||||
<BodyGoalsStrip grouped={groupedGoals} />
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
||||
Hinweis: Diese Seite bündelt <strong>Körpermaße und -zusammensetzung</strong>. Trainingsbedingte Fitness (Belastung, Leistung, Ausdauer) findest du unter{' '}
|
||||
<strong>Verlauf → Aktivität</strong> — dort werden sportliche Trends ausgewertet, hier geht es um Silhouette, Zusammensetzung und Gesundheitsindikatoren.
|
||||
<strong>Layer 2b</strong>: Diagramme und Bewertung stammen aus dem Backend-Bundle — dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '}
|
||||
Sportliche Fitness: <strong>Verlauf → Aktivität</strong>.
|
||||
</p>
|
||||
|
||||
{/* Summary stats */}
|
||||
{viz?.meta?.layer_2a_alignment && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.4 }}>
|
||||
{viz.meta.layer_2a_alignment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{latestW2 && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||||
<div style={{fontSize:16,fontWeight:700}}>{latestW2.weight} kg</div>
|
||||
{summary.weight_kg != null && (
|
||||
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{summary.weight_kg} kg</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Aktuell</div>
|
||||
</div>}
|
||||
{latestCal?.body_fat_pct && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:bfCat?.color}}>{latestCal.body_fat_pct}%</div>
|
||||
<div style={{fontSize:9,color:'var(--text3)'}}>KF {bfCat?.label}</div>
|
||||
</div>}
|
||||
{latestCal?.lean_mass && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
|
||||
</div>
|
||||
)}
|
||||
{summary.body_fat_pct != null && (
|
||||
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: bfCat?.color }}>{summary.body_fat_pct}%</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>KF {bfCat?.label || summary.bf_category_label}</div>
|
||||
</div>
|
||||
)}
|
||||
{summary.lean_mass_kg != null && (
|
||||
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#1D9E75' }}>{summary.lean_mass_kg} kg</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Mager</div>
|
||||
</div>}
|
||||
{derivedFFMI != null && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:'#378ADD'}}>{derivedFFMI}</div>
|
||||
</div>
|
||||
)}
|
||||
{summary.ffmi != null && (
|
||||
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#378ADD' }}>{summary.ffmi}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>FFMI</div>
|
||||
</div>}
|
||||
{whr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
|
||||
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
|
||||
</div>
|
||||
)}
|
||||
{whr != null && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 70,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 6px',
|
||||
textAlign: 'center',
|
||||
borderTop: `3px solid ${whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)' }}>{whr}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHR</div>
|
||||
</div>}
|
||||
{whtr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
|
||||
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
|
||||
</div>
|
||||
)}
|
||||
{whtr != null && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 70,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 6px',
|
||||
textAlign: 'center',
|
||||
borderTop: `3px solid ${whtr < 0.5 ? 'var(--accent)' : 'var(--warn)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: whtr < 0.5 ? 'var(--accent)' : 'var(--warn)' }}>{whtr}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHtR</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weight chart – 3 lines like WeightScreen */}
|
||||
{vizLoading && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
||||
)}
|
||||
|
||||
{hasWeight && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Gewicht · {filtW.length} Einträge
|
||||
Gewicht · {w?.data_points || 0} Einträge
|
||||
</div>
|
||||
<button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
|
||||
onClick={()=>window.location.href='/weight'}>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
|
||||
Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(wCd.length/6)-1)}/>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(wCd.length / 6) - 1)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{avgAll && <ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{value:`Ø ${avgAll}`,fontSize:9,fill:'var(--text3)',position:'right'}}/>}
|
||||
{profile?.goal_weight && <ReferenceLine y={profile.goal_weight} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5}
|
||||
label={{value:`Ziel ${profile.goal_weight}kg`,fontSize:9,fill:'var(--accent)',position:'right'}}/>}
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
formatter={(v,n)=>[`${v} kg`,n==='weight'?'Täglich':n==='avg7'?'Ø 7 Tage':'Ø 14 Tage']}/>
|
||||
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5}
|
||||
dot={{r:3,fill:'#378ADD',stroke:'#378ADD',strokeWidth:1}} activeDot={{r:5}} name="weight"/>
|
||||
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5}
|
||||
dot={false} name="avg7"/>
|
||||
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2}
|
||||
dot={false} strokeDasharray="6 3" name="avg14"/>
|
||||
{avgAll != null && (
|
||||
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
|
||||
)}
|
||||
{goalW != null && (
|
||||
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}kg`, fontSize: 9, fill: 'var(--accent)', position: 'right' }} />
|
||||
)}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
|
||||
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} name="weight" />
|
||||
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
|
||||
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
|
||||
|
|
@ -427,30 +485,28 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>Ø 14T</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
|
||||
</div>
|
||||
|
||||
{/* Trend tiles */}
|
||||
{trendPeriods.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
|
||||
{trendPeriods.map(({label,diff})=>(
|
||||
<div key={label} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center',
|
||||
borderTop:`3px solid ${diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--border)'}`}}>
|
||||
{trendPeriods.map(({ label, diff_kg: diff }) => (
|
||||
<div key={label} style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center', borderTop: `3px solid ${diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--border)'}` }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--text3)' }}>
|
||||
{diff > 0 ? '+' : ''}{diff} kg
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
{minW && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center'}}>
|
||||
{minW != null && (
|
||||
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--accent)', fontWeight: 600 }}>{minW}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--warn)', fontWeight: 600 }}>{maxW}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Min/Max</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */}
|
||||
{bfCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
|
|
@ -462,31 +518,23 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
formatter={(v) => [`${v}%`, 'KF%']}/>
|
||||
{profile?.goal_bf_pct && <ReferenceLine y={profile.goal_bf_pct}
|
||||
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
|
||||
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={v => [`${v}%`, 'KF%']} />
|
||||
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
|
||||
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:6,lineHeight:1.4}}>
|
||||
Magermasse ergibt sich aus Gewicht und KF% — als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben.
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% — zweite Kurve entfällt.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proportion: V-Taper vs. Bauch (Brust−Taille vs. Bauchumfang) */}
|
||||
{propChartData.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
|
||||
<strong>V-Taper (Brust − Taille)</strong> in cm: größer bedeutet stärkere Schulter-/Brustentwicklung relativ zur Taille.
|
||||
{showBellyOnProp && (
|
||||
<><strong> Bauch</strong> (rechte Achse): steigender Trend hier deutet eher auf Zunahme zentralen Umfangs hin — unabhängig von sportlicher Brustentwicklung.</>
|
||||
)}
|
||||
<strong>V-Taper (Brust − Taille)</strong> in cm.
|
||||
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
|
||||
</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
|
|
@ -496,20 +544,18 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{showBellyOnProp && (
|
||||
<YAxis yAxisId="belly" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
||||
)}
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => {
|
||||
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille']
|
||||
if (name === 'belly') return [`${v} cm`, 'Bauch']
|
||||
return [v, name]
|
||||
}}/>
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
|
||||
{showBellyOnProp && (
|
||||
<Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{r:3}} connectNulls name="belly"/>
|
||||
)}
|
||||
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||
|
|
@ -520,15 +566,12 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Relative Umfangsänderung (Index erste Messung im Zeitraum = 100) */}
|
||||
{idxOk && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
|
||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:4,lineHeight:1.4}}>
|
||||
Index 100 = erste erfasste Messung im Zeitraum. So sind Trend und Richtung besser vergleichbar als absolute cm-Werte nebeneinander.
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
|
|
@ -538,11 +581,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
formatter={(v,n)=>[`${v} Index`, n==='chestIdx'?'Brust':n==='waistIdx'?'Taille':'Bauch']}/>
|
||||
{idxSeries.some(d=>d.chestIdx!=null) && <Line type="monotone" dataKey="chestIdx" stroke="#1D9E75" strokeWidth={2} dot={{r:2}} connectNulls name="chestIdx"/>}
|
||||
{idxSeries.some(d=>d.waistIdx!=null) && <Line type="monotone" dataKey="waistIdx" stroke="#EF9F27" strokeWidth={2} dot={{r:2}} connectNulls name="waistIdx"/>}
|
||||
{idxSeries.some(d=>d.bellyIdx!=null) && <Line type="monotone" dataKey="bellyIdx" stroke="#D4537E" strokeWidth={2} dot={{r:2}} connectNulls name="bellyIdx"/>}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
|
||||
{idxSeries.some(d => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
|
||||
{idxSeries.some(d => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
|
||||
{idxSeries.some(d => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||
|
|
@ -553,23 +595,19 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: klassischer Taille/Hüfte/Bauch-Verlauf wenn keine Brust-Taille-Kombi */}
|
||||
{propChartData.length < 2 && cirCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<div style={{fontSize:10,color:'var(--text3)',marginBottom:8,lineHeight:1.4}}>
|
||||
Sobald Brust- und Taillenumfang gemeinsam erfasst sind, erscheint oben die Proportionen-Ansicht (V-Taper).
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
formatter={(v,n)=>[`${v} cm`,n]}/>
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
|
||||
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
|
||||
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
|
||||
{cirCd.some(d => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
|
||||
|
|
@ -580,12 +618,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
|||
|
||||
<EvaluationTileGrid items={rules} />
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
|
||||
onRequest={onRequest} loading={loadingSlug}/>
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(30)
|
||||
|
|
@ -1306,7 +1342,7 @@ export default function History() {
|
|||
</div>
|
||||
</nav>
|
||||
<div className="history-content">
|
||||
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
|
||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user