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 ( +
- 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).