diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py index a014576..4178f8e 100644 --- a/backend/data_layer/nutrition_interpretation.py +++ b/backend/data_layer/nutrition_interpretation.py @@ -121,8 +121,7 @@ def build_nutrition_history_kpi_tiles( "status": "bad", "verdict": _verdict("bad"), "hint": ( - f"Es fehlen rund {miss} g Protein pro Tag – bei Kaloriendefizit " - "steigt das Risiko für Muskelerhalt." + f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet." ), "hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", "hoverBody": ( @@ -159,8 +158,8 @@ def build_nutrition_history_kpi_tiles( "status": "warn", "verdict": _verdict("warn"), "hint": ( - f"Viele Kalorien kommen aus KH/Fett; Proteinanteil oft sinnvoll bei 25–35 % " - f"(aktuell P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %)." + f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); " + "Ziel oft 25–35 %." ), "hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", "hoverBody": ( @@ -173,6 +172,37 @@ def build_nutrition_history_kpi_tiles( return tiles +def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning.""" + level = str(ea.get("warning_level") or "none").strip().lower() + if level == "none": + return None + triggers: List[str] = list(ea.get("triggers") or []) + msg = str(ea.get("message") or "").strip() + st = "bad" if level == "warning" else "warn" + first = triggers[0] if triggers else msg + if len(first) > 90: + first = first[:87] + "…" + meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {} + note = str(meta.get("note") or "") + hover_lines = [msg] + [f"• {t}" for t in triggers] + if note: + hover_lines.append(note) + return { + "key": "energy-availability-e5", + "category": "Energieverfügbarkeit", + "icon": "⚡", + "value": "Achtung" if level == "warning" else "Hinweis", + "sublabel": first or "Signale prüfen", + "status": st, + "verdict": _verdict(st), + "hint": msg, + "hoverTop": "Energieverfügbarkeit (Heuristik)", + "hoverBody": "\n".join(hover_lines), + "keys": ["nutrition_score"], + } + + def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]: """Anteile in % der Makro-kcal + Gramm für Legende.""" p = float(navg.get("protein_avg") or 0) diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 33c844a..b3865d8 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -699,6 +699,70 @@ def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dic } +def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict: + """ + E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning. + """ + from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d + from data_layer.body_metrics import calculate_lbm_28d_change + + triggers: List[str] = [] + warning_level = "none" + + energy_data = get_energy_balance_data(profile_id, days) + if energy_data.get("energy_balance", 0) < -500: + triggers.append("Großes Energiedefizit (>500 kcal/Tag)") + + try: + recovery_score = calculate_recovery_score_v2(profile_id) + if recovery_score and recovery_score < 50: + triggers.append("Recovery Score niedrig (<50)") + except Exception: + pass + + try: + sleep_quality = calculate_sleep_quality_7d(profile_id) + if sleep_quality and sleep_quality < 60: + triggers.append("Schlafqualität reduziert (<60%)") + except Exception: + pass + + try: + lbm_change = calculate_lbm_28d_change(profile_id) + if lbm_change and lbm_change < -1.0: + triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change))) + except Exception: + pass + + if len(triggers) >= 3: + warning_level = "warning" + message = ( + "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. " + "Erwäge Defizit-Anpassung oder Regenerationswoche." + ) + elif len(triggers) >= 2: + warning_level = "caution" + message = ( + "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten." + ) + elif len(triggers) >= 1: + warning_level = "caution" + message = "💡 Ein Indikator auffällig. Weiter beobachten." + else: + message = "✅ Energieverfügbarkeit unauffällig." + + return { + "warning_level": warning_level, + "triggers": triggers, + "message": message, + "metadata": { + "days_analyzed": days, + "trigger_count": len(triggers), + "note": "Heuristische Einschätzung, keine medizinische Diagnose", + }, + } + + # ============================================================================ # Calculated Metrics (migrated from calculations/nutrition_metrics.py) # ============================================================================ diff --git a/backend/data_layer/nutrition_viz.py b/backend/data_layer/nutrition_viz.py index f05b0a5..8891cf6 100644 --- a/backend/data_layer/nutrition_viz.py +++ b/backend/data_layer/nutrition_viz.py @@ -11,11 +11,13 @@ from typing import Any, Dict, List, Optional from db import get_db, get_cursor, r2d from data_layer.nutrition_interpretation import ( + build_energy_availability_kpi_tile, build_macro_donut_from_averages, build_nutrition_history_kpi_tiles, ) from data_layer.nutrition_metrics import ( estimate_tdee_kcal_from_latest_weight, + get_energy_availability_warning_payload, get_energy_balance_data, get_nutrition_average_data, get_protein_targets_data, @@ -184,12 +186,14 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "tdee_reference_kcal": None, "energy_balance_meta": {}, "interpretation_tiles": [], + "energy_availability_warning": None, "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, } all_history = days >= 9999 eff_days = 3650 if all_history else max(7, min(int(days), 3650)) cutoff = _cutoff_sql(days) + chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history) targets = get_protein_targets_data(profile_id) @@ -223,12 +227,18 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An navg, targets, date_span_label or "—", max(1, n_days) ) + ea_days = min(28, max(7, chart_days_for_pipeline)) + ea_payload = get_energy_availability_warning_payload(profile_id, ea_days) + ea_tile = build_energy_availability_kpi_tile(ea_payload) + kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles) + if ea_tile: + kpi_tiles_out.append(ea_tile) + donut = build_macro_donut_from_averages(navg) kw_points = _kcal_weight_points_for_window(profile_id, cutoff) pt_low = round(float(targets.get("protein_target_low") or 0)) - chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7)) weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly) @@ -255,8 +265,9 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "protein_target_high": targets.get("protein_target_high"), "reference_weight_kg": targets.get("current_weight"), }, - "kpi_tiles": kpi_tiles, + "kpi_tiles": kpi_tiles_out, "interpretation_tiles": [], + "energy_availability_warning": ea_payload, "daily_macros": daily_macros, "donut_avg_pct": donut, "protein_reference_line_g": pt_low, diff --git a/backend/routers/charts.py b/backend/routers/charts.py index eee336a..fca7c36 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -40,6 +40,7 @@ from data_layer.nutrition_metrics import ( get_macro_consistency_data, get_energy_balance_data, get_weekly_macro_distribution_chart_data, + get_energy_availability_warning_payload, ) from data_layer.activity_metrics import ( get_activity_summary_data, @@ -1026,87 +1027,10 @@ def get_energy_availability_warning( """ Energy Availability Warning (E5) - Konzept-konform. - Heuristic warning for potential undernutrition/overtraining. - - Checks: - - Persistent large deficit - - Recovery score declining - - Sleep quality declining - - LBM declining - - Args: - days: Analysis window (7-28 days, default 14) - session: Auth session (injected) - - Returns: - { - "warning_level": "none" | "caution" | "warning", - "triggers": [...], - "message": "..." - } + Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload """ profile_id = session['profile_id'] - - from db import get_db, get_cursor - from data_layer.nutrition_metrics import get_energy_balance_data - from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d - from data_layer.body_metrics import calculate_lbm_28d_change - - triggers = [] - warning_level = "none" - - # Check 1: Large energy deficit - energy_data = get_energy_balance_data(profile_id, days) - if energy_data.get('energy_balance', 0) < -500: - triggers.append("Großes Energiedefizit (>500 kcal/Tag)") - - # Check 2: Recovery declining - try: - recovery_score = calculate_recovery_score_v2(profile_id) - if recovery_score and recovery_score < 50: - triggers.append("Recovery Score niedrig (<50)") - except: - pass - - # Check 3: Sleep quality - try: - sleep_quality = calculate_sleep_quality_7d(profile_id) - if sleep_quality and sleep_quality < 60: - triggers.append("Schlafqualität reduziert (<60%)") - except: - pass - - # Check 4: LBM declining - try: - lbm_change = calculate_lbm_28d_change(profile_id) - if lbm_change and lbm_change < -1.0: - triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change))) - except: - pass - - # Determine warning level - if len(triggers) >= 3: - warning_level = "warning" - message = "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. Erwäge Defizit-Anpassung oder Regenerationswoche." - elif len(triggers) >= 2: - warning_level = "caution" - message = "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten." - elif len(triggers) >= 1: - warning_level = "caution" - message = "💡 Ein Indikator auffällig. Weiter beobachten." - else: - message = "✅ Energieverfügbarkeit unauffällig." - - return { - "warning_level": warning_level, - "triggers": triggers, - "message": message, - "metadata": { - "days_analyzed": days, - "trigger_count": len(triggers), - "note": "Heuristische Einschätzung, keine medizinische Diagnose" - } - } + return get_energy_availability_warning_payload(profile_id, days) @router.get("/training-volume") diff --git a/frontend/src/app.css b/frontend/src/app.css index 9cccb52..f8092ee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -350,6 +350,11 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we font-style: italic; } +/* KPI: Kurz-Hinweis max. 2 Zeilen — Details weiter per ℹ */ +.kpi-tiles-card__hint { + max-height: 2.8em; +} + /* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */ .nutrition-macro-pair { display: grid; diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx index e6fead0..275e8a4 100644 --- a/frontend/src/components/KpiTilesOverview.jsx +++ b/frontend/src/components/KpiTilesOverview.jsx @@ -125,14 +125,17 @@ export default function KpiTilesOverview({
{cardHint} diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index acec389..cd3a7da 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -206,7 +206,12 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error } /** * Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird. */ -export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) { +export default function NutritionCharts({ + days = 28, + showWeeklyMacroDistribution = true, + /** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */ + hideEnergyAvailabilityCard = false, +}) { const [energyData, setEnergyData] = useState(null) const [proteinData, setProteinData] = useState(null) const [macroWeeklyData, setMacroWeeklyData] = useState(null) @@ -221,15 +226,17 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution useEffect(() => { loadCharts() - }, [days, showWeeklyMacroDistribution]) + }, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard]) const loadCharts = async () => { const tasks = [ loadEnergyBalance(), loadProteinAdequacy(), loadAdherence(), - loadWarning(), ] + if (!hideEnergyAvailabilityCard) { + tasks.push(loadWarning()) + } if (showWeeklyMacroDistribution) { tasks.splice(2, 0, loadMacroWeekly()) } @@ -474,7 +481,7 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution )} {!loading.adherence && !errors.adherence && renderAdherence()} - {!loading.warning && !errors.warning && renderWarning()} + {!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 99be26c..c6f4591 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -700,6 +700,26 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl ) } +/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */ +function kcalVsWeightKcalDomain(points, tdeeRef) { + const vals = (points || []) + .map(d => Number(d.kcal_avg)) + .filter(v => !Number.isNaN(v)) + if (!vals.length) return ['auto', 'auto'] + let lo = Math.min(...vals) + let hi = Math.max(...vals) + const t = tdeeRef != null ? Number(tdeeRef) : NaN + if (!Number.isNaN(t)) { + lo = Math.min(lo, t) + hi = Math.max(hi, t) + } + const span = hi - lo || 400 + const pad = Math.max(100, span * 0.1) + return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)] +} + +const TDEE_REF_LINE_COLOR = '#475569' + /** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ function KcalVsWeightLegend({ showTdee }) { const line = (color) => ({ @@ -753,7 +773,7 @@ function KcalVsWeightLegend({ showTdee }) { height: 0, verticalAlign: 'middle', marginRight: 6, - borderTop: '2px dashed #EA580C', + borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`, opacity: 0.95, }} /> @@ -774,6 +794,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD })) const n = vizKcalWeight.common_days_count ?? kcalVsW.length const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) return (
@@ -786,14 +807,21 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> {tdeeLabel != null && ( - + )} @@ -823,6 +851,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161 const tdee = Math.round(bmr * 1.4) const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal') + const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee) return (
@@ -836,13 +865,20 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> - + @@ -1054,7 +1090,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
Zeitverläufe (Energie & Protein)
- +