""" Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4). Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz`` und die API dieselbe Berechnung nutzen (Phase C, Issue 53). """ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict from db import get_db, get_cursor from data_layer.nutrition_metrics import ( get_energy_balance_data, get_protein_adequacy_data, get_protein_targets_data, ) from data_layer.utils import calculate_confidence, safe_float, serialize_dates def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: """E1 Energiebilanz — identisch zu GET /api/charts/energy-balance.""" balance_meta = get_energy_balance_data(profile_id, days) with get_db() as conn: cur = get_cursor(conn) cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") 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 ORDER BY date""", (profile_id, cutoff), ) rows = cur.fetchall() if not rows or len(rows) < 3: return { "chart_type": "line", "data": {"labels": [], "datasets": []}, "metadata": { "confidence": "insufficient", "data_points": len(rows) if rows else 0, "message": "Nicht genug Ernährungsdaten (min. 3 Tage)", }, } estimated_tdee = balance_meta.get("estimated_tdee") or 0 if estimated_tdee <= 0: return { "chart_type": "line", "data": {"labels": [], "datasets": []}, "metadata": { "confidence": "insufficient", "data_points": len(rows), "message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)", }, } labels = [] daily_values = [] avg_7d = [] avg_14d = [] for i, row in enumerate(rows): labels.append(row["date"].isoformat()) daily_values.append(safe_float(row["kcal"])) start_7d = max(0, i - 6) window_7d = [safe_float(rows[j]["kcal"]) for j in range(start_7d, i + 1)] avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None) start_14d = max(0, i - 13) window_14d = [safe_float(rows[j]["kcal"]) for j in range(start_14d, i + 1)] avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None) avg_intake = float( balance_meta.get("avg_intake") or (sum(daily_values) / len(daily_values) if daily_values else 0) ) energy_balance = float( balance_meta.get("energy_balance") or (avg_intake - estimated_tdee) ) balance_status = balance_meta.get("status") or ( "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance" ) datasets = [ { "label": "Kalorien (täglich)", "data": daily_values, "borderColor": "#1D9E7599", "backgroundColor": "rgba(29, 158, 117, 0.1)", "borderWidth": 1.5, "tension": 0.2, "fill": False, "pointRadius": 2, }, { "label": "Ø 7 Tage", "data": avg_7d, "borderColor": "#1D9E75", "borderWidth": 2.5, "tension": 0.3, "fill": False, "pointRadius": 0, }, { "label": "Ø 14 Tage", "data": avg_14d, "borderColor": "#085041", "borderWidth": 2, "tension": 0.3, "fill": False, "pointRadius": 0, "borderDash": [6, 3], }, { "label": "TDEE (geschätzt)", "data": [estimated_tdee] * len(labels), "borderColor": "#888", "borderWidth": 1, "borderDash": [5, 5], "fill": False, "pointRadius": 0, }, ] confidence = balance_meta.get("confidence") or "low" return { "chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": serialize_dates( { "confidence": confidence, "data_points": len(rows), "avg_kcal": round(avg_intake, 1), "estimated_tdee": estimated_tdee, "energy_balance": round(energy_balance, 1), "balance_status": balance_status, "first_date": rows[0]["date"], "last_date": rows[-1]["date"], } ), } def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: """E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy.""" targets = get_protein_targets_data(profile_id) with get_db() as conn: cur = get_cursor(conn) cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") cur.execute( """SELECT date, SUM(protein_g)::float AS protein_g FROM nutrition_log WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL GROUP BY date ORDER BY date""", (profile_id, cutoff), ) rows = cur.fetchall() if not rows or len(rows) < 3: return { "chart_type": "line", "data": {"labels": [], "datasets": []}, "metadata": { "confidence": "insufficient", "data_points": len(rows) if rows else 0, "message": "Nicht genug Protein-Daten (min. 3 Tage)", }, } labels = [] daily_values = [] avg_7d = [] avg_28d = [] for i, row in enumerate(rows): labels.append(row["date"].isoformat()) daily_values.append(safe_float(row["protein_g"])) start_7d = max(0, i - 6) window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)] avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None) start_28d = max(0, i - 27) window_28d = [safe_float(rows[j]["protein_g"]) for j in range(start_28d, i + 1)] avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None) target_low = targets["protein_target_low"] target_high = targets["protein_target_high"] datasets = [ { "label": "Protein (täglich)", "data": daily_values, "borderColor": "#1D9E7599", "backgroundColor": "rgba(29, 158, 117, 0.1)", "borderWidth": 1.5, "tension": 0.2, "fill": False, "pointRadius": 2, }, { "label": "Ø 7 Tage", "data": avg_7d, "borderColor": "#1D9E75", "borderWidth": 2.5, "tension": 0.3, "fill": False, "pointRadius": 0, }, { "label": "Ø 28 Tage", "data": avg_28d, "borderColor": "#085041", "borderWidth": 2, "tension": 0.3, "fill": False, "pointRadius": 0, "borderDash": [6, 3], }, { "label": "Ziel Min", "data": [target_low] * len(labels), "borderColor": "#888", "borderWidth": 1, "borderDash": [5, 5], "fill": False, "pointRadius": 0, }, ] datasets.append( { "label": "Ziel Max", "data": [target_high] * len(labels), "borderColor": "#888", "borderWidth": 1, "borderDash": [5, 5], "fill": False, "pointRadius": 0, } ) confidence = calculate_confidence(len(rows), days, "general") days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high) return { "chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": serialize_dates( { "confidence": confidence, "data_points": len(rows), "target_low": round(target_low, 1), "target_high": round(target_high, 1), "days_in_target": days_in_target, "target_compliance_pct": round( days_in_target / len(daily_values) * 100, 1 ) if daily_values else 0, "first_date": rows[0]["date"], "last_date": rows[-1]["date"], } ), } def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]: """E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,)) profile_row = cur.fetchone() goal_mode = ( profile_row["goal_mode"] if profile_row and profile_row["goal_mode"] else "health" ) cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") cur.execute( """WITH daily AS ( SELECT date, COALESCE(SUM(kcal), 0)::float AS dk, COALESCE(SUM(protein_g), 0)::float AS dp, COALESCE(SUM(carbs_g), 0)::float AS dc, COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL GROUP BY date ) SELECT COUNT(*)::int AS cnt, AVG(dk) AS avg_kcal, STDDEV(dk) AS std_kcal, AVG(dp) AS avg_protein, AVG(dc) AS avg_carbs, AVG(df) AS avg_fat FROM daily""", (profile_id, cutoff), ) stats = cur.fetchone() if not stats or stats["cnt"] < 7: return { "score": 0, "components": {}, "metadata": { "confidence": "insufficient", "message": "Nicht genug Daten (min. 7 Tage)", }, } protein_data = get_protein_adequacy_data(profile_id, days) calorie_adherence = 70.0 protein_adequacy_pct = protein_data.get("adequacy_score", 0) protein_adherence = min(100, protein_adequacy_pct) kcal_cv = ( (safe_float(stats["std_kcal"]) / safe_float(stats["avg_kcal"]) * 100) if safe_float(stats["avg_kcal"]) > 0 else 100 ) intake_consistency = max(0, 100 - kcal_cv) food_quality = 60.0 if goal_mode == "weight_loss": weights = { "calorie": 0.35, "protein": 0.25, "consistency": 0.20, "quality": 0.20, } elif goal_mode == "strength": weights = { "calorie": 0.25, "protein": 0.35, "consistency": 0.20, "quality": 0.20, } elif goal_mode == "endurance": weights = { "calorie": 0.30, "protein": 0.20, "consistency": 0.20, "quality": 0.30, } else: weights = { "calorie": 0.25, "protein": 0.25, "consistency": 0.25, "quality": 0.25, } final_score = ( calorie_adherence * weights["calorie"] + protein_adherence * weights["protein"] + intake_consistency * weights["consistency"] + food_quality * weights["quality"] ) components = { "calorie_adherence": round(calorie_adherence, 1), "protein_adherence": round(protein_adherence, 1), "intake_consistency": round(intake_consistency, 1), "food_quality": round(food_quality, 1), } weak_areas = [k for k, v in components.items() if v < 60] if weak_areas: recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}" else: recommendation = "Gute Adhärenz, weiter so!" return { "score": round(final_score, 1), "components": components, "goal_mode": goal_mode, "weights": weights, "recommendation": recommendation, "metadata": { "confidence": calculate_confidence(stats["cnt"], days, "general"), "data_points": stats["cnt"], "days_analyzed": days, }, }