""" Charts Router - Chart.js-compatible Data Endpoints Provides structured data for frontend charts/diagrams. All endpoints return Chart.js-compatible JSON format: { "chart_type": "line" | "bar" | "scatter" | "pie", "data": { "labels": [...], "datasets": [...] }, "metadata": { "confidence": "high" | "medium" | "low" | "insufficient", "data_points": int, ... } } Phase 0c: Multi-Layer Architecture Version: 1.0 """ from fastapi import APIRouter, Depends, HTTPException, Query from typing import Dict, List, Optional, Set from datetime import datetime, timedelta from auth import require_auth from data_layer.body_metrics import ( get_weight_trend_data, get_body_composition_data, get_circumference_summary_data ) from data_layer.body_viz import get_body_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.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 ( build_recovery_score_chart_payload, build_hrv_rhr_baseline_chart_payload, build_sleep_duration_quality_chart_payload, build_sleep_debt_chart_payload, build_vital_signs_matrix_chart_payload, ) from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, get_macro_consistency_data, get_weekly_macro_distribution_chart_data, get_energy_availability_warning_payload, ) from data_layer.activity_metrics import ( get_activity_summary_data, calculate_training_minutes_week, calculate_monotony_score, calculate_strain_score, calculate_ability_balance, build_training_volume_chart_payload, build_training_type_distribution_chart_payload, build_quality_sessions_chart_payload, build_load_monitoring_chart_payload, ) from data_layer.recovery_metrics import ( get_sleep_duration_data, get_sleep_quality_data, calculate_recovery_score_v2, calculate_hrv_vs_baseline_pct, calculate_rhr_vs_baseline_pct, calculate_sleep_debt_hours ) from data_layer.correlations import ( calculate_lag_correlation, calculate_correlation_sleep_recovery, 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"]) # ── Body Charts ───────────────────────────────────────────────────────────── @router.get("/weight-trend") def get_weight_trend_chart( days: int = Query(default=90, ge=7, le=365, description="Analysis window in days"), session: dict = Depends(require_auth) ) -> Dict: """ Weight trend chart data. Returns Chart.js-compatible line chart with: - Raw weight values - Trend line (if enough data) - Confidence indicator Args: days: Analysis window (7-365 days, default 90) session: Auth session (injected) Returns: { "chart_type": "line", "data": { "labels": ["2026-01-01", ...], "datasets": [{ "label": "Gewicht", "data": [85.0, 84.5, ...], "borderColor": "#1D9E75", "backgroundColor": "rgba(29, 158, 117, 0.1)", "tension": 0.4 }] }, "metadata": { "confidence": "high", "data_points": 60, "first_value": 92.0, "last_value": 85.0, "delta": -7.0, "direction": "decreasing" } } Metadata fields: - confidence: Data quality ("high", "medium", "low", "insufficient") - data_points: Number of weight entries in period - first_value: First weight in period (kg) - last_value: Latest weight (kg) - delta: Weight change (kg, negative = loss) - direction: "increasing" | "decreasing" | "stable" """ profile_id = session['profile_id'] # Get structured data from data layer (includes series — no second weight_log query) trend_data = get_weight_trend_data(profile_id, days) # Early return if insufficient data if trend_data['confidence'] == 'insufficient': return { "chart_type": "line", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Trend-Analyse" } } series = trend_data.get("series") or [] labels = [ pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"]) for pt in series ] values = [pt["weight"] for pt in series] return { "chart_type": "line", "data": { "labels": labels, "datasets": [ { "label": "Gewicht", "data": values, "borderColor": "#1D9E75", "backgroundColor": "rgba(29, 158, 117, 0.1)", "borderWidth": 2, "tension": 0.4, "fill": True, "pointRadius": 3, "pointHoverRadius": 5 } ] }, "metadata": serialize_dates({ "confidence": trend_data['confidence'], "data_points": trend_data['data_points'], "first_value": trend_data['first_value'], "last_value": trend_data['last_value'], "delta": trend_data['delta'], "direction": trend_data['direction'], "first_date": trend_data['first_date'], "last_date": trend_data['last_date'], "days_analyzed": trend_data['days_analyzed'] }) } @router.get("/body-composition") def get_body_composition_chart( days: int = Query(default=90, ge=7, le=365), session: dict = Depends(require_auth) ) -> Dict: """ Body composition chart (body fat percentage, lean mass trend). Returns Chart.js-compatible multi-line chart. Args: days: Analysis window (7-365 days, default 90) session: Auth session (injected) Returns: Chart.js format with datasets for: - Body fat percentage (%) - Lean mass (kg, if weight available) """ profile_id = session['profile_id'] # Get latest body composition comp_data = get_body_composition_data(profile_id, days) if comp_data['confidence'] == 'insufficient': return { "chart_type": "line", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Körperfett-Messungen vorhanden" } } # For PoC: Return single data point # TODO in bulk migration: Fetch historical caliper entries return { "chart_type": "line", "data": { "labels": [comp_data['date'].isoformat() if comp_data['date'] else ""], "datasets": [ { "label": "Körperfett %", "data": [comp_data['body_fat_pct']], "borderColor": "#D85A30", "backgroundColor": "rgba(216, 90, 48, 0.1)", "borderWidth": 2 } ] }, "metadata": serialize_dates({ "confidence": comp_data['confidence'], "data_points": comp_data['data_points'], "body_fat_pct": comp_data['body_fat_pct'], "method": comp_data['method'], "date": comp_data['date'] }) } @router.get("/body-history-viz") def get_body_history_viz( days: int = Query( default=90, ge=7, le=9999, description="Analysefenster in Tagen (9999 = gesamte Historie im Rohdatensatz)", ), session: dict = Depends(require_auth), ) -> Dict: """ Layer 2b: Ein Bundle für Verlauf «Körper» — Charts, Kennzahlen, Bewertungskacheln. Alle Reihen und Kennzahlen stammen aus Layer 1 (dieselben Tabellen wie die Körper-Platzhalter / body_metrics). Interpretationskacheln sind mit ``related_placeholder_keys`` an Layer 2a ausgewiesen. Frontend: ausschließlich Darstellung — keine parallele Berechnung. """ profile_id = session["profile_id"] bundle = get_body_history_viz_bundle(profile_id, days) return serialize_dates(bundle) @router.get("/nutrition-history-viz") def get_nutrition_history_viz( days: int = Query( default=90, ge=7, le=9999, description="Analysefenster in Tagen (9999 = gesamte Historie)", ), session: dict = Depends(require_auth), ) -> Dict: """ Layer 2b: Ein Bundle für Verlauf «Ernährung» — Kennzahlen, Reihen, TDEE-Referenz, Wochen-Chart. Alle Kennzahlen aus nutrition_metrics (gleiche Logik wie Platzhalter / Chart-Endpunkte). """ profile_id = session["profile_id"] bundle = get_nutrition_history_viz_bundle(profile_id, days) return serialize_dates(bundle) @router.get("/fitness-dashboard-viz") def get_fitness_dashboard_viz( days: int = Query( default=28, ge=7, le=9999, description="Analysefenster in Tagen (9999 = lange Historie)", ), session: dict = Depends(require_auth), ) -> Dict: """ Layer 2b: Fitness-Übersicht — KPI-Kacheln + Volumen- und Typ-Verteilungs-Charts. Daten aus activity_metrics (gleiche Payloads wie training-volume / training-type-distribution). """ profile_id = session["profile_id"] bundle = get_fitness_dashboard_viz_bundle(profile_id, days) 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( default=28, ge=7, le=9999, description="Analysefenster in Tagen (9999 = lange Historie)", ), session: dict = Depends(require_auth), ) -> Dict: """ Layer 2b: Recovery/Erholung — KPIs, Insights, Charts R1–R5 (recovery_metrics). """ profile_id = session["profile_id"] bundle = get_recovery_dashboard_viz_bundle(profile_id, days) return serialize_dates(bundle) @router.get("/history-overview-viz") def get_history_overview_viz( days: int = Query( default=30, ge=7, le=9999, description="Analysefenster in Tagen (komponiert Körper/Ernährung/Fitness/Erholung)", ), session: dict = Depends(require_auth), ) -> Dict: """ Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1–C4 (Metadaten). """ profile_id = session["profile_id"] bundle = get_history_overview_viz_bundle(profile_id, days) return serialize_dates(bundle) @router.get("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), session: dict = Depends(require_auth) ) -> Dict: """ Latest circumference measurements as bar chart. Shows most recent measurement for each body point. Args: max_age_days: Maximum age of measurements (default 90) session: Auth session (injected) Returns: Chart.js bar chart with all circumference points """ profile_id = session['profile_id'] circ_data = get_circumference_summary_data(profile_id, max_age_days) if circ_data['confidence'] == 'insufficient': return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Umfangsmessungen vorhanden" } } # Sort by value (descending) for better visualization measurements = sorted( circ_data['measurements'], key=lambda m: m['value'], reverse=True ) labels = [m['point'] for m in measurements] values = [m['value'] for m in measurements] return { "chart_type": "bar", "data": { "labels": labels, "datasets": [ { "label": "Umfang (cm)", "data": values, "backgroundColor": "#1D9E75", "borderColor": "#085041", "borderWidth": 1 } ] }, "metadata": serialize_dates({ "confidence": circ_data['confidence'], "data_points": circ_data['data_points'], "newest_date": circ_data['newest_date'], "oldest_date": circ_data['oldest_date'], "measurements": circ_data['measurements'] # Full details }) } # ── Nutrition Charts ──────────────────────────────────────────────────────── @router.get("/energy-balance") def get_energy_balance_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Energy balance timeline (E1) - Konzept-konform. 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 multiple datasets """ profile_id = session['profile_id'] return build_energy_balance_chart_payload(profile_id, days) @router.get("/macro-distribution") def get_macro_distribution_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Macronutrient distribution pie chart (E2). Shows average protein/carbs/fat distribution over period. Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js pie chart with macro percentages """ profile_id = session['profile_id'] # Get average macros macro_data = get_nutrition_average_data(profile_id, days) if macro_data['confidence'] == 'insufficient': return { "chart_type": "pie", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Ernährungsdaten vorhanden" } } # Calculate calories from macros (protein/carbs = 4 kcal/g, fat = 9 kcal/g) protein_kcal = macro_data['protein_avg'] * 4 carbs_kcal = macro_data['carbs_avg'] * 4 fat_kcal = macro_data['fat_avg'] * 9 total_kcal = protein_kcal + carbs_kcal + fat_kcal if total_kcal == 0: return { "chart_type": "pie", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Makronährstoff-Daten" } } protein_pct = (protein_kcal / total_kcal * 100) carbs_pct = (carbs_kcal / total_kcal * 100) fat_pct = (fat_kcal / total_kcal * 100) return { "chart_type": "pie", "data": { "labels": ["Protein", "Kohlenhydrate", "Fett"], "datasets": [ { "data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)], "backgroundColor": [ "#1D9E75", # Protein (green) "#F59E0B", # Carbs (amber) "#EF4444" # Fat (red) ], "borderWidth": 2, "borderColor": "#fff" } ] }, "metadata": { "confidence": macro_data['confidence'], "data_points": macro_data['data_points'], "protein_g": round(macro_data['protein_avg'], 1), "carbs_g": round(macro_data['carbs_avg'], 1), "fat_g": round(macro_data['fat_avg'], 1), "protein_pct": round(protein_pct, 1), "carbs_pct": round(carbs_pct, 1), "fat_pct": round(fat_pct, 1) } } @router.get("/protein-adequacy") def get_protein_adequacy_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Protein adequacy timeline (E2) - Konzept-konform. 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 + averages + target bands """ profile_id = session['profile_id'] return build_protein_adequacy_chart_payload(profile_id, days) @router.get("/nutrition-consistency") def get_nutrition_consistency_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Nutrition consistency score (E5). Shows macro consistency score as bar chart. Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js bar chart with consistency metrics """ profile_id = session['profile_id'] consistency_data = get_macro_consistency_data(profile_id, days) if consistency_data['confidence'] == 'insufficient': return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Konsistenz-Analyse" } } # Show consistency score + macro averages labels = [ "Gesamt-Score", f"Protein ({consistency_data['avg_protein_pct']:.0f}%)", f"Kohlenhydrate ({consistency_data['avg_carbs_pct']:.0f}%)", f"Fett ({consistency_data['avg_fat_pct']:.0f}%)" ] # Score = 100 - std_dev (inverted for display) # Higher bar = more consistent protein_consistency = max(0, 100 - consistency_data['std_dev_protein'] * 10) carbs_consistency = max(0, 100 - consistency_data['std_dev_carbs'] * 10) fat_consistency = max(0, 100 - consistency_data['std_dev_fat'] * 10) values = [ consistency_data['consistency_score'], protein_consistency, carbs_consistency, fat_consistency ] return { "chart_type": "bar", "data": { "labels": labels, "datasets": [ { "label": "Konsistenz-Score", "data": values, "backgroundColor": ["#1D9E75", "#1D9E75", "#F59E0B", "#EF4444"], "borderColor": "#085041", "borderWidth": 1 } ] }, "metadata": { "confidence": consistency_data['confidence'], "data_points": consistency_data['data_points'], "consistency_score": consistency_data['consistency_score'], "std_dev_protein": round(consistency_data['std_dev_protein'], 2), "std_dev_carbs": round(consistency_data['std_dev_carbs'], 2), "std_dev_fat": round(consistency_data['std_dev_fat'], 2) } } # ── 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. Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data """ profile_id = session['profile_id'] return get_weekly_macro_distribution_chart_data(profile_id, weeks) @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'] return build_nutrition_adherence_score_payload(profile_id, 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. Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload """ profile_id = session['profile_id'] return get_energy_availability_warning_payload(profile_id, days) @router.get("/training-volume") def get_training_volume_chart( weeks: int = Query(default=12, ge=4, le=52), session: dict = Depends(require_auth) ) -> Dict: """ Training volume week-over-week (A1). Shows weekly training minutes over time. Args: weeks: Number of weeks to analyze (4-52, default 12) session: Auth session (injected) Returns: Chart.js bar chart with weekly training minutes """ profile_id = session['profile_id'] return build_training_volume_chart_payload(profile_id, weeks) @router.get("/training-type-distribution") def get_training_type_distribution_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Training type distribution (A2). Shows distribution of training categories as pie chart. Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js pie chart with training categories """ profile_id = session['profile_id'] return build_training_type_distribution_chart_payload(profile_id, days) @router.get("/quality-sessions") def get_quality_sessions_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Quality session rate (A3). Shows percentage of quality sessions (RPE >= 7 or duration >= 60min). Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js bar chart with quality metrics """ profile_id = session['profile_id'] return build_quality_sessions_chart_payload(profile_id, days) @router.get("/load-monitoring") def get_load_monitoring_chart( days: int = Query(default=28, ge=14, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Load monitoring (A4). Shows acute load (7d) vs chronic load (28d) and ACWR. Args: days: Analysis window (14-90 days, default 28) session: Auth session (injected) Returns: Chart.js line chart with load metrics """ profile_id = session['profile_id'] return build_load_monitoring_chart_payload(profile_id, days) @router.get("/monotony-strain") def get_monotony_strain_chart( days: int = Query(default=7, ge=7, le=28), session: dict = Depends(require_auth) ) -> Dict: """ Monotony & Strain (A5). Shows training monotony and strain scores. Args: days: Analysis window (7-28 days, default 7) session: Auth session (injected) Returns: Chart.js bar chart with monotony and strain """ profile_id = session['profile_id'] monotony = calculate_monotony_score(profile_id, days) strain = calculate_strain_score(profile_id, days) if monotony == 0 and strain == 0: return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Monotonie-Analyse" } } return { "chart_type": "bar", "data": { "labels": ["Monotonie", "Strain"], "datasets": [ { "label": "Score", "data": [round(monotony, 2), round(strain, 1)], "backgroundColor": ["#F59E0B", "#EF4444"], "borderColor": "#085041", "borderWidth": 1 } ] }, "metadata": { "confidence": "medium", # Fixed for monotony calculations "monotony_score": round(monotony, 2), "strain_score": round(strain, 1), "monotony_status": "high" if monotony > 2.0 else "normal", "strain_status": "high" if strain > 10000 else "normal" } } @router.get("/ability-balance") def get_ability_balance_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Ability balance radar chart (A6). Shows training distribution across 5 abilities. Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js radar chart with ability balance """ profile_id = session['profile_id'] balance_data = calculate_ability_balance(profile_id, days) if balance_data['total_minutes'] == 0: return { "chart_type": "radar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Aktivitätsdaten" } } labels = ["Kraft", "Ausdauer", "Beweglichkeit", "Gleichgewicht", "Geist"] values = [ balance_data['strength_pct'], balance_data['endurance_pct'], balance_data['flexibility_pct'], balance_data['balance_pct'], balance_data['mind_pct'] ] return { "chart_type": "radar", "data": { "labels": labels, "datasets": [ { "label": "Fähigkeiten-Balance (%)", "data": values, "borderColor": "#1D9E75", "backgroundColor": "rgba(29, 158, 117, 0.2)", "borderWidth": 2, "pointBackgroundColor": "#1D9E75", "pointBorderColor": "#fff", "pointHoverBackgroundColor": "#fff", "pointHoverBorderColor": "#1D9E75" } ] }, "metadata": { "confidence": balance_data['confidence'], "total_minutes": balance_data['total_minutes'], "strength_pct": round(balance_data['strength_pct'], 1), "endurance_pct": round(balance_data['endurance_pct'], 1), "flexibility_pct": round(balance_data['flexibility_pct'], 1), "balance_pct": round(balance_data['balance_pct'], 1), "mind_pct": round(balance_data['mind_pct'], 1) } } @router.get("/volume-by-ability") def get_volume_by_ability_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """ Training volume by ability (A8). Shows absolute minutes per ability category. Args: days: Analysis window (7-90 days, default 28) session: Auth session (injected) Returns: Chart.js bar chart with volume per ability """ profile_id = session['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 COALESCE(ability, 'unknown') as ability, SUM(duration_min) as total_minutes FROM activity_log WHERE profile_id=%s AND date >= %s GROUP BY ability ORDER BY total_minutes DESC""", (profile_id, cutoff) ) rows = cur.fetchall() if not rows: return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Ability-Daten" } } # Map ability names to German ability_map = { "strength": "Kraft", "endurance": "Ausdauer", "flexibility": "Beweglichkeit", "balance": "Gleichgewicht", "mind": "Geist", "unknown": "Nicht zugeordnet" } labels = [ability_map.get(row['ability'], row['ability']) for row in rows] values = [safe_float(row['total_minutes']) for row in rows] total_minutes = sum(values) return { "chart_type": "bar", "data": { "labels": labels, "datasets": [ { "label": "Trainingsminuten", "data": values, "backgroundColor": "#1D9E75", "borderColor": "#085041", "borderWidth": 1 } ] }, "metadata": { "confidence": calculate_confidence(len(rows), days, "general"), "data_points": len(rows), "total_minutes": total_minutes } } # ── Recovery Charts ───────────────────────────────────────────────────────── @router.get("/recovery-score") def get_recovery_score_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """Recovery score timeline (R1). Delegiert an recovery_chart_payloads.""" profile_id = session["profile_id"] return build_recovery_score_chart_payload(profile_id, days) @router.get("/hrv-rhr-baseline") def get_hrv_rhr_baseline_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """HRV/RHR vs baseline (R2).""" profile_id = session["profile_id"] return build_hrv_rhr_baseline_chart_payload(profile_id, days) @router.get("/sleep-duration-quality") def get_sleep_duration_quality_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """Sleep duration + quality (R3).""" profile_id = session["profile_id"] return build_sleep_duration_quality_chart_payload(profile_id, days) @router.get("/sleep-debt") def get_sleep_debt_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: """Sleep debt (R4).""" profile_id = session["profile_id"] return build_sleep_debt_chart_payload(profile_id, days) @router.get("/vital-signs-matrix") def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=365), omit_snapshot_keys: Optional[str] = Query( default=None, description="Optional: Komma-getrennte Keys ausblenden (z. B. resting_hr,hrv) wenn Einordnung woanders steht.", ), session: dict = Depends(require_auth), ) -> Dict: """Vital signs matrix (R5).""" profile_id = session["profile_id"] omit_set: Optional[Set[str]] = None if omit_snapshot_keys and omit_snapshot_keys.strip(): omit_set = {x.strip() for x in omit_snapshot_keys.split(",") if x.strip()} return build_vital_signs_matrix_chart_payload(profile_id, days, omit_snapshot_keys=omit_set) # ── Correlation Charts ────────────────────────────────────────────────────── @router.get("/weight-energy-correlation") def get_weight_energy_correlation_chart( max_lag: int = Query(default=14, ge=7, le=28), session: dict = Depends(require_auth) ) -> Dict: """ Weight vs energy balance correlation (C1). Shows lag correlation between energy intake and weight change. Args: max_lag: Maximum lag days to analyze (7-28, default 14) session: Auth session (injected) Returns: Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag) if not corr_data or corr_data.get('correlation') is None: return { "chart_type": "scatter", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Korrelationsanalyse" } } # Create lag vs correlation data for chart # For simplicity, show best lag point as single data point best_lag = corr_data.get('best_lag_days', 0) correlation = corr_data.get('correlation', 0) return { "chart_type": "scatter", "data": { "labels": [f"Lag {best_lag} Tage"], "datasets": [ { "label": "Korrelation", "data": [{"x": best_lag, "y": correlation}], "backgroundColor": "#1D9E75", "borderColor": "#085041", "borderWidth": 2, "pointRadius": 8 } ] }, "metadata": { "confidence": corr_data.get('confidence', 'low'), "correlation": round(correlation, 3), "best_lag_days": best_lag, "interpretation": corr_data.get('interpretation', ''), "data_points": corr_data.get('data_points', 0) } } @router.get("/lbm-protein-correlation") def get_lbm_protein_correlation_chart( max_lag: int = Query(default=14, ge=7, le=28), session: dict = Depends(require_auth) ) -> Dict: """ Lean mass vs protein intake correlation (C2). Shows lag correlation between protein intake and lean mass change. Args: max_lag: Maximum lag days to analyze (7-28, default 14) session: Auth session (injected) Returns: Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag) if not corr_data or corr_data.get('correlation') is None: return { "chart_type": "scatter", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für LBM-Protein Korrelation" } } best_lag = corr_data.get('best_lag_days', 0) correlation = corr_data.get('correlation', 0) return { "chart_type": "scatter", "data": { "labels": [f"Lag {best_lag} Tage"], "datasets": [ { "label": "Korrelation", "data": [{"x": best_lag, "y": correlation}], "backgroundColor": "#3B82F6", "borderColor": "#1E40AF", "borderWidth": 2, "pointRadius": 8 } ] }, "metadata": { "confidence": corr_data.get('confidence', 'low'), "correlation": round(correlation, 3), "best_lag_days": best_lag, "interpretation": corr_data.get('interpretation', ''), "data_points": corr_data.get('data_points', 0) } } @router.get("/load-vitals-correlation") def get_load_vitals_correlation_chart( max_lag: int = Query(default=14, ge=7, le=28), session: dict = Depends(require_auth) ) -> Dict: """ Training load vs vitals correlation (C3). Shows lag correlation between training load and HRV/RHR. Args: max_lag: Maximum lag days to analyze (7-28, default 14) session: Auth session (injected) Returns: Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] # Try HRV first corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag) corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag) # Use whichever has stronger correlation if corr_hrv and corr_rhr: corr_data = corr_hrv if abs(corr_hrv.get('correlation', 0)) > abs(corr_rhr.get('correlation', 0)) else corr_rhr metric_name = "HRV" if corr_data == corr_hrv else "RHR" elif corr_hrv: corr_data = corr_hrv metric_name = "HRV" elif corr_rhr: corr_data = corr_rhr metric_name = "RHR" else: return { "chart_type": "scatter", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Load-Vitals Korrelation" } } best_lag = corr_data.get('best_lag_days', 0) correlation = corr_data.get('correlation', 0) return { "chart_type": "scatter", "data": { "labels": [f"Load → {metric_name} (Lag {best_lag}d)"], "datasets": [ { "label": "Korrelation", "data": [{"x": best_lag, "y": correlation}], "backgroundColor": "#F59E0B", "borderColor": "#D97706", "borderWidth": 2, "pointRadius": 8 } ] }, "metadata": { "confidence": corr_data.get('confidence', 'low'), "correlation": round(correlation, 3), "best_lag_days": best_lag, "metric": metric_name, "interpretation": corr_data.get('interpretation', ''), "data_points": corr_data.get('data_points', 0) } } @router.get("/recovery-performance") def get_recovery_performance_chart( session: dict = Depends(require_auth) ) -> Dict: """ Recovery vs performance correlation (C4). Shows relationship between recovery metrics and training quality. Args: session: Auth session (injected) Returns: Chart.js bar chart with top drivers """ profile_id = session['profile_id'] # Get top drivers (hindering/helpful factors) drivers = calculate_top_drivers(profile_id) if not drivers or len(drivers) == 0: return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Nicht genug Daten für Driver-Analyse" } } # Separate hindering and helpful hindering = [d for d in drivers if d.get('impact', '') == 'hindering'] helpful = [d for d in drivers if d.get('impact', '') == 'helpful'] # Take top 3 of each top_hindering = hindering[:3] top_helpful = helpful[:3] labels = [] values = [] colors = [] for d in top_hindering: labels.append(f"❌ {d.get('factor', '')}") values.append(-abs(d.get('score', 0))) # Negative for hindering colors.append("#EF4444") for d in top_helpful: labels.append(f"✅ {d.get('factor', '')}") values.append(abs(d.get('score', 0))) # Positive for helpful colors.append("#1D9E75") if not labels: return { "chart_type": "bar", "data": { "labels": [], "datasets": [] }, "metadata": { "confidence": "low", "data_points": 0, "message": "Keine signifikanten Treiber gefunden" } } return { "chart_type": "bar", "data": { "labels": labels, "datasets": [ { "label": "Impact Score", "data": values, "backgroundColor": colors, "borderColor": "#085041", "borderWidth": 1 } ] }, "metadata": { "confidence": "medium", "hindering_count": len(top_hindering), "helpful_count": len(top_helpful), "total_factors": len(drivers) } } # ── Health Endpoint ────────────────────────────────────────────────────────── @router.get("/health") def health_check() -> Dict: """ Health check endpoint for charts API. Returns: { "status": "ok", "version": "1.0", "available_charts": [...] } """ return { "status": "ok", "version": "1.0", "phase": "0c", "available_charts": [ { "category": "body", "endpoint": "/charts/weight-trend", "type": "line", "description": "Weight trend over time" }, { "category": "body", "endpoint": "/charts/body-composition", "type": "line", "description": "Body fat % and lean mass" }, { "category": "body", "endpoint": "/charts/circumferences", "type": "bar", "description": "Latest circumference measurements" }, { "category": "nutrition", "endpoint": "/charts/energy-balance", "type": "line", "description": "Daily calorie intake vs. TDEE" }, { "category": "nutrition", "endpoint": "/charts/macro-distribution", "type": "pie", "description": "Protein/Carbs/Fat distribution" }, { "category": "nutrition", "endpoint": "/charts/protein-adequacy", "type": "line", "description": "Protein intake vs. target range" }, { "category": "nutrition", "endpoint": "/charts/nutrition-consistency", "type": "bar", "description": "Macro consistency score" }, { "category": "activity", "endpoint": "/charts/training-volume", "type": "bar", "description": "Weekly training minutes" }, { "category": "activity", "endpoint": "/charts/training-type-distribution", "type": "pie", "description": "Training category distribution" }, { "category": "activity", "endpoint": "/charts/quality-sessions", "type": "bar", "description": "Quality session rate" }, { "category": "activity", "endpoint": "/charts/load-monitoring", "type": "line", "description": "Acute vs chronic load + ACWR" }, { "category": "activity", "endpoint": "/charts/monotony-strain", "type": "bar", "description": "Training monotony and strain" }, { "category": "activity", "endpoint": "/charts/ability-balance", "type": "radar", "description": "Training balance across 5 abilities" }, { "category": "activity", "endpoint": "/charts/volume-by-ability", "type": "bar", "description": "Training volume per ability" }, { "category": "recovery", "endpoint": "/charts/recovery-score", "type": "line", "description": "Recovery score timeline" }, { "category": "recovery", "endpoint": "/charts/hrv-rhr-baseline", "type": "line", "description": "HRV and RHR vs baseline" }, { "category": "recovery", "endpoint": "/charts/sleep-duration-quality", "type": "line", "description": "Sleep duration and quality" }, { "category": "recovery", "endpoint": "/charts/sleep-debt", "type": "line", "description": "Cumulative sleep debt" }, { "category": "recovery", "endpoint": "/charts/vital-signs-matrix", "type": "bar", "description": "Latest vital signs overview" }, { "category": "correlations", "endpoint": "/charts/weight-energy-correlation", "type": "scatter", "description": "Weight vs energy balance (lag correlation)" }, { "category": "correlations", "endpoint": "/charts/lbm-protein-correlation", "type": "scatter", "description": "Lean mass vs protein intake (lag correlation)" }, { "category": "correlations", "endpoint": "/charts/load-vitals-correlation", "type": "scatter", "description": "Training load vs HRV/RHR (lag correlation)" }, { "category": "correlations", "endpoint": "/charts/recovery-performance", "type": "bar", "description": "Top drivers (hindering/helpful factors)" } ] }