diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py new file mode 100644 index 0000000..4178f8e --- /dev/null +++ b/backend/data_layer/nutrition_interpretation.py @@ -0,0 +1,219 @@ +""" +Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf. + +Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert +für KpiTilesOverview (keys = related_placeholder_keys). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def _verdict(status: str) -> str: + if status == "good": + return "Gut" + if status == "warn": + return "Hinweis" + return "Achtung" + + +def build_nutrition_history_kpi_tiles( + navg: Dict[str, Any], + targets: Dict[str, Any], + date_span_label: str, + n_days_with_entries: int, +) -> List[Dict[str, Any]]: + """ + KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln). + """ + kcal_avg = round(float(navg.get("kcal_avg") or 0)) + avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10 + avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10 + avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10 + + pt_low = round(float(targets.get("protein_target_low") or 0)) + pt_high = round(float(targets.get("protein_target_high") or 0)) + targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0 + protein_ok = targets_ok and avg_protein >= pt_low + + total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9 + prot_pct = ( + round(avg_protein * 4 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + kh_pct = ( + round(avg_carbs * 4 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + fat_pct = ( + round(avg_fat * 9 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + + tiles: List[Dict[str, Any]] = [ + { + "key": "kcal", + "category": "Kalorien (Ø)", + "icon": "🔥", + "value": f"{kcal_avg} kcal", + "sublabel": date_span_label, + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliche tägliche Energie", + "hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.", + "keys": ["nutrition_score"], + }, + { + "key": "carbs", + "category": "KH (Ø)", + "icon": "🌾", + "value": f"{avg_carbs} g", + "sublabel": "Kohlenhydrate / Tag", + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliche Kohlenhydrate", + "hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.", + "keys": ["nutrition_summary"], + }, + { + "key": "fat", + "category": "Fett (Ø)", + "icon": "🧈", + "value": f"{avg_fat} g", + "sublabel": "Fett / Tag", + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliches Fett", + "hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.", + "keys": ["nutrition_summary"], + }, + ] + + if not targets_ok: + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": "Referenzgewicht fehlt", + "status": "warn", + "verdict": _verdict("warn"), + "hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.", + "hoverTop": "Protein-Ziel nicht berechenbar", + "hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.", + "keys": ["protein_adequacy"], + } + ) + elif not protein_ok: + miss = max(0, pt_low - round(avg_protein)) + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "status": "bad", + "verdict": _verdict("bad"), + "hint": ( + 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": ( + f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. " + "Konsequenz: Muskelverlust bei Defizit." + ), + "keys": ["protein_adequacy", "nutrition_score"], + } + ) + else: + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "status": "good", + "verdict": _verdict("good"), + "hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "hoverBody": "Ausreichend für Muskelerhalt und -aufbau.", + "keys": ["protein_adequacy", "nutrition_score"], + } + ) + + if prot_pct < 20 and total_macro_kcal > 0: + tiles.append( + { + "key": "eval-macro-pct", + "category": "Makro-Anteil", + "icon": "📊", + "value": f"{prot_pct}%", + "sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", + "status": "warn", + "verdict": _verdict("warn"), + "hint": ( + 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": ( + f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F" + ), + "keys": ["nutrition_summary"], + } + ) + + 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) + c = float(navg.get("carbs_avg") or 0) + f = float(navg.get("fat_avg") or 0) + pkcal, ckcal, fkcal = p * 4, c * 4, f * 9 + tot = pkcal + ckcal + fkcal + if tot <= 0: + return None + return [ + {"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)}, + {"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)}, + {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)}, + ] diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 7ce9fa7..b3865d8 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture Version: 1.0 """ +import statistics from typing import Dict, List, Optional from datetime import datetime, timedelta, date from db import get_db, get_cursor, r2d @@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str: def get_nutrition_average_data( profile_id: str, - days: int = 30 + days: int = 30, + *, + all_history: bool = False, ) -> Dict: """ Get average nutrition values for all macros. @@ -136,11 +139,18 @@ def get_nutrition_average_data( """ with get_db() as conn: cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') # Mean over calendar days (per-day sums), not over raw log rows. + if cutoff: + inner_where = "WHERE profile_id=%s AND date >= %s" + params = (profile_id, cutoff) + else: + inner_where = "WHERE profile_id=%s" + params = (profile_id,) + cur.execute( - """SELECT + f"""SELECT AVG(daily_kcal) AS kcal_avg, AVG(daily_protein) AS protein_avg, AVG(daily_carbs) AS carbs_avg, @@ -153,10 +163,10 @@ def get_nutrition_average_data( COALESCE(SUM(carbs_g), 0)::float AS daily_carbs, COALESCE(SUM(fat_g), 0)::float AS daily_fat FROM nutrition_log - WHERE profile_id=%s AND date >= %s + {inner_where} GROUP BY date ) AS daily""", - (profile_id, cutoff), + params, ) row = cur.fetchone() @@ -494,8 +504,6 @@ def get_macro_consistency_data( "data_points": len(rows) } - import statistics - protein_pcts = [] carbs_pcts = [] fat_pcts = [] @@ -561,6 +569,200 @@ def get_macro_consistency_data( } +def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict: + """ + Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution. + """ + cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, protein_g, carbs_g, fat_g, kcal + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + AND protein_g IS NOT NULL AND carbs_g IS NOT NULL + AND fat_g IS NOT NULL AND kcal > 0 + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows or len(rows) < 7: + return { + "chart_type": "bar", + "data": { + "labels": [], + "datasets": [], + }, + "metadata": { + "confidence": "insufficient", + "data_points": len(rows) if rows else 0, + "message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)", + }, + } + + weekly_data: Dict[str, Dict[str, List[float]]] = {} + for row in rows: + date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"])) + iso_week = date_obj.strftime("%Y-W%V") + + if iso_week not in weekly_data: + weekly_data[iso_week] = { + "protein": [], + "carbs": [], + "fat": [], + "kcal": [], + } + + weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"])) + weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"])) + weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"])) + weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"])) + + labels: List[str] = [] + protein_pcts: List[float] = [] + carbs_pcts: List[float] = [] + fat_pcts: List[float] = [] + + for iso_week in sorted(weekly_data.keys())[-weeks:]: + data = weekly_data[iso_week] + + avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0 + avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0 + avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0 + + protein_kcal = avg_protein * 4 + carbs_kcal = avg_carbs * 4 + fat_kcal = avg_fat * 9 + + total_kcal = protein_kcal + carbs_kcal + fat_kcal + + if total_kcal > 0: + labels.append(f"KW {iso_week[-2:]}") + protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1)) + carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1)) + fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1)) + + protein_cv = ( + statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 + if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 + else 0 + ) + carbs_cv = ( + statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 + if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 + else 0 + ) + fat_cv = ( + statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 + if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 + else 0 + ) + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Protein (%)", + "data": protein_pcts, + "backgroundColor": "#4a8f72", + "stack": "macro", + }, + { + "label": "Kohlenhydrate (%)", + "data": carbs_pcts, + "backgroundColor": "#c17d45", + "stack": "macro", + }, + { + "label": "Fett (%)", + "data": fat_pcts, + "backgroundColor": "#6e8eb8", + "stack": "macro", + }, + ], + }, + "metadata": { + "confidence": calculate_confidence(len(rows), weeks * 7, "general"), + "data_points": len(rows), + "weeks_analyzed": len(labels), + "avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0, + "avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0, + "avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0, + "protein_cv": round(protein_cv, 1), + "carbs_cv": round(carbs_cv, 1), + "fat_cv": round(fat_cv, 1), + }, + } + + +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 new file mode 100644 index 0000000..8891cf6 --- /dev/null +++ b/backend/data_layer/nutrition_viz.py @@ -0,0 +1,294 @@ +""" +Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53). + +Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +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, + get_weekly_macro_distribution_chart_data, +) +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 _iso(d: Any) -> Optional[str]: + if d is None: + return None + if hasattr(d, "isoformat"): + return d.isoformat()[:10] + return str(d)[:10] + + +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 _has_nutrition_entries(profile_id: str) -> bool: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1", + (profile_id,), + ) + return cur.fetchone() is not None + + +def _last_nutrition_date(profile_id: str) -> Optional[str]: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s", + (profile_id,), + ) + row = cur.fetchone() + if not row or row["d"] is None: + return None + return _iso(row["d"]) + + +def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]: + with get_db() as conn: + cur = get_cursor(conn) + if cutoff: + cur.execute( + """SELECT date, + COALESCE(SUM(kcal), 0)::float AS kcal, + COALESCE(SUM(protein_g), 0)::float AS protein_g, + COALESCE(SUM(carbs_g), 0)::float AS carbs_g, + COALESCE(SUM(fat_g), 0)::float AS fat_g + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + GROUP BY date + ORDER BY date ASC""", + (profile_id, cutoff), + ) + else: + cur.execute( + """SELECT date, + COALESCE(SUM(kcal), 0)::float AS kcal, + COALESCE(SUM(protein_g), 0)::float AS protein_g, + COALESCE(SUM(carbs_g), 0)::float AS carbs_g, + COALESCE(SUM(fat_g), 0)::float AS fat_g + FROM nutrition_log + WHERE profile_id=%s + GROUP BY date + ORDER BY date ASC""", + (profile_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +def _kcal_weight_points_for_window( + profile_id: str, cutoff: Optional[str] +) -> List[Dict[str, Any]]: + """Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert.""" + with get_db() as conn: + cur = get_cursor(conn) + if cutoff: + cur.execute( + """SELECT date, SUM(kcal)::float AS kcal + FROM nutrition_log + WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL + GROUP BY date""", + (profile_id, cutoff), + ) + else: + cur.execute( + """SELECT date, SUM(kcal)::float AS kcal + FROM nutrition_log + WHERE profile_id=%s AND kcal IS NOT NULL + GROUP BY date""", + (profile_id,), + ) + nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() } + + if cutoff: + cur.execute( + "SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date", + (profile_id, cutoff), + ) + else: + cur.execute( + "SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", + (profile_id,), + ) + wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None } + + common = sorted(set(nk) & set(wk)) + raw: List[Dict[str, Any]] = [] + for ds in common: + raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]}) + rolled = _rolling_avg(raw, "kcal", 7) + out: List[Dict[str, Any]] = [] + for r in rolled: + out.append( + { + "date": r["date"], + "kcal": r.get("kcal"), + "weight": r.get("weight"), + "kcal_avg": r.get("kcal_avg"), + } + ) + return out + + +def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: + """ + Layer 2b Bundle für Verlauf «Ernährung». + + days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen). + """ + if not _has_nutrition_entries(profile_id): + return { + "confidence": "insufficient", + "has_nutrition_entries": False, + "message": "Noch keine Ernährungsdaten", + "kpi_tiles": [], + "summary": {}, + "daily_macros": [], + "donut_avg_pct": None, + "kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0}, + "weekly_macro_chart": {}, + "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) + energy_days = eff_days if not all_history else min(9999, 3650) + energy_meta = get_energy_balance_data(profile_id, energy_days) + tdee = estimate_tdee_kcal_from_latest_weight(profile_id) + if tdee is None: + tdee = safe_float(energy_meta.get("estimated_tdee")) or None + else: + tdee = float(tdee) + + daily_rows = _fetch_daily_macro_totals(profile_id, cutoff) + daily_macros: List[Dict[str, Any]] = [] + for r in daily_rows: + daily_macros.append( + { + "date": _iso(r["date"]), + "kcal": round(safe_float(r.get("kcal")) or 0), + "Protein": round(safe_float(r.get("protein_g")) or 0), + "KH": round(safe_float(r.get("carbs_g")) or 0), + "Fett": round(safe_float(r.get("fat_g")) or 0), + } + ) + + date_span_label = "" + if daily_macros: + date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}" + + n_days = int(navg.get("data_points") or 0) + kpi_tiles = build_nutrition_history_kpi_tiles( + 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)) + + 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) + + conf = navg.get("confidence") or "medium" + if targets.get("confidence") == "insufficient": + conf = "insufficient" + + return { + "confidence": conf, + "has_nutrition_entries": True, + "days_requested": days, + "effective_window_days": eff_days, + "nutrition_charts_days": chart_days_for_pipeline, + "weekly_macro_weeks_used": weeks_for_weekly, + "last_updated": _last_nutrition_date(profile_id), + "summary": { + "kcal_avg": navg.get("kcal_avg"), + "protein_avg": navg.get("protein_avg"), + "carbs_avg": navg.get("carbs_avg"), + "fat_avg": navg.get("fat_avg"), + "data_points": navg.get("data_points"), + "days_analyzed": navg.get("days_analyzed"), + "protein_target_low": targets.get("protein_target_low"), + "protein_target_high": targets.get("protein_target_high"), + "reference_weight_kg": targets.get("current_weight"), + }, + "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, + "kcal_vs_weight": { + "points": kw_points, + "tdee_reference_kcal": tdee, + "common_days_count": len(kw_points), + }, + "weekly_macro_chart": weekly_chart, + "tdee_reference_kcal": tdee, + "energy_balance_meta": { + "energy_balance": energy_meta.get("energy_balance"), + "avg_intake": energy_meta.get("avg_intake"), + "estimated_tdee": energy_meta.get("estimated_tdee"), + "status": energy_meta.get("status"), + "confidence": energy_meta.get("confidence"), + "data_points": energy_meta.get("data_points"), + }, + "meta": { + "layer_1": "nutrition_metrics", + "layer_2b": "nutrition_viz", + "issue": "53-phase-0c", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index d985a36..fca7c36 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -32,12 +32,15 @@ from data_layer.body_metrics import ( get_circumference_summary_data ) from data_layer.body_viz import get_body_history_viz_bundle +from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, get_protein_adequacy_data, 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, @@ -265,6 +268,26 @@ def get_body_history_viz( return serialize_dates(bundle) +@router.get("/nutrition-history-viz") +def get_nutrition_history_viz( + days: int = Query( + default=90, + ge=7, + le=9999, + description="Analysefenster in Tagen (9999 = gesamte Historie)", + ), + session: dict = Depends(require_auth), +) -> Dict: + """ + Layer 2b: Ein Bundle für Verlauf «Ernährung» — Kennzahlen, Reihen, TDEE-Referenz, Wochen-Chart. + + Alle Kennzahlen aus nutrition_metrics (gleiche Logik wie Platzhalter / Chart-Endpunkte). + """ + profile_id = session["profile_id"] + bundle = get_nutrition_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), @@ -830,136 +853,10 @@ def get_weekly_macro_distribution_chart( Weekly macro distribution (E3) - Konzept-konform. 100%-gestapelter Wochenbalken statt Pie Chart. - Shows macro consistency across weeks, not just overall average. - - Args: - weeks: Number of weeks to analyze (4-52, default 12) - session: Auth session (injected) - - Returns: - Chart.js stacked bar chart with weekly macro percentages + Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data """ profile_id = session['profile_id'] - - from db import get_db, get_cursor - import statistics - - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, protein_g, carbs_g, fat_g, kcal - FROM nutrition_log - WHERE profile_id=%s AND date >= %s - AND protein_g IS NOT NULL AND carbs_g IS NOT NULL - AND fat_g IS NOT NULL AND kcal > 0 - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows or len(rows) < 7: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": len(rows) if rows else 0, - "message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)" - } - } - - # Group by ISO week - weekly_data = {} - for row in rows: - date_obj = row['date'] if isinstance(row['date'], datetime) else datetime.fromisoformat(str(row['date'])) - iso_week = date_obj.strftime('%Y-W%V') - - if iso_week not in weekly_data: - weekly_data[iso_week] = { - 'protein': [], - 'carbs': [], - 'fat': [], - 'kcal': [] - } - - weekly_data[iso_week]['protein'].append(safe_float(row['protein_g'])) - weekly_data[iso_week]['carbs'].append(safe_float(row['carbs_g'])) - weekly_data[iso_week]['fat'].append(safe_float(row['fat_g'])) - weekly_data[iso_week]['kcal'].append(safe_float(row['kcal'])) - - # Calculate weekly averages and percentages - labels = [] - protein_pcts = [] - carbs_pcts = [] - fat_pcts = [] - - for iso_week in sorted(weekly_data.keys())[-weeks:]: - data = weekly_data[iso_week] - - avg_protein = sum(data['protein']) / len(data['protein']) if data['protein'] else 0 - avg_carbs = sum(data['carbs']) / len(data['carbs']) if data['carbs'] else 0 - avg_fat = sum(data['fat']) / len(data['fat']) if data['fat'] else 0 - - # Convert to kcal - protein_kcal = avg_protein * 4 - carbs_kcal = avg_carbs * 4 - fat_kcal = avg_fat * 9 - - total_kcal = protein_kcal + carbs_kcal + fat_kcal - - if total_kcal > 0: - labels.append(f"KW {iso_week[-2:]}") - protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1)) - carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1)) - fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1)) - - # Calculate variation coefficient (Variationskoeffizient) - protein_cv = statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 else 0 - carbs_cv = statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 else 0 - fat_cv = statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 else 0 - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Protein (%)", - "data": protein_pcts, - "backgroundColor": "#1D9E75", - "stack": "macro" - }, - { - "label": "Kohlenhydrate (%)", - "data": carbs_pcts, - "backgroundColor": "#F59E0B", - "stack": "macro" - }, - { - "label": "Fett (%)", - "data": fat_pcts, - "backgroundColor": "#EF4444", - "stack": "macro" - } - ] - }, - "metadata": { - "confidence": calculate_confidence(len(rows), weeks * 7, "general"), - "data_points": len(rows), - "weeks_analyzed": len(labels), - "avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0, - "avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0, - "avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0, - "protein_cv": round(protein_cv, 1), - "carbs_cv": round(carbs_cv, 1), - "fat_cv": round(fat_cv, 1) - } - } + return get_weekly_macro_distribution_chart_data(profile_id, weeks) @router.get("/nutrition-adherence-score") @@ -1130,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 af03b56..f8092ee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -199,13 +199,16 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we .page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; } /* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */ -/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */ +/* KPI-Kachel-Raster: gemeinsam für Verlauf Körper, Dashboard KPI-Board, … + Desktop: title-Tooltip; Touch: ℹ → Bottom-Sheet (siehe KpiTilesOverview.jsx) */ +.kpi-tiles-grid, .body-kpi-overview { display: grid; grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 8px; margin-bottom: 12px; } +.kpi-tiles-card, .body-kpi-card { background: var(--surface2); border-radius: 10px; @@ -215,11 +218,190 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we text-align: left; transition: border-color 0.15s ease, box-shadow 0.15s ease; } +@media (hover: none) { + .kpi-tiles-card, + .body-kpi-card { + cursor: default; + } +} +.kpi-tiles-card:hover, .body-kpi-card:hover { border-color: var(--border2); box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07); } +.kpi-tiles-info-btn, +.body-kpi-info-btn { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 36px; + margin: 0; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text3); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.kpi-tiles-info-btn:active, +.body-kpi-info-btn:active { + background: var(--surface); + color: var(--accent); +} + +.kpi-tiles-touch-backdrop, +.body-kpi-touch-backdrop { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 12px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.45); + animation: kpi-tiles-fade-in 0.15s ease; +} + +@keyframes kpi-tiles-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes body-kpi-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.kpi-tiles-touch-sheet, +.body-kpi-touch-sheet { + width: 100%; + max-width: 520px; + max-height: min(72vh, 560px); + overflow: auto; + margin: 0 auto; + padding: 14px 16px 18px; + border-radius: 16px 16px 0 0; + background: var(--surface); + border: 1px solid var(--border); + border-bottom: none; + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18); +} + +.kpi-tiles-touch-sheet__head, +.body-kpi-touch-sheet__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.kpi-tiles-touch-sheet__title, +.body-kpi-touch-sheet__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text1); + line-height: 1.3; + flex: 1; + min-width: 0; +} + +.kpi-tiles-touch-sheet__close, +.body-kpi-touch-sheet__close { + flex-shrink: 0; + width: 40px; + height: 40px; + margin: -6px -8px 0 0; + padding: 0; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text2); + font-size: 26px; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.kpi-tiles-touch-sheet__close:active, +.body-kpi-touch-sheet__close:active { + background: var(--surface2); +} + +.kpi-tiles-touch-sheet__body, +.body-kpi-touch-sheet__body { + font-size: 13px; + line-height: 1.5; + color: var(--text2); + white-space: pre-wrap; + word-break: break-word; +} + +.kpi-tiles-touch-sheet__body--muted, +.body-kpi-touch-sheet__body--muted { + color: var(--text3); + 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; + gap: 12px; + margin-bottom: 12px; + align-items: stretch; +} + +@media (min-width: 780px) { + .nutrition-macro-pair { + grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.25fr); + } +} + +.nutrition-macro-pair__weekly { + min-width: 0; +} + +/* Einheitliche Chart-Höhe (Donut-Bereich ≈ E3-Balken) */ +.nutrition-macro-pair__chart-wrap { + width: 100%; + min-height: 260px; +} + +.nutrition-macro-pair__donut-inner { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.nutrition-macro-pair__donut-chart { + width: 100%; + min-height: 260px; +} + +.nutrition-macro-pair__legend { + width: 100%; + padding-top: 2px; +} + +.nutrition-macro-pair .card.nutrition-macro-pair__donut, +.nutrition-macro-pair .card.nutrition-macro-pair__weekly { + display: flex; + flex-direction: column; +} + .history-page__title { margin-bottom: 12px; } diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx new file mode 100644 index 0000000..275e8a4 --- /dev/null +++ b/frontend/src/components/KpiTilesOverview.jsx @@ -0,0 +1,178 @@ +import { useState, useEffect, useId } from 'react' +import { Info } from 'lucide-react' +import { getStatusColor } from '../utils/interpret' + +/** + * Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip. + * @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t + */ +export function kpiTileDetailParts(t) { + const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '' + const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n') + return { title: t.hoverTop || t.category || 'Kennzahl', body } +} + +/** Ein Zeilentext wie natives `title` (Desktop-Hover). */ +export function buildKpiTileTitleString(t) { + return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''] + .filter(Boolean) + .join('\n\n') +} + +/** + * Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch ℹ → Bottom-Sheet (gleicher Inhalt). + * + * Erwartete Kachel-Felder: + * - `key` (string, eindeutig) + * - `category` (string) — Zeilenkopf + * - `value` (ReactNode) — Hauptwert + * - `status` — für Farbstreifen: `good` | `warn` | `bad` + * - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys` + * - optional: `hint` — Kurz-Hinweis/Warnung direkt auf der Kachel (z. B. Ernährung bei warn/bad) + */ +export default function KpiTilesOverview({ + tiles, + heading = 'Kennzahlen', + showTouchHint = true, + gridClassName = 'kpi-tiles-grid', + marginBottom = 12, +}) { + const [touchUi, setTouchUi] = useState(false) + const [openKey, setOpenKey] = useState(null) + const sheetTitleId = useId() + + useEffect(() => { + const mq = window.matchMedia('(hover: none)') + const apply = () => setTouchUi(mq.matches) + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, []) + + useEffect(() => { + if (!openKey) return + const onKey = e => { if (e.key === 'Escape') setOpenKey(null) } + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', onKey) + return () => { + document.body.style.overflow = prev + window.removeEventListener('keydown', onKey) + } + }, [openKey]) + + if (!tiles?.length) return null + + const openTile = openKey ? tiles.find(x => x.key === openKey) : null + const openParts = openTile ? kpiTileDetailParts(openTile) : null + + const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—' + + return ( +
+ {heading ? ( +
{heading}
+ ) : null} + {showTouchHint && touchUi && ( +
+ + Auf dem Smartphone: für Erklärung und Details. +
+ )} +
+ {tiles.map(t => { + const accent = getStatusColor(t.status) + const tip = buildKpiTileTitleString(t) + const cardHint = t.hint ? String(t.hint) : null + return ( +
+ {touchUi && ( + + )} +
+ {t.icon != null && t.icon !== false ? ( + {t.icon} + ) : ( + + )} +
+
{t.category}
+
{t.value}
+ {t.sublabel ? ( +
{t.sublabel}
+ ) : null} +
+ {showVerdict(t.verdict) ? ( +
+
{t.verdict}
+
+ ) : null} +
+ {cardHint ? ( +
+ {cardHint} +
+ ) : null} +
+ ) + })} +
+ + {openParts && ( +
setOpenKey(null)} + > +
e.stopPropagation()} + > +
+

{openParts.title}

+ +
+ {openParts.body ? ( +
{openParts.body}
+ ) : ( +
Keine weiteren Details.
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index 9a03343..cd3a7da 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react' import { LineChart, Line, BarChart, Bar, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, + ComposedChart, ReferenceArea, } from 'recharts' import { api } from '../utils/api' +import { MACRO_CHART, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import dayjs from 'dayjs' const fmtDate = d => dayjs(d).format('DD.MM') @@ -135,16 +137,81 @@ function WarningCard({ title, warning_level, triggers, message }) { ) } +/** Wöchentliche Makro-Verteilung (E3) — für Verlauf neben Donut nutzbar. */ +export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }) { + if (loading) { + return ( +
+
+
+ ) + } + if (error) { + return ( +
{error}
+ ) + } + if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { + const msg = macroWeeklyData?.metadata?.message || 'Nicht genug Daten für Wochen-Analyse (min. 7 Tage)' + return ( +
{msg}
+ ) + } + + const chartData = macroWeeklyData.data.labels.map((label, i) => ({ + week: label, + protein: macroWeeklyData.data.datasets[0]?.data[i], + carbs: macroWeeklyData.data.datasets[1]?.data[i], + fat: macroWeeklyData.data.datasets[2]?.data[i], + })) + + const meta = macroWeeklyData.metadata + + return ( + <> +
+ Anteil der Kalorien aus jedem Makronährstoff pro Kalenderwoche (100 % gestapelt). Gut vergleichbar mit der + Donut-Übersicht links. +
+
+ + + + + + [`${v}%`, name]} + /> + + + + + + +
+
+ Ø Verteilung: P {meta.avg_protein_pct}% · KH {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Variabilität (CV): P{' '} + {meta.protein_cv}% · KH {meta.carbs_cv}% · F {meta.fat_cv}% +
+ + ) +} + /** - * Nutrition Charts Component (E1-E5) - Konzept-konform v2.0 - * - * E1: Energy Balance (mit 7d/14d Durchschnitten) - * E2: Protein Adequacy (mit 7d/28d Durchschnitten) - * E3: Weekly Macro Distribution (100% gestapelte Balken) - * E4: Nutrition Adherence Score (0-100, goal-aware) - * E5: Energy Availability Warning (Ampel-System) + * Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird. */ -export default function NutritionCharts({ days = 28 }) { +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) @@ -159,16 +226,21 @@ export default function NutritionCharts({ days = 28 }) { useEffect(() => { loadCharts() - }, [days]) + }, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard]) const loadCharts = async () => { - await Promise.all([ + const tasks = [ loadEnergyBalance(), loadProteinAdequacy(), - loadMacroWeekly(), loadAdherence(), - loadWarning() - ]) + ] + if (!hideEnergyAvailabilityCard) { + tasks.push(loadWarning()) + } + if (showWeeklyMacroDistribution) { + tasks.splice(2, 0, loadMacroWeekly()) + } + await Promise.all(tasks) } const loadEnergyBalance = async () => { @@ -236,12 +308,13 @@ export default function NutritionCharts({ days = 28 }) { } } - // E1: Energy Balance Timeline (mit 7d/14d Durchschnitten) + // E1: Energy Balance — klare Farben (kein hellgraues Gewirr) const renderEnergyBalance = () => { if (!energyData || energyData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Ernährungsdaten (min. 7 Tage) -
+ const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.' + return ( +
{msg}
+ ) } const chartData = energyData.data.labels.map((label, i) => ({ @@ -249,7 +322,7 @@ export default function NutritionCharts({ days = 28 }) { täglich: energyData.data.datasets[0]?.data[i], avg7d: energyData.data.datasets[1]?.data[i], avg14d: energyData.data.datasets[2]?.data[i], - tdee: energyData.data.datasets[3]?.data[i] + tdee: energyData.data.datasets[3]?.data[i], })) const balance = energyData.metadata?.energy_balance || 0 @@ -257,111 +330,90 @@ export default function NutritionCharts({ days = 28 }) { return ( <> - - - - - - - - - - - +
+ Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE — Linien sind farblich getrennt (Legende unten). +
+ + + + + + + + + + + -
- - Ø {energyData.metadata.avg_kcal} kcal/Tag · - - - Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag - - - · {energyData.metadata.data_points} Tage +
+ Ø {energyData.metadata.avg_kcal} kcal/Tag · + + Balance: {balance > 0 ? '+' : ''} + {balance} kcal/Tag + · {energyData.metadata.data_points} Tage
) } - // E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten) + // E2: Protein — Zielzone als Fläche, Linien klar von E1 abgrenzbar const renderProteinAdequacy = () => { if (!proteinData || proteinData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Protein-Daten (min. 7 Tage) -
+ const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.' + return ( +
{msg}
+ ) } + const tl = proteinData.metadata.target_low + const th = proteinData.metadata.target_high + const chartData = proteinData.data.labels.map((label, i) => ({ date: fmtDate(label), täglich: proteinData.data.datasets[0]?.data[i], avg7d: proteinData.data.datasets[1]?.data[i], avg28d: proteinData.data.datasets[2]?.data[i], - targetLow: proteinData.data.datasets[3]?.data[i], - targetHigh: proteinData.data.datasets[4]?.data[i] })) return ( <> - - - - - - - - - - - - - - -
- {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%) +
+ Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel — andere Farben als Energiebilanz oben.
- - ) - } - - // E3: Weekly Macro Distribution (100% gestapelte Balken) - const renderMacroWeekly = () => { - if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Daten für Wochen-Analyse (min. 7 Tage) -
- } - - const chartData = macroWeeklyData.data.labels.map((label, i) => ({ - week: label, - protein: macroWeeklyData.data.datasets[0]?.data[i], - carbs: macroWeeklyData.data.datasets[1]?.data[i], - fat: macroWeeklyData.data.datasets[2]?.data[i] - })) - - const meta = macroWeeklyData.metadata - - return ( - <> - - - - - - - - - - - + + + + + + + {tl != null && th != null && ( + + )} + + + + + -
- Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · - Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}% +
+ Ziel {tl}–{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ( + {proteinData.metadata.target_compliance_pct}%)
) @@ -414,20 +466,22 @@ export default function NutritionCharts({ days = 28 }) { return (
- + {renderEnergyBalance()} - + {renderProteinAdequacy()} - - {renderMacroWeekly()} - + {showWeeklyMacroDistribution && ( + + + + )} {!loading.adherence && !errors.adherence && renderAdherence()} - {!loading.warning && !errors.warning && renderWarning()} + {!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
) } diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx index 02b3898..b98e60c 100644 --- a/frontend/src/components/pilot/PilotKpiBoard.jsx +++ b/frontend/src/components/pilot/PilotKpiBoard.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import dayjs from 'dayjs' import { api } from '../../utils/api' @@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc' import { useProfile } from '../../context/ProfileContext' import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays' import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles' +import KpiTilesOverview from '../KpiTilesOverview' const MAX_KPI = 9 @@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { return buildAutoTileIds(refTiles, hasBf, hasKcal) }, [manualOrder, refTiles, bf, avgKcal]) - const pushTileForId = useCallback( - (id, out) => { - if (id === 'body_fat') { - if (!bf) return - out.push( -
-
Körperfett
-
- {bf.pct}% -
-
{bf.cat?.label || 'Caliper'}
-
, - ) - return - } - if (id === 'avg_kcal') { - if (avgKcal == null) return - out.push( -
-
- Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T) -
-
{avgKcal} kcal
-
Ernährung
-
, - ) - return - } - const tk = parseRefTypeKey(id) - if (!tk) return - const tile = refByKey.get(tk) - if (!tile?.latest) return - const l = tile.latest - out.push( -
-
{tile.type_label}
-
- {formatRefVal(l)} - {l.unit ? ( - {l.unit} - ) : null} -
-
Ref.wert
-
, - ) - }, - [bf, avgKcal, refByKey], - ) - - const visibleTiles = useMemo(() => { + const kpiTiles = useMemo(() => { const out = [] for (const id of orderIds) { - pushTileForId(id, out) + if (id === 'body_fat') { + if (!bf) continue + out.push({ + key: 'kpi-bf', + status: 'good', + category: 'Körperfett', + icon: '🫧', + value: `${bf.pct}%`, + sublabel: bf.cat?.label || 'Caliper', + valueColor: bf.cat?.color, + hoverTop: 'Körperfett (Caliper)', + hoverBody: + `Letzte Messung: ${bf.date ? dayjs(bf.date).format('DD.MM.YYYY') : '—'}.\n` + + 'Wert aus dem Caliper-Log; die Farbe/Kategorie richtet sich nach Geschlecht und üblicher Spanne.', + }) + continue + } + if (id === 'avg_kcal') { + if (avgKcal == null) continue + out.push({ + key: 'kpi-kcal', + status: 'good', + category: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T)`, + icon: '🍽️', + value: `${avgKcal} kcal`, + sublabel: 'Ernährung', + valueColor: '#EF9F27', + hoverTop: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)`, + hoverBody: + `Durchschnitt der täglichen Kalorien aus dem Ernährungs-Log über die letzten ${KPI_KCAL_WINDOW_DEFAULT} Tage (Mittel über alle geladenen Tageseinträge im Fenster).`, + }) + continue + } + const tk = parseRefTypeKey(id) + if (!tk) continue + const tile = refByKey.get(tk) + if (!tile?.latest) continue + const l = tile.latest + const valStr = formatRefVal(l) + const withUnit = l.unit ? `${valStr} ${l.unit}`.trim() : valStr + out.push({ + key: `ref-${tk}`, + status: 'good', + category: tile.type_label, + icon: '📌', + value: withUnit, + sublabel: 'Ref.wert', + hoverTop: tile.type_label, + hoverBody: + 'Persönlicher Referenzwert aus dem Profil. Verwaltung unter Einstellungen → Referenzwerte.', + }) } return out - }, [orderIds, pushTileForId]) + }, [orderIds, bf, avgKcal, refByKey]) if (loading) { return ( @@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { ) } - if (visibleTiles.length === 0) { + if (kpiTiles.length === 0) { return (
Kennzahlen
@@ -216,7 +218,13 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { ? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).' : `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}

-
{visibleTiles}
+
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 4d5cd2f..c6f4591 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -11,10 +11,12 @@ import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' +import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' -import NutritionCharts from '../components/NutritionCharts' +import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' +import KpiTilesOverview from '../components/KpiTilesOverview' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -232,39 +234,46 @@ function buildBodyKpiTiles({ return tiles } -/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */ -function BodyKpiOverview({ tiles }) { - if (!tiles?.length) return null +function NutritionGoalsStrip({ grouped }) { + const nav = useNavigate() + const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4) + if (!goals.length) return null return ( -
-
Kennzahlen
-
- {tiles.map(t => { - const accent = getStatusColor(t.status) - const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n') - return ( -
-
- {t.icon} -
-
{t.category}
-
{t.value}
- {t.sublabel && ( -
{t.sublabel}
- )} -
-
-
{t.verdict}
-
-
+
+
+
Ernährungsbezogene Ziele
+ +
+
+ {goals.map(g => ( +
+
{g.name || g.label_de || g.goal_type}
+
+
- ) - })} +
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''} +
+
+ ))}
) @@ -535,7 +544,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
)} - + {vizLoading && (
Aktualisiere…
@@ -690,191 +699,398 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
) } -// ── Nutrition Section ───────────────────────────────────────────────────────── -function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(30) - if (!nutrition?.length) return ( - - ) - const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') - const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff) - const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date)) +/** 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)] +} - if (!filtN.length) return ( -
- - - +const TDEE_REF_LINE_COLOR = '#475569' + +/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ +function KcalVsWeightLegend({ showTdee }) { + const line = (color) => ({ + display: 'inline-block', + width: 22, + height: 3, + background: color, + borderRadius: 1, + verticalAlign: 'middle', + marginRight: 6, + }) + return ( +
+ + + Ø Kalorien (7-Tage-Mittel) + + + + Gewicht (kg) + + {showTdee ? ( + + + TDEE-Referenz (geschätzt) + + ) : null}
) +} - const n = filtN.length - const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n) - const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10 - const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10 - const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10 - const latestW = weights?.[0]?.weight||80 - const ptLow = Math.round(latestW*1.6) - const ptHigh = Math.round(latestW*2.2) - const proteinOk = avgProtein>=ptLow +/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */ +function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) { + if (vizKcalWeight?.points?.length >= 5) { + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map(d => ({ + ...d, + date: fmtDate(d.date), + })) + const n = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. +
+ + + + + + + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + {tdeeLabel != null && ( + + )} + + + + + +
+ {tdeeLabel != null + ? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`} +
+
+ ) + } - // Stacked macro bar (daily) - const cdMacro = sorted.map(d=>({ - date: fmtDate(d.date), - Protein: Math.round(d.protein_g||0), - KH: Math.round(d.carbs_g||0), - Fett: Math.round(d.fat_g||0), - kcal: Math.round(d.kcal||0), - })) - - // Pie - const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9 - const pieData = [ - {name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'}, - {name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'}, - {name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'}, - ] - - // Weekly macro bars - const weeklyMap={} - filtN.forEach(d=>{ - const wk=dayjs(d.date).format('YYYY-WW') - const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })() - if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0} - weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0 - weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++ + const raw = (corrRows || []).filter(d => { + if (!d.kcal || d.weight == null) return false + const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD') + return allTime || ds >= cutoffDate }) - const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({ - label:w.label, - Protein:Math.round(w.protein/w.n), - KH:Math.round(w.carbs/w.n), - Fett:Math.round(w.fat/w.n), - kcal:Math.round(w.kcal/w.n), - })) + if (raw.length < 5) return null - // Rules - const macroRules=[] - if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein', - title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail:`1,6–2,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`, - value:avgProtein+'g'}) - else macroRules.push({status:'good',icon:'🥩',category:'Protein', - title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'}) - const protPct=Math.round(avgProtein*4/totalMacroKcal*100) - if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil', - title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`, - detail:`Empfehlung: 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`, - value:protPct+'%'}) + const sex = profile?.sex || 'm' + const height = profile?.height || 178 + const latestW = raw[raw.length - 1]?.weight || 80 + const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35 + 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 ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. +
+ + + + + + + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + + + + + + +
+ TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage +
+
+ ) +} + +// ── Nutrition Section ───────────────────────────────────────────────────────── +/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ +function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { + const [period, setPeriod] = useState(30) + const [groupedGoals, setGroupedGoals] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoad, setVizLoad] = useState(true) + const [vizErr, setVizErr] = useState(null) + + useEffect(() => { + let cancelled = false + api.listGoalsGrouped() + .then(g => { if (!cancelled) setGroupedGoals(g) }) + .catch(() => { if (!cancelled) setGroupedGoals({}) }) + return () => { cancelled = true } + }, []) + + useEffect(() => { + let cancelled = false + setViz(null) + setVizLoad(true) + setVizErr(null) + const daysReq = period === 9999 ? 9999 : period + api.getNutritionHistoryViz(daysReq) + .then(v => { if (!cancelled) setViz(v) }) + .catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') }) + .finally(() => { if (!cancelled) setVizLoad(false) }) + return () => { cancelled = true } + }, [period]) + + if (vizLoad) { + return ( +
+ + +
+
+ ) + } + + if (vizErr) { + return ( +
+ +
{vizErr}
+
+ ) + } + + if (!viz?.has_nutrition_entries) { + return ( + + ) + } + + const summary = viz.summary || {} + const n = Math.max(0, Number(summary.data_points) || 0) + const avgKcal = Math.round(Number(summary.kcal_avg) || 0) + const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0) + const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period) + const kpiTiles = (viz.kpi_tiles || []).map(t => ({ + ...t, + sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, + })) + const pieData = viz.donut_avg_pct || [] + const cdMacro = (viz.daily_macros || []).map(d => ({ + date: fmtDate(d.date), + Protein: d.Protein, + KH: d.KH, + Fett: d.Fett, + kcal: d.kcal, + })) + const weeklyMacro = viz.weekly_macro_chart + const wmLoading = false + const wmError = null + + if (!cdMacro.length || n === 0) { + return ( +
+ + + +
+ ) + } return (
- + -
- {[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'], - ['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'], - ['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>( -
-
{v}
-
{l}
-
- ))} -
+

+ Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} + Kalorien vs. Gewicht und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg). +

- {/* Stacked macro bars (daily) */} -
-
- Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)} + + + + + + +
+
+ Makroverteilung täglich (g) · Fokus Protein
- - - - - - - [`${v}g`,n]}/> - - - +
+ Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht). +
+ + + + + + {ptLow > 0 && ( + + )} + [`${v}g`, name]} /> + + + -
- Protein - KH - Fett - Protein-Ziel +
+ Protein (unten) + Fett (Mitte) + KH (oben)
- {/* Pie + macro breakdown */} -
-
- Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} – {sorted[sorted.length-1]?.date?.slice(0,10)}) -
-
- - - {pieData.map((e,i)=>)} - - [`${v}%`,n]}/> - -
- {pieData.map(p=>( -
-
-
{p.name}
-
{p.value}%
-
{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g
- {p.name==='Protein' &&
- {proteinOk?'✓':'⚠️'} Ziel {ptLow}g -
} -
- ))} -
- Gesamt: {avgKcal} kcal/Tag -
+
+
+
+ Ø Makro-Quote ({n} Tage)
+ {pieData.length > 0 ? ( +
+
+ + + + {pieData.map((e, i) => ( + + ))} + + [`${v}%`, name]} /> + + +
+
+ {pieData.map(p => { + const fill = macroFillByName(p.name) + return ( +
+
+
{p.name}
+
{p.value}%
+
+ {p.grams != null ? `${p.grams}g` : '—'} +
+
+ ) + })} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz +
+
+
+ ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )} +
+
+
+ Wöchentliche Makro-Verteilung (Backend) +
+
- {/* Weekly stacked bars */} - {weeklyData.length>=2 && ( -
-
Makros pro Woche (Ø g/Tag)
- - - - - - - [`${v}g`,n]}/> - - - - - -
- )} - -
-
BEWERTUNG
- {macroRules.map((item,i)=>)} -
- - {/* New Nutrition Charts (Phase 0c) */} -
-
📊 DETAILLIERTE CHARTS
- +
+ Zeitverläufe (Energie & Protein)
+
@@ -1001,10 +1217,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu 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) // light activity baseline - // Chart 1: Kcal vs Weight - const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal') - - // Chart 2: Protein vs Lean Mass (only days with both) + // Protein vs Lean Mass (only days with both) const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) .map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) @@ -1080,31 +1293,11 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
- {/* Chart 1: Kcal vs Weight */} -
-
- 📉 Kalorien (Ø 7T) vs. Gewicht -
- - - - - - - [`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/> - - - - - -
- Gestrichelt: geschätzter TDEE {tdee} kcal · — Kalorien · — Gewicht -
-
+

+ Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis). +

- {/* Chart 2: Calorie balance */} + {/* Chart: Calorie balance */}
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal) @@ -1411,7 +1604,7 @@ export default function History() {
{tab==='body' && } - {tab==='nutrition' && } + {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } {tab==='correlation' && } diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index fc6c1ac..09ed0ba 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -5,6 +5,7 @@ import { ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter } from 'recharts' import { api as nutritionApi } from '../utils/api' +import { MACRO_CHART } from '../utils/macroChartTheme' import dayjs from 'dayjs' import isoWeek from 'dayjs/plugin/isoWeek' dayjs.extend(isoWeek) @@ -709,9 +710,9 @@ function WeeklyMacros({ weekly }) { [`${Math.round(v)} g`, n]}/> - - - + + + ) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e6f9b38..c55e583 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -637,6 +637,8 @@ export const api = { // 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}`), + /** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */ + getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-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}`), diff --git a/frontend/src/utils/macroChartTheme.js b/frontend/src/utils/macroChartTheme.js new file mode 100644 index 0000000..03ab9ad --- /dev/null +++ b/frontend/src/utils/macroChartTheme.js @@ -0,0 +1,21 @@ +/** + * Einheitliche Makro-Farben für Verlauf (Balken, Donut, E3). + * Reihenfolge gestapelter Balken (Recharts, unten zuerst): Protein → Fett → Kohlenhydrate. + */ +export const MACRO_CHART = { + protein: '#4a8f72', + fat: '#6e8eb8', + carbs: '#c17d45', +} + +/** Einheitliche Höhe Donut-Bereich / E3-Balken (Verlauf) */ +export const NUTRITION_MACRO_CHART_BLOCK_PX = 260 + +/** Farbe nach Segment-Name (Protein / KH / Fett / englische Keys). */ +export function macroFillByName(name) { + const n = String(name || '').toLowerCase() + if (n.includes('protein') || n === 'p') return MACRO_CHART.protein + if (n.includes('fett') || n.includes('fat')) return MACRO_CHART.fat + if (n.includes('kh') || n.includes('kohlenhydrat') || n.includes('carb')) return MACRO_CHART.carbs + return MACRO_CHART.carbs +}