2026-04-20 08:11:23 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Layer 2b: Recovery/Erholung — Bundle für Verlauf unter Fitness (Issue 53).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor
|
|
|
|
|
|
from data_layer.recovery_chart_payloads import (
|
|
|
|
|
|
build_hrv_rhr_baseline_chart_payload,
|
|
|
|
|
|
build_recovery_score_chart_payload,
|
|
|
|
|
|
build_sleep_debt_chart_payload,
|
|
|
|
|
|
build_sleep_duration_quality_chart_payload,
|
|
|
|
|
|
build_vital_signs_matrix_chart_payload,
|
|
|
|
|
|
)
|
2026-04-20 09:36:10 +02:00
|
|
|
|
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
|
2026-04-20 08:11:23 +02:00
|
|
|
|
from data_layer.recovery_interpretation import (
|
|
|
|
|
|
build_recovery_dashboard_kpi_tiles,
|
|
|
|
|
|
build_recovery_progress_insights,
|
|
|
|
|
|
)
|
|
|
|
|
|
from data_layer.recovery_metrics import (
|
|
|
|
|
|
calculate_hrv_vs_baseline_pct,
|
|
|
|
|
|
calculate_recovery_score_v2,
|
|
|
|
|
|
calculate_rhr_vs_baseline_pct,
|
|
|
|
|
|
calculate_sleep_debt_hours,
|
|
|
|
|
|
get_sleep_duration_data,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_recovery_sources(profile_id: str) -> bool:
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT 1 FROM sleep_log WHERE profile_id=%s LIMIT 1", (profile_id,))
|
|
|
|
|
|
if cur.fetchone():
|
|
|
|
|
|
return True
|
|
|
|
|
|
cur.execute("SELECT 1 FROM vitals_baseline WHERE profile_id=%s LIMIT 1", (profile_id,))
|
|
|
|
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Ein Request: KPIs, Insights, Charts R1–R5 (Chart.js-kompatibel).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not _has_recovery_sources(profile_id):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"confidence": "insufficient",
|
|
|
|
|
|
"has_recovery_data": False,
|
|
|
|
|
|
"message": "Noch keine Schlaf- oder Vitaldaten",
|
|
|
|
|
|
"kpi_tiles": [],
|
|
|
|
|
|
"progress_insights": [],
|
|
|
|
|
|
"charts": {},
|
|
|
|
|
|
"meta": {"layer_1": "recovery_metrics", "layer_2b": "recovery_viz"},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
all_history = days >= 9999
|
|
|
|
|
|
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
|
|
|
|
chart_days = min(90, max(7, min(eff_days, 365)))
|
2026-04-20 08:36:45 +02:00
|
|
|
|
# Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“
|
|
|
|
|
|
vital_days = min(365, max(30, min(eff_days, 365)))
|
2026-04-20 08:11:23 +02:00
|
|
|
|
|
|
|
|
|
|
recovery_score_val = calculate_recovery_score_v2(profile_id)
|
|
|
|
|
|
sleep_debt = calculate_sleep_debt_hours(profile_id)
|
|
|
|
|
|
dur = get_sleep_duration_data(profile_id, chart_days)
|
|
|
|
|
|
avg_sleep = None
|
|
|
|
|
|
if dur.get("confidence") != "insufficient":
|
|
|
|
|
|
avg_sleep = float(dur.get("avg_duration_hours") or 0) or None
|
|
|
|
|
|
|
|
|
|
|
|
hrv_dev = calculate_hrv_vs_baseline_pct(profile_id)
|
|
|
|
|
|
rhr_dev = calculate_rhr_vs_baseline_pct(profile_id)
|
|
|
|
|
|
|
|
|
|
|
|
kpi_tiles = build_recovery_dashboard_kpi_tiles(
|
|
|
|
|
|
recovery_score_val,
|
|
|
|
|
|
float(sleep_debt) if sleep_debt is not None else None,
|
|
|
|
|
|
avg_sleep,
|
|
|
|
|
|
float(hrv_dev) if hrv_dev is not None else None,
|
|
|
|
|
|
float(rhr_dev) if rhr_dev is not None else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
insights = build_recovery_progress_insights(
|
|
|
|
|
|
recovery_score_val,
|
|
|
|
|
|
float(sleep_debt) if sleep_debt is not None else None,
|
|
|
|
|
|
float(hrv_dev) if hrv_dev is not None else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
charts = {
|
|
|
|
|
|
"recovery_score": build_recovery_score_chart_payload(profile_id, chart_days),
|
|
|
|
|
|
"hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days),
|
|
|
|
|
|
"sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
|
|
|
|
|
|
"sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
|
|
|
|
|
|
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
|
2026-04-20 09:36:10 +02:00
|
|
|
|
"vitals_history": build_vitals_history_and_analytics(profile_id, vital_days),
|
2026-04-20 08:11:23 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conf = "medium"
|
|
|
|
|
|
if recovery_score_val is None and sleep_debt is None:
|
|
|
|
|
|
conf = "low"
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"confidence": conf,
|
|
|
|
|
|
"has_recovery_data": True,
|
|
|
|
|
|
"days_requested": days,
|
|
|
|
|
|
"effective_window_days": eff_days,
|
|
|
|
|
|
"chart_days_used": chart_days,
|
|
|
|
|
|
"vital_matrix_days_used": vital_days,
|
|
|
|
|
|
"kpi_tiles": kpi_tiles,
|
|
|
|
|
|
"progress_insights": insights,
|
|
|
|
|
|
"charts": charts,
|
|
|
|
|
|
"meta": {
|
|
|
|
|
|
"layer_1": "recovery_metrics",
|
|
|
|
|
|
"layer_2b": "recovery_viz",
|
|
|
|
|
|
"issue": "53-layer-2b-recovery",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|