diff --git a/backend/data_layer/body_viz.py b/backend/data_layer/body_viz.py index 558490c..1820ea5 100644 --- a/backend/data_layer/body_viz.py +++ b/backend/data_layer/body_viz.py @@ -48,6 +48,31 @@ def _iso(d: Any) -> Optional[str]: return str(d)[:10] +def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]: + """ + Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode). + Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr. + """ + if not trend_periods: + return {"verdict": "Stabil", "status": "good"} + t90 = next((t for t in trend_periods if t.get("label") == "90T"), None) + t30 = next((t for t in trend_periods if t.get("label") == "30T"), None) + d: Optional[float] = None + if t90 is not None and t90.get("diff_kg") is not None: + d = float(t90["diff_kg"]) + elif t30 is not None and t30.get("diff_kg") is not None: + d = float(t30["diff_kg"]) + elif trend_periods[0].get("diff_kg") is not None: + d = float(trend_periods[0]["diff_kg"]) + else: + return {"verdict": "Stabil", "status": "good"} + if d < -0.25: + return {"verdict": "Trend ↓", "status": "good"} + if d > 0.25: + return {"verdict": "Trend ↑", "status": "warn"} + return {"verdict": "Stabil", "status": "good"} + + def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: """ Returns chart-ready series and interpretation tiles for the body history tab. @@ -437,6 +462,7 @@ def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: "min_kg": min_w, "max_kg": max_w, "trend_periods": trend_periods, + "trend_kpi": _weight_trend_kpi(trend_periods), "data_points": len(w_points), "related_placeholder_keys": [ "weight_aktuell", diff --git a/backend/data_layer/fitness_viz.py b/backend/data_layer/fitness_viz.py index 9c8ae03..fbf5446 100644 --- a/backend/data_layer/fitness_viz.py +++ b/backend/data_layer/fitness_viz.py @@ -59,6 +59,15 @@ def _last_activity_date(profile_id: str) -> Optional[str]: return _iso(row["d"]) +def get_activity_last_updated_iso(profile_id: str) -> Optional[str]: + """ + Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle. + + Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad). + """ + return _last_activity_date(profile_id) + + def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: """ Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format). 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 eed8e92..8578beb 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -33,7 +33,7 @@ from data_layer.body_metrics import ( ) 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.fitness_viz import get_fitness_dashboard_viz_bundle +from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle, get_activity_last_updated_iso from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle from data_layer.history_overview_viz import get_history_overview_viz_bundle from data_layer.recovery_chart_payloads import ( @@ -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"]) @@ -319,6 +322,17 @@ def get_fitness_dashboard_viz( return serialize_dates(bundle) +@router.get("/activity-last-updated") +def get_activity_last_updated(session: dict = Depends(require_auth)) -> Dict: + """ + Minimal-Metadatum: letztes Trainingsdatum — gleiche Quelle wie ``last_updated`` im Fitness-Viz-Bundle. + + Vermeidet Massen-Ladevorgänge (z. B. listActivity) nur für Datumsanzeige im Verlauf. + """ + pid = session["profile_id"] + return {"last_activity_date": get_activity_last_updated_iso(pid)} + + @router.get("/recovery-dashboard-viz") def get_recovery_dashboard_viz( days: int = Query( @@ -451,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") @@ -695,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") @@ -952,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 bb05fce..7393c96 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9q" -BUILD_DATE = "2026-04-11" +APP_VERSION = "0.9t" +BUILD_DATE = "2026-04-20" DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import MODULE_VERSIONS = { @@ -36,6 +36,31 @@ 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", + "changes": [ + "Phase B: body-history-viz weight.trend_kpi (Gewichtstrend-Urteil im data_layer/body_viz)", + "History Körper-KPIs: keine Client-Schwellen für WHR/WHtR; KF%-Farbe über Interpretations-Status", + "Kcal vs. Gewicht: kein Frontend-TDEE-Fallback; Hinweis bei <5 gemeinsamen Tagen", + ], + }, + { + "version": "0.9r", + "date": "2026-04-20", + "changes": [ + "History Phase A: GET /api/charts/activity-last-updated (data_layer fitness_viz, gleiche Quelle wie last_updated im Fitness-Bundle)", + "History: entfernt toten Initial-Load listWeight/listCaliper/listCirc/listNutrition/listActivity(25k); Profil/Insights/Prompts + Activity-Datum", + ], + }, { "version": "0.9q", "date": "2026-04-11", 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 b0eb75a..95b76db 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -10,7 +10,6 @@ import { import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' -import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' @@ -22,12 +21,6 @@ import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -function rollingAvg(arr, key, window=7) { - return arr.map((d,i) => { - const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null) - return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d - }) -} const fmtDate = d => dayjs(d).format('DD.MM') function NavToCaliper() { @@ -94,23 +87,14 @@ function verdictShort(status) { return 'Achtung' } -/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */ +/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */ function buildBodyKpiTiles({ - summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW, + summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW, }) { const tiles = [] if (summary.weight_kg != null) { - const t90 = trendPeriods.find(t => t.label === '90T') - const t30 = trendPeriods.find(t => t.label === '30T') - const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg - let st = 'good' - let vs = 'Stabil' - if (d != null) { - if (d < -0.25) { st = 'good'; vs = 'Trend ↓' } - else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' } - else { st = 'good'; vs = 'Stabil' } - } + const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' } const trendBits = trendPeriods.length ? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ') : '' @@ -128,8 +112,8 @@ function buildBodyKpiTiles({ icon: '⚖️', value: `${summary.weight_kg} kg`, sublabel: dataPoints ? `${dataPoints} Messwerte` : '', - verdict: vs, - status: st, + verdict: wt.verdict, + status: wt.status, hoverTop: 'Gewicht', hoverBody, keys: ['weight_aktuell', 'weight_trend'], @@ -143,8 +127,8 @@ function buildBodyKpiTiles({ category: 'Körperfett', icon: '🫧', value: `${summary.body_fat_pct}%`, - valueColor: bfCat?.color, - sublabel: bfCat?.label || summary.bf_category_label || '', + valueColor: kfRule ? getStatusColor(kfRule.status) : undefined, + sublabel: summary.bf_category_label || '', verdict: verdictShort(kfRule?.status || 'good'), status: kfRule?.status || 'good', hoverTop: kfRule?.title || 'Körperfettanteil', @@ -186,34 +170,32 @@ function buildBodyKpiTiles({ } const whrRule = rules.find(r => r.category === 'Fettverteilung') - if (summary.whr != null) { - const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85) + if (summary.whr != null && whrRule) { tiles.push({ key: 'whr', category: 'Fettverteilung', icon: '📐', value: String(summary.whr), sublabel: 'WHR · Taille ÷ Hüfte', - verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'), - status: whrRule?.status || (ok ? 'good' : 'warn'), - hoverTop: whrRule?.title || 'Waist-Hip-Ratio', - hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + verdict: verdictShort(whrRule.status), + status: whrRule.status, + hoverTop: whrRule.title || 'Waist-Hip-Ratio', + hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } const whtrRule = rules.find(r => r.category === 'Taille/Größe') - if (summary.whtr != null) { - const ok = summary.whtr < 0.5 + if (summary.whtr != null && whtrRule) { tiles.push({ key: 'whtr', category: 'Taille/Größe', icon: '📏', value: String(summary.whtr), sublabel: 'WHtR · Taille ÷ Größe', - verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'), - status: whtrRule?.status || (ok ? 'good' : 'warn'), - hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio', - hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + verdict: verdictShort(whtrRule.status), + status: whtrRule.status, + hoverTop: whtrRule.title || 'Waist-to-Height-Ratio', + hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } @@ -399,8 +381,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl const [vizLoading, setVizLoading] = useState(true) const [vizError, setVizError] = useState(null) - const sex = profile?.sex || 'm' - useEffect(() => { let cancelled = false api.listGoalsGrouped() @@ -470,7 +450,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl belly: r.belly, })) - const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct @@ -492,8 +471,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl maxW, avgAll, dataPoints: w?.data_points, - sex, - bfCat, + weightTrendKpi: w?.trend_kpi, goalW, }) @@ -785,75 +763,31 @@ function KcalVsWeightLegend({ showTdee }) { ) } -/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */ -function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) { - if (vizKcalWeight?.points?.length >= 5) { - const tdee = vizKcalWeight.tdee_reference_kcal - const kcalVsW = vizKcalWeight.points.map(d => ({ - ...d, - date: fmtDate(d.date), - })) - const n = vizKcalWeight.common_days_count ?? kcalVsW.length - const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null - const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) +/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */ +function KcalVsWeightChart({ vizKcalWeight }) { + const n = vizKcalWeight?.points?.length ?? 0 + if (n < 5) { + if (n === 0) return null return ( -
+
Kalorien (Ø 7 Tage) vs. Gewicht
-
- Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. -
- - - - - - - [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - {tdeeLabel != null && ( - - )} - - - - - -
- {tdeeLabel != null - ? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage` - : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`} +
+ Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
) } - const raw = (corrRows || []).filter(d => { - if (!d.kcal || d.weight == null) return false - const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD') - return allTime || ds >= cutoffDate - }) - if (raw.length < 5) return null - - const sex = profile?.sex || 'm' - const height = profile?.height || 178 - const latestW = raw[raw.length - 1]?.weight || 80 - const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35 - const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161 - const tdee = Math.round(bmr * 1.4) - const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal') - const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee) - + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map(d => ({ + ...d, + date: fmtDate(d.date), + })) + const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) return (
@@ -866,27 +800,31 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> + {tdeeLabel != null && ( + + )} - +
- TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage + {tdeeLabel != null + ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
) @@ -894,7 +832,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD // ── Nutrition Section ───────────────────────────────────────────────────────── /** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ -function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { +function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) const [groupedGoals, setGroupedGoals] = useState(null) const [viz, setViz] = useState(null) @@ -1000,13 +938,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct - + {balDaily.length > 0 && tdeeRef != null && (
@@ -1188,7 +1120,12 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
Zeitverläufe (Energie & Protein)
- +
@@ -1196,14 +1133,12 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct } // ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─ -function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { +function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) - const actList = activities || [] - const hasList = actList.length > 0 return (
- +

Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig. @@ -1215,7 +1150,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA

- {hasList && globalQualityLevel && globalQualityLevel !== 'all' && ( + {activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
Promise.all([ - api.listWeight(365), api.listCaliper(), api.listCirc(), - api.listNutrition(90), api.listActivity(25_000), - api.latestInsights(), api.getProfile(), + api.latestInsights(), + api.getProfile(), api.listPrompts(), - ]).then(([w,ca,ci,n,a,ins,p,pr])=>{ - setWeights(w); setCalipers(ca); setCircs(ci) - setNutrition(n); setActivities(a) - setInsights(Array.isArray(ins)?ins:[]); setProfile(p) - setPrompts(Array.isArray(pr)?pr:[]) + api.getActivityLastUpdated(), + ]).then(([ins, p, pr, actMeta]) => { + setInsights(Array.isArray(ins) ? ins : []) + setProfile(p) + setPrompts(Array.isArray(pr) ? pr : []) + setActivityLastDate(actMeta?.last_activity_date ?? null) setLoading(false) }) @@ -1819,8 +1750,8 @@ export default function History() {
{tab==='overview' && } {tab==='body' && } - {tab==='nutrition' && } - {tab==='activity' && } + {tab==='nutrition' && } + {tab==='activity' && } {tab==='photos' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 505e354..80fba9c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -644,6 +644,8 @@ export const api = { /** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */ getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`), getHistoryOverviewViz: (days=30) => req(`/charts/history-overview-viz?days=${days}`), + /** Minimal: letztes activity_log.date — wie fitness-dashboard-viz.last_updated */ + getActivityLastUpdated: () => req('/charts/activity-last-updated'), getWeightEnergyCorrelationChart: (maxLag=14) => req(`/charts/weight-energy-correlation?max_lag=${maxLag}`), getLbmProteinCorrelationChart: (maxLag=14) => req(`/charts/lbm-protein-correlation?max_lag=${maxLag}`), getLoadVitalsCorrelationChart: (maxLag=14) => req(`/charts/load-vitals-correlation?max_lag=${maxLag}`),