mitai-jinkendo/backend/routers/charts.py

1340 lines
41 KiB
Python
Raw Permalink Normal View History

"""
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.correlation_chart_payloads import (
build_lbm_protein_correlation_chart_payload,
build_load_vitals_correlation_chart_payload,
build_recovery_performance_chart_payload,
build_weight_energy_correlation_chart_payload,
)
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) und Chart.js-Payloads C1–C4 (chart_payloads, wie /charts/*).
"""
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']
return build_weight_energy_correlation_chart_payload(profile_id, max_lag)
@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']
return build_lbm_protein_correlation_chart_payload(profile_id, max_lag)
@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']
return build_load_vitals_correlation_chart_payload(profile_id, max_lag)
@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']
return build_recovery_performance_chart_payload(profile_id)
# ── 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)"
}
]
}