- Introduced the `history_overview_viz` widget to the dashboard, allowing users to visualize consolidated history data across various metrics. - Updated widget configuration to include `history_overview_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `history_overview_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `history_overview_viz` widget configuration. - Bumped application version to reflect the addition of the new widget.
1340 lines
41 KiB
Python
1340 lines
41 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.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)"
|
||
}
|
||
]
|
||
}
|