mitai-jinkendo/backend/routers/charts.py
Lars 3f6673b636
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
feat: update app version to 0.9t and enhance nutrition visualization
- Bumped application version to 0.9t and updated changelog with new features.
- Integrated new chart payloads for energy balance, protein adequacy, and nutrition adherence to optimize data retrieval and reduce HTTP requests.
- Updated NutritionCharts component to utilize prefetched chart payloads, improving loading efficiency and user experience.
- Refactored History page to pass chart payloads, enhancing the visualization of nutrition trends without additional requests.
2026-04-20 14:51:27 +02:00

1553 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 R1R5 (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 C1C4 (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)"
}
]
}