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