From b96b1931dbe85cfe705ba938f54087f0223a6ccc Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:20:24 +0200 Subject: [PATCH] feat: implement nutrition history visualization bundle and related API endpoint - Added a new `nutrition_interpretation.py` file to handle KPI tile generation for nutrition history. - Introduced `nutrition_viz.py` to create a visualization bundle for nutrition data, integrating metrics and historical analysis. - Implemented `get_nutrition_history_viz` endpoint in `charts.py` to serve the new visualization data. - Updated frontend components to fetch and display nutrition history data, enhancing user experience with detailed insights. - Refactored existing logic to streamline data handling and improve overall performance. --- .../data_layer/nutrition_interpretation.py | 180 +++++++++++ backend/data_layer/nutrition_metrics.py | 152 ++++++++- backend/data_layer/nutrition_viz.py | 283 +++++++++++++++++ backend/routers/charts.py | 152 ++------- frontend/src/pages/History.jsx | 297 +++++++++--------- frontend/src/utils/api.js | 2 + 6 files changed, 774 insertions(+), 292 deletions(-) create mode 100644 backend/data_layer/nutrition_interpretation.py create mode 100644 backend/data_layer/nutrition_viz.py diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py new file mode 100644 index 0000000..304f189 --- /dev/null +++ b/backend/data_layer/nutrition_interpretation.py @@ -0,0 +1,180 @@ +""" +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"), + "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"), + "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"), + "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_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": "#059669", "grams": round(p, 1)}, + {"name": "KH", "value": round(ckcal / tot * 100), "color": "#EA580C", "grams": round(c, 1)}, + {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#2563EB", "grams": round(f, 1)}, + ] diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 7ce9fa7..9389d48 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,136 @@ 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": "#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), + }, + } + + # ============================================================================ # 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..f05b0a5 --- /dev/null +++ b/backend/data_layer/nutrition_viz.py @@ -0,0 +1,283 @@ +""" +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_macro_donut_from_averages, + build_nutrition_history_kpi_tiles, +) +from data_layer.nutrition_metrics import ( + estimate_tdee_kcal_from_latest_weight, + 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": [], + "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) + + 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) + ) + + 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) + + 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, + "interpretation_tiles": [], + "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..eee336a 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -32,12 +32,14 @@ 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, ) from data_layer.activity_metrics import ( get_activity_summary_data, @@ -265,6 +267,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 +852,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") diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 9e18919..b13ca4c 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -699,62 +699,50 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl ) } -function buildNutritionKpiTiles({ - avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, -}) { - const tiles = [ - { - key: 'kcal', - category: 'Kalorien (Ø)', - icon: '🔥', - value: `${avgKcal} kcal`, - sublabel: dateSpanLabel, - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliche tägliche Energie', - hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`, - }, - { - key: 'carbs', - category: 'KH (Ø)', - icon: '🌾', - value: `${avgCarbs} g`, - sublabel: 'Kohlenhydrate / Tag', - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliche Kohlenhydrate', - hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', - }, - { - key: 'fat', - category: 'Fett (Ø)', - icon: '🧈', - value: `${avgFat} g`, - sublabel: 'Fett / Tag', - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliches Fett', - hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', - }, - ] - macroRules.forEach((r, i) => { - tiles.push({ - key: `eval-${i}`, - category: r.category, - icon: r.icon, - value: r.value, - sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}…` : r.title, - status: r.status, - verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'), - hoverTop: r.title, - hoverBody: r.detail, - }) - }) - return tiles -} +/** 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 + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht. +
+ + + + + + + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + {tdeeLabel != null && ( + + )} + + + + +
+ {tdeeLabel != null + ? `Referenz TDEE ~${tdeeLabel} kcal (Data Layer, gestrichelt) · ${n} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`} +
+
+ ) + } -/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */ -function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) { 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') @@ -794,21 +782,20 @@ function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime })
- Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage + Referenz TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
) } // ── Nutrition Section ───────────────────────────────────────────────────────── -function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) { +/** 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 chartDays = period === 9999 ? 90 : period - const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7))) - const [weeklyMacro, setWeeklyMacro] = useState(null) - const [wmLoading, setWmLoading] = useState(false) - const [wmError, setWmError] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoad, setVizLoad] = useState(true) + const [vizErr, setVizErr] = useState(null) useEffect(() => { let cancelled = false @@ -820,120 +807,110 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo useEffect(() => { let cancelled = false - setWmLoading(true) - setWmError(null) - api.getWeeklyMacroDistributionChart(weeks) - .then(d => { if (!cancelled) setWeeklyMacro(d) }) - .catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') }) - .finally(() => { if (!cancelled) setWmLoading(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 } - }, [weeks]) + }, [period]) - if (!nutrition?.length) { + if (vizLoad) { + return ( +
+ + +
+
+ ) + } + + if (vizErr) { + return ( +
+ +
{vizErr}
+
+ ) + } + + if (!viz?.has_nutrition_entries) { 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)) + 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 (!filtN.length) { + if (!cdMacro.length || n === 0) { return (
- +
) } - 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 - - 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), - })) - - const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9 - const pieData = [ - { name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' }, - { name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' }, - { name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' }, - ] - - 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: ~${Math.max(0, 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 oft 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`, - value: `${protPct}%`, - }) - } - - const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} – ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}` - const kpiTiles = buildNutritionKpiTiles({ - avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, - }) - return (
- +

- Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '} - Kalorien vs. Gewicht bezieht gemeinsame Tage aus Ernährung und Gewicht. + 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).

- +
Makroverteilung täglich (g) · Fokus Protein
- Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht). + Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
- + {ptLow > 0 && ( + + )} [`${v}g`, name]} /> @@ -953,27 +930,33 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo Ø Makro-Quote ({n} Tage)
- - - {pieData.map((e, i) => )} - - [`${v}%`, name]} /> - -
- {pieData.map(p => ( -
-
-
{p.name}
-
{p.value}%
-
- {Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g + {pieData.length > 0 ? ( + <> + + + {pieData.map((e, i) => )} + + [`${v}%`, name]} /> + +
+ {pieData.map(p => ( +
+
+
{p.name}
+
{p.value}%
+
+ {p.grams != null ? `${p.grams}g` : '—'} +
+
+ ))} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
- ))} -
- Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz -
-
+ + ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )}
@@ -1501,7 +1484,7 @@ export default function History() {
{tab==='body' && } - {tab==='nutrition' && } + {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } {tab==='correlation' && } 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}`),