diff --git a/backend/routers/charts.py b/backend/routers/charts.py index b810418..591be37 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -327,16 +327,22 @@ def get_energy_balance_chart( session: dict = Depends(require_auth) ) -> Dict: """ - Energy balance timeline (E1). + Energy balance timeline (E1) - Konzept-konform. - Shows daily calorie intake over time with optional TDEE reference line. + Shows: + - Daily calorie intake + - 7d rolling average + - 14d rolling average + - TDEE reference line + - Energy deficit/surplus + - Lagged comparison to weight trend Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: - Chart.js line chart with daily kcal intake + Chart.js line chart with multiple datasets """ profile_id = session['profile_id'] @@ -354,7 +360,7 @@ def get_energy_balance_chart( ) rows = cur.fetchall() - if not rows: + if not rows or len(rows) < 3: return { "chart_type": "line", "data": { @@ -363,42 +369,80 @@ def get_energy_balance_chart( }, "metadata": { "confidence": "insufficient", - "data_points": 0, - "message": "Keine Ernährungsdaten vorhanden" + "data_points": len(rows) if rows else 0, + "message": "Nicht genug Ernährungsdaten (min. 3 Tage)" } } - labels = [row['date'].isoformat() for row in rows] - values = [safe_float(row['kcal']) for row in rows] + # Prepare data + labels = [] + daily_values = [] + avg_7d = [] + avg_14d = [] - # Calculate average for metadata - avg_kcal = sum(values) / len(values) if values else 0 + for i, row in enumerate(rows): + labels.append(row['date'].isoformat()) + daily_values.append(safe_float(row['kcal'])) + + # 7d rolling average + 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) + + # 14d rolling average + 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) + + # Calculate TDEE (estimated, should come from profile) + # TODO: Calculate from profile (weight, height, age, activity level) + estimated_tdee = 2500.0 + + # Calculate deficit/surplus + avg_intake = sum(daily_values) / len(daily_values) if daily_values else 0 + energy_balance = avg_intake - estimated_tdee datasets = [ { - "label": "Kalorien", - "data": values, - "borderColor": "#1D9E75", + "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": True + "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 } ] - # Add TDEE reference line (estimated) - # TODO: Get actual TDEE from profile calculation - estimated_tdee = 2500.0 - datasets.append({ - "label": "TDEE (geschätzt)", - "data": [estimated_tdee] * len(labels), - "borderColor": "#888", - "borderWidth": 1, - "borderDash": [5, 5], - "fill": False, - "pointRadius": 0 - }) - from data_layer.utils import calculate_confidence confidence = calculate_confidence(len(rows), days, "general") @@ -411,8 +455,10 @@ def get_energy_balance_chart( "metadata": serialize_dates({ "confidence": confidence, "data_points": len(rows), - "avg_kcal": round(avg_kcal, 1), + "avg_kcal": round(avg_intake, 1), "estimated_tdee": estimated_tdee, + "energy_balance": round(energy_balance, 1), + "balance_status": "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance", "first_date": rows[0]['date'], "last_date": rows[-1]['date'] }) @@ -515,16 +561,20 @@ def get_protein_adequacy_chart( session: dict = Depends(require_auth) ) -> Dict: """ - Protein adequacy timeline (E3). + Protein adequacy timeline (E2) - Konzept-konform. - Shows daily protein intake vs. target range. + Shows: + - Daily protein intake + - 7d rolling average + - 28d rolling average + - Target range bands Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: - Chart.js line chart with protein intake + target bands + Chart.js line chart with protein intake + averages + target bands """ profile_id = session['profile_id'] @@ -545,7 +595,7 @@ def get_protein_adequacy_chart( ) rows = cur.fetchall() - if not rows: + if not rows or len(rows) < 3: return { "chart_type": "line", "data": { @@ -554,35 +604,70 @@ def get_protein_adequacy_chart( }, "metadata": { "confidence": "insufficient", - "data_points": 0, - "message": "Keine Protein-Daten vorhanden" + "data_points": len(rows) if rows else 0, + "message": "Nicht genug Protein-Daten (min. 3 Tage)" } } - labels = [row['date'].isoformat() for row in rows] - values = [safe_float(row['protein_g']) for row in rows] + # Prepare data + labels = [] + daily_values = [] + avg_7d = [] + avg_28d = [] - datasets = [ - { - "label": "Protein (g)", - "data": values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.2)", - "borderWidth": 2, - "tension": 0.3, - "fill": False - } - ] + for i, row in enumerate(rows): + labels.append(row['date'].isoformat()) + daily_values.append(safe_float(row['protein_g'])) + + # 7d rolling average + 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) + + # 28d rolling average + 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) # Add target range bands target_low = targets['protein_target_low'] target_high = targets['protein_target_high'] - datasets.append({ - "label": "Ziel Min", - "data": [target_low] * len(labels), - "borderColor": "#888", - "borderWidth": 1, + 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 @@ -704,7 +789,392 @@ def get_nutrition_consistency_chart( } -# ── Activity Charts ───────────────────────────────────────────────────────── +# ── NEW: Konzept-konforme Nutrition Endpoints (E3, E4, E5) ────────────────── + + +@router.get("/weekly-macro-distribution") +def get_weekly_macro_distribution_chart( + weeks: int = Query(default=12, ge=4, le=52), + session: dict = Depends(require_auth) +) -> Dict: + """ + 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 + """ + 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) + } + } + + +@router.get("/nutrition-adherence-score") +def get_nutrition_adherence_score( + days: int = Query(default=28, ge=7, le=90), + session: dict = Depends(require_auth) +) -> Dict: + """ + Nutrition Adherence Score (E4) - Konzept-konform. + + Score 0-100 based on goal-specific criteria: + - Calorie target adherence + - Protein target adherence + - Intake consistency + - Food quality indicators (fiber, sugar) + + Args: + days: Analysis window (7-90 days, default 28) + session: Auth session (injected) + + Returns: + { + "score": 0-100, + "components": {...}, + "recommendation": "..." + } + """ + profile_id = session['profile_id'] + + from db import get_db, get_cursor + from data_layer.nutrition_metrics import ( + get_protein_adequacy_data, + calculate_macro_consistency_score + ) + + # Get user's goal mode (weight_loss, strength, endurance, etc.) + 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') + + # Get nutrition data + cur.execute( + """SELECT COUNT(*) as cnt, + AVG(kcal) as avg_kcal, + STDDEV(kcal) as std_kcal, + AVG(protein_g) as avg_protein, + AVG(carbs_g) as avg_carbs, + AVG(fat_g) as avg_fat + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + AND kcal IS NOT NULL""", + (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)" + } + } + + # Get protein adequacy + protein_data = get_protein_adequacy_data(profile_id, days) + + # Calculate components based on goal mode + components = {} + + # 1. Calorie adherence (placeholder, needs goal-specific logic) + calorie_adherence = 70.0 # TODO: Calculate based on TDEE target + + # 2. Protein adherence + protein_adequacy_pct = protein_data.get('adequacy_score', 0) + protein_adherence = min(100, protein_adequacy_pct) + + # 3. Intake consistency (low volatility = good) + 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) # Invert: low CV = high score + + # 4. Food quality (placeholder for fiber/sugar analysis) + food_quality = 60.0 # TODO: Calculate from fiber/sugar data + + # Goal-specific weighting (from concept E4) + 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: # health, recomposition + weights = { + 'calorie': 0.25, + 'protein': 0.25, + 'consistency': 0.25, + 'quality': 0.25 + } + + # Calculate weighted score + 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) + } + + # Generate recommendation + 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 + } + } + + +@router.get("/energy-availability-warning") +def get_energy_availability_warning( + days: int = Query(default=14, ge=7, le=28), + session: dict = Depends(require_auth) +) -> Dict: + """ + 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": "..." + } + """ + 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" + } + } @router.get("/training-volume") diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index e60c65b..9a03343 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react' import { - LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - ReferenceLine + LineChart, Line, BarChart, Bar, + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts' import { api } from '../utils/api' import dayjs from 'dayjs' @@ -30,35 +29,145 @@ function ChartCard({ title, loading, error, children }) { ) } +function ScoreCard({ title, score, components, goal_mode, recommendation }) { + const scoreColor = score >= 80 ? '#1D9E75' : score >= 60 ? '#F59E0B' : '#EF4444' + + return ( +
+
+ {title} +
+ + {/* Score Circle */} +
+
+
{score}
+
/ 100
+
+
+ + {/* Components Breakdown */} +
+ {Object.entries(components).map(([key, value]) => { + const barColor = value >= 80 ? '#1D9E75' : value >= 60 ? '#F59E0B' : '#EF4444' + const label = { + 'calorie_adherence': 'Kalorien-Adhärenz', + 'protein_adherence': 'Protein-Adhärenz', + 'intake_consistency': 'Konsistenz', + 'food_quality': 'Lebensmittelqualität' + }[key] || key + + return ( +
+
+ {label} + {value} +
+
+
+
+
+ ) + })} +
+ + {/* Recommendation */} +
+ 💡 {recommendation} +
+ + {/* Goal Mode */} +
+ Optimiert für: {goal_mode || 'health'} +
+
+ ) +} + +function WarningCard({ title, warning_level, triggers, message }) { + const levelConfig = { + 'warning': { icon: '⚠️', color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }, + 'caution': { icon: '⚡', color: '#F59E0B', bg: 'rgba(245, 158, 11, 0.1)' }, + 'none': { icon: '✅', color: '#1D9E75', bg: 'rgba(29, 158, 117, 0.1)' } + }[warning_level] || levelConfig['none'] + + return ( +
+
+ {title} +
+ + {/* Status Badge */} +
+
+ {levelConfig.icon} {message} +
+
+ + {/* Triggers List */} + {triggers && triggers.length > 0 && ( +
+
+ Auffällige Indikatoren: +
+
    + {triggers.map((t, i) => ( +
  • {t}
  • + ))} +
+
+ )} + +
+ Heuristische Einschätzung, keine medizinische Diagnose +
+
+ ) +} + /** - * Nutrition Charts Component (E1-E5) + * Nutrition Charts Component (E1-E5) - Konzept-konform v2.0 * - * Displays 4 nutrition chart endpoints: - * - Energy Balance Timeline (E1) - * - Macro Distribution (E2) - * - Protein Adequacy (E3) - * - Nutrition Consistency (E5) + * E1: Energy Balance (mit 7d/14d Durchschnitten) + * E2: Protein Adequacy (mit 7d/28d Durchschnitten) + * E3: Weekly Macro Distribution (100% gestapelte Balken) + * E4: Nutrition Adherence Score (0-100, goal-aware) + * E5: Energy Availability Warning (Ampel-System) */ export default function NutritionCharts({ days = 28 }) { const [energyData, setEnergyData] = useState(null) - const [macroData, setMacroData] = useState(null) const [proteinData, setProteinData] = useState(null) - const [consistencyData, setConsistencyData] = useState(null) + const [macroWeeklyData, setMacroWeeklyData] = useState(null) + const [adherenceData, setAdherenceData] = useState(null) + const [warningData, setWarningData] = useState(null) const [loading, setLoading] = useState({}) const [errors, setErrors] = useState({}) + // Weeks for macro distribution (proportional to days selected) + const weeks = Math.max(4, Math.min(52, Math.ceil(days / 7))) + useEffect(() => { loadCharts() }, [days]) const loadCharts = async () => { - // Load all 4 charts in parallel await Promise.all([ loadEnergyBalance(), - loadMacroDistribution(), loadProteinAdequacy(), - loadConsistency() + loadMacroWeekly(), + loadAdherence(), + loadWarning() ]) } @@ -75,19 +184,6 @@ export default function NutritionCharts({ days = 28 }) { } } - const loadMacroDistribution = async () => { - setLoading(l => ({...l, macro: true})) - setErrors(e => ({...e, macro: null})) - try { - const data = await api.getMacroDistributionChart(days) - setMacroData(data) - } catch (err) { - setErrors(e => ({...e, macro: err.message})) - } finally { - setLoading(l => ({...l, macro: false})) - } - } - const loadProteinAdequacy = async () => { setLoading(l => ({...l, protein: true})) setErrors(e => ({...e, protein: null})) @@ -101,121 +197,127 @@ export default function NutritionCharts({ days = 28 }) { } } - const loadConsistency = async () => { - setLoading(l => ({...l, consistency: true})) - setErrors(e => ({...e, consistency: null})) + const loadMacroWeekly = async () => { + setLoading(l => ({...l, macro: true})) + setErrors(e => ({...e, macro: null})) try { - const data = await api.getNutritionConsistencyChart(days) - setConsistencyData(data) + const data = await api.getWeeklyMacroDistributionChart(weeks) + setMacroWeeklyData(data) } catch (err) { - setErrors(e => ({...e, consistency: err.message})) + setErrors(e => ({...e, macro: err.message})) } finally { - setLoading(l => ({...l, consistency: false})) + setLoading(l => ({...l, macro: false})) } } - // E1: Energy Balance Timeline + const loadAdherence = async () => { + setLoading(l => ({...l, adherence: true})) + setErrors(e => ({...e, adherence: null})) + try { + const data = await api.getNutritionAdherenceScore(days) + setAdherenceData(data) + } catch (err) { + setErrors(e => ({...e, adherence: err.message})) + } finally { + setLoading(l => ({...l, adherence: false})) + } + } + + const loadWarning = async () => { + setLoading(l => ({...l, warning: true})) + setErrors(e => ({...e, warning: null})) + try { + const data = await api.getEnergyAvailabilityWarning(Math.min(days, 28)) + setWarningData(data) + } catch (err) { + setErrors(e => ({...e, warning: err.message})) + } finally { + setLoading(l => ({...l, warning: false})) + } + } + + // E1: Energy Balance Timeline (mit 7d/14d Durchschnitten) const renderEnergyBalance = () => { if (!energyData || energyData.metadata?.confidence === 'insufficient') { return
- Nicht genug Ernährungsdaten + Nicht genug Ernährungsdaten (min. 7 Tage)
} const chartData = energyData.data.labels.map((label, i) => ({ date: fmtDate(label), - kcal: energyData.data.datasets[0]?.data[i], - tdee: energyData.data.datasets[1]?.data[i] + täglich: energyData.data.datasets[0]?.data[i], + avg7d: energyData.data.datasets[1]?.data[i], + avg14d: energyData.data.datasets[2]?.data[i], + tdee: energyData.data.datasets[3]?.data[i] })) + const balance = energyData.metadata?.energy_balance || 0 + const balanceColor = balance < -200 ? '#EF4444' : balance > 200 ? '#F59E0B' : '#1D9E75' + return ( <> - + - - + + + + + -
- Ø {energyData.metadata.avg_kcal} kcal/Tag · {energyData.metadata.data_points} Einträge +
+ + Ø {energyData.metadata.avg_kcal} kcal/Tag · + + + Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag + + + · {energyData.metadata.data_points} Tage +
) } - // E2: Macro Distribution (Pie) - const renderMacroDistribution = () => { - if (!macroData || macroData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Makronährstoff-Daten -
- } - - const chartData = macroData.data.labels.map((label, i) => ({ - name: label, - value: macroData.data.datasets[0]?.data[i], - color: macroData.data.datasets[0]?.backgroundColor[i] - })) - - return ( - <> - - - `${name}: ${value}%`} - outerRadius={70} - dataKey="value" - > - {chartData.map((entry, index) => ( - - ))} - - - - -
- P: {macroData.metadata.protein_g}g · C: {macroData.metadata.carbs_g}g · F: {macroData.metadata.fat_g}g -
- - ) - } - - // E3: Protein Adequacy Timeline + // E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten) const renderProteinAdequacy = () => { if (!proteinData || proteinData.metadata?.confidence === 'insufficient') { return
- Nicht genug Protein-Daten + Nicht genug Protein-Daten (min. 7 Tage)
} const chartData = proteinData.data.labels.map((label, i) => ({ date: fmtDate(label), - protein: proteinData.data.datasets[0]?.data[i], - targetLow: proteinData.data.datasets[1]?.data[i], - targetHigh: proteinData.data.datasets[2]?.data[i] + täglich: proteinData.data.datasets[0]?.data[i], + avg7d: proteinData.data.datasets[1]?.data[i], + avg28d: proteinData.data.datasets[2]?.data[i], + targetLow: proteinData.data.datasets[3]?.data[i], + targetHigh: proteinData.data.datasets[4]?.data[i] })) return ( <> - + - - - + + + + + +
@@ -225,60 +327,107 @@ export default function NutritionCharts({ days = 28 }) { ) } - // E5: Nutrition Consistency (Bar) - const renderConsistency = () => { - if (!consistencyData || consistencyData.metadata?.confidence === 'insufficient') { + // E3: Weekly Macro Distribution (100% gestapelte Balken) + const renderMacroWeekly = () => { + if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { return
- Nicht genug Daten für Konsistenz-Analyse + Nicht genug Daten für Wochen-Analyse (min. 7 Tage)
} - const chartData = consistencyData.data.labels.map((label, i) => ({ - name: label, - score: consistencyData.data.datasets[0]?.data[i], - color: consistencyData.data.datasets[0]?.backgroundColor[i] + const chartData = macroWeeklyData.data.labels.map((label, i) => ({ + week: label, + protein: macroWeeklyData.data.datasets[0]?.data[i], + carbs: macroWeeklyData.data.datasets[1]?.data[i], + fat: macroWeeklyData.data.datasets[2]?.data[i] })) + const meta = macroWeeklyData.metadata + return ( <> - + - + - - {chartData.map((entry, index) => ( - - ))} - + + + +
- Gesamt-Score: {consistencyData.metadata.consistency_score}/100 + Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · + Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}%
) } + // E4: Nutrition Adherence Score + const renderAdherence = () => { + if (!adherenceData || adherenceData.metadata?.confidence === 'insufficient') { + return ( + +
+ Nicht genug Daten (min. 7 Tage) +
+
+ ) + } + + return ( + + ) + } + + // E5: Energy Availability Warning + const renderWarning = () => { + if (!warningData) { + return ( + +
+ Keine Daten verfügbar +
+
+ ) + } + + return ( + + ) + } + return (
- + {renderEnergyBalance()} - - {renderMacroDistribution()} - - - + {renderProteinAdequacy()} - - {renderConsistency()} + + {renderMacroWeekly()} + + {!loading.adherence && !errors.adherence && renderAdherence()} + {!loading.warning && !errors.warning && renderWarning()}
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 024c929..9df5ef3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -377,10 +377,12 @@ export const api = { // Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery) // Nutrition Charts (E1-E5) - getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), - getMacroDistributionChart: (days=28) => req(`/charts/macro-distribution?days=${days}`), - getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), - getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?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}`), + getWeeklyMacroDistributionChart: (weeks=12) => req(`/charts/weekly-macro-distribution?weeks=${weeks}`), + getNutritionAdherenceScore: (days=28) => req(`/charts/nutrition-adherence-score?days=${days}`), + getEnergyAvailabilityWarning: (days=14) => req(`/charts/energy-availability-warning?days=${days}`), // Recovery Charts (R1-R5) getRecoveryScoreChart: (days=28) => req(`/charts/recovery-score?days=${days}`),