From 3f6673b6368daa74da0f1703e9d03616e99f51ae Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 14:51:27 +0200 Subject: [PATCH] feat: update app version to 0.9t and enhance nutrition visualization - Bumped application version to 0.9t and updated changelog with new features. - Integrated new chart payloads for energy balance, protein adequacy, and nutrition adherence to optimize data retrieval and reduce HTTP requests. - Updated NutritionCharts component to utilize prefetched chart payloads, improving loading efficiency and user experience. - Refactored History page to pass chart payloads, enhancing the visualization of nutrition trends without additional requests. --- .../data_layer/nutrition_chart_payloads.py | 404 ++++++++++++++++++ backend/data_layer/nutrition_viz.py | 23 + backend/routers/charts.py | 402 +---------------- backend/version.py | 10 +- frontend/src/components/NutritionCharts.jsx | 44 +- frontend/src/pages/History.jsx | 7 +- 6 files changed, 485 insertions(+), 405 deletions(-) create mode 100644 backend/data_layer/nutrition_chart_payloads.py diff --git a/backend/data_layer/nutrition_chart_payloads.py b/backend/data_layer/nutrition_chart_payloads.py new file mode 100644 index 0000000..7702195 --- /dev/null +++ b/backend/data_layer/nutrition_chart_payloads.py @@ -0,0 +1,404 @@ +""" +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, + }, + } diff --git a/backend/data_layer/nutrition_viz.py b/backend/data_layer/nutrition_viz.py index 4a7ef6b..bbdb7fa 100644 --- a/backend/data_layer/nutrition_viz.py +++ b/backend/data_layer/nutrition_viz.py @@ -17,6 +17,11 @@ from data_layer.nutrition_interpretation import ( build_nutrition_correlation_heuristic_items, build_nutrition_history_kpi_tiles, ) +from data_layer.nutrition_chart_payloads import ( + build_energy_balance_chart_payload, + build_nutrition_adherence_score_payload, + build_protein_adequacy_chart_payload, +) from data_layer.nutrition_metrics import ( estimate_tdee_kcal_from_latest_weight, get_energy_availability_warning_payload, @@ -244,6 +249,8 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "calorie_balance_daily": [], "protein_vs_lean_mass": {"points": [], "protein_target_low_g": None}, "nutrition_correlation_heuristics": [], + "chart_payloads": {}, + "chart_payloads_days": None, "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, } @@ -312,6 +319,20 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An 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) + # E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf) + days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90)) + chart_payloads = { + "energy_balance": build_energy_balance_chart_payload( + profile_id, days_for_embedded_charts + ), + "protein_adequacy": build_protein_adequacy_chart_payload( + profile_id, days_for_embedded_charts + ), + "nutrition_adherence": build_nutrition_adherence_score_payload( + profile_id, days_for_embedded_charts + ), + } + conf = navg.get("confidence") or "medium" if targets.get("confidence") == "insufficient": conf = "insufficient" @@ -362,6 +383,8 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "protein_target_low_g": pt_low if pt_low > 0 else None, }, "nutrition_correlation_heuristics": nutrition_correlation_heuristics, + "chart_payloads": chart_payloads, + "chart_payloads_days": days_for_embedded_charts, "meta": { "layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz", diff --git a/backend/routers/charts.py b/backend/routers/charts.py index fd42033..8578beb 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -46,9 +46,7 @@ from data_layer.recovery_chart_payloads import ( 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, ) @@ -77,6 +75,11 @@ from data_layer.correlations import ( calculate_top_drivers ) from data_layer.utils import serialize_dates, safe_float, calculate_confidence +from data_layer.nutrition_chart_payloads import ( + build_energy_balance_chart_payload, + build_protein_adequacy_chart_payload, + build_nutrition_adherence_score_payload, +) router = APIRouter(prefix="/api/charts", tags=["charts"]) @@ -462,136 +465,7 @@ def get_energy_balance_chart( Chart.js line chart with multiple datasets """ profile_id = session['profile_id'] - - balance_meta = get_energy_balance_data(profile_id, days) - - from db import get_db, get_cursor - 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'] - }) - } + return build_energy_balance_chart_payload(profile_id, days) @router.get("/macro-distribution") @@ -706,136 +580,7 @@ def get_protein_adequacy_chart( Chart.js line chart with protein intake + averages + target bands """ profile_id = session['profile_id'] - - # Get protein targets - targets = get_protein_targets_data(profile_id) - - from db import get_db, get_cursor - 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)" - } - } - - # Prepare data - 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'])) - - # 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 = [ - { - "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 - }) - - from data_layer.utils import calculate_confidence - 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'] - }) - } + return build_protein_adequacy_chart_payload(profile_id, days) @router.get("/nutrition-consistency") @@ -963,138 +708,7 @@ def get_nutrition_adherence_score( } """ 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( - """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)" - } - } - - # 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 - } - } + return build_nutrition_adherence_score_payload(profile_id, days) @router.get("/energy-availability-warning") diff --git a/backend/version.py b/backend/version.py index 8e3e8c1..7393c96 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,7 +7,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9s" +APP_VERSION = "0.9t" BUILD_DATE = "2026-04-20" DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import @@ -36,6 +36,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.9t", + "date": "2026-04-20", + "changes": [ + "Phase C: data_layer/nutrition_chart_payloads (E1/E2/E4) — gemeinsam mit /api/charts/*", + "nutrition-history-viz: chart_payloads + chart_payloads_days; Verlauf NutritionCharts ohne 3 Extra-HTTP-Calls", + ], + }, { "version": "0.9s", "date": "2026-04-20", diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index cd3a7da..323357c 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -205,12 +205,14 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error } /** * Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird. + * @param {object} [prefetchedChartPayloads] — aus GET /charts/nutrition-history-viz (`chart_payloads`): E1/E2/E4 ohne Extra-Requests (Phase C). */ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true, /** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */ hideEnergyAvailabilityCard = false, + prefetchedChartPayloads = null, }) { const [energyData, setEnergyData] = useState(null) const [proteinData, setProteinData] = useState(null) @@ -226,20 +228,44 @@ export default function NutritionCharts({ useEffect(() => { loadCharts() - }, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard]) + }, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard, prefetchedChartPayloads]) const loadCharts = async () => { - const tasks = [ - loadEnergyBalance(), - loadProteinAdequacy(), - loadAdherence(), - ] + const p = prefetchedChartPayloads + const tasks = [] + + if (p?.energy_balance) { + setEnergyData(p.energy_balance) + setLoading((l) => ({ ...l, energy: false })) + setErrors((e) => ({ ...e, energy: null })) + } else { + tasks.push(loadEnergyBalance()) + } + + if (p?.protein_adequacy) { + setProteinData(p.protein_adequacy) + setLoading((l) => ({ ...l, protein: false })) + setErrors((e) => ({ ...e, protein: null })) + } else { + tasks.push(loadProteinAdequacy()) + } + + if (showWeeklyMacroDistribution) { + tasks.push(loadMacroWeekly()) + } + + if (p?.nutrition_adherence) { + setAdherenceData(p.nutrition_adherence) + setLoading((l) => ({ ...l, adherence: false })) + setErrors((e) => ({ ...e, adherence: null })) + } else { + tasks.push(loadAdherence()) + } + if (!hideEnergyAvailabilityCard) { tasks.push(loadWarning()) } - if (showWeeklyMacroDistribution) { - tasks.splice(2, 0, loadMacroWeekly()) - } + await Promise.all(tasks) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 09a5894..95b76db 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -1120,7 +1120,12 @@ function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs
Zeitverläufe (Energie & Protein)
- +