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({