- Updated `calculate_lag_correlation` to include detailed interpretations and lag details for energy balance vs. weight change, protein vs. lean mass, and load vs. vital metrics. - Improved handling of insufficient data scenarios in correlation charts, providing clearer messages and metadata for user insights. - Refactored chart functions to utilize best lag values and correlation data more effectively, enhancing the visualization of relationships between metrics.
1587 lines
50 KiB
Python
1587 lines
50 KiB
Python
"""
|
||
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:
|
||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
||
if isinstance(corr_data, dict):
|
||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
||
return {
|
||
"chart_type": "scatter",
|
||
"data": {
|
||
"labels": [],
|
||
"datasets": []
|
||
},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
||
"message": msg,
|
||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
||
}
|
||
}
|
||
|
||
# Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53)
|
||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 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(float(correlation), 3),
|
||
"best_lag_days": best_lag,
|
||
"interpretation": corr_data.get('interpretation', ''),
|
||
"data_points": corr_data.get('data_points', 0),
|
||
"lag_details": corr_data.get("lag_details"),
|
||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
||
"layer_1": "correlations._correlate_energy_weight",
|
||
}
|
||
}
|
||
|
||
|
||
@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:
|
||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
||
if isinstance(corr_data, dict):
|
||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
||
return {
|
||
"chart_type": "scatter",
|
||
"data": {
|
||
"labels": [],
|
||
"datasets": []
|
||
},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
||
"message": msg,
|
||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
||
}
|
||
}
|
||
|
||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 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(float(correlation), 3),
|
||
"best_lag_days": best_lag,
|
||
"interpretation": corr_data.get('interpretation', ''),
|
||
"data_points": corr_data.get('data_points', 0),
|
||
"lag_details": corr_data.get("lag_details"),
|
||
"layer_1": "correlations._correlate_protein_lbm",
|
||
}
|
||
}
|
||
|
||
|
||
@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']
|
||
|
||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
||
|
||
def _abs_corr(c):
|
||
if not c or c.get("correlation") is None:
|
||
return -1.0
|
||
try:
|
||
return abs(float(c["correlation"]))
|
||
except (TypeError, ValueError):
|
||
return -1.0
|
||
|
||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
||
if h_msg or r_msg:
|
||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
||
return {
|
||
"chart_type": "scatter",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": msg,
|
||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
||
},
|
||
}
|
||
|
||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
||
corr_data = corr_hrv
|
||
metric_name = "HRV"
|
||
else:
|
||
corr_data = corr_rhr
|
||
metric_name = "RHR"
|
||
|
||
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": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
||
},
|
||
}
|
||
|
||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 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(float(correlation), 3),
|
||
"best_lag_days": best_lag,
|
||
"metric": metric_name,
|
||
"interpretation": corr_data.get('interpretation', ''),
|
||
"data_points": corr_data.get('data_points', 0),
|
||
"lag_details": corr_data.get("lag_details"),
|
||
"layer_1": "correlations._correlate_load_vitals",
|
||
}
|
||
}
|
||
|
||
|
||
@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)"
|
||
}
|
||
]
|
||
}
|