mitai-jinkendo/backend/data_layer/recovery_viz.py

115 lines
4.1 KiB
Python
Raw Normal View History

"""
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,
)
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
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)))
# Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“
vital_days = min(365, max(30, min(eff_days, 365)))
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),
"vitals_history": build_vitals_history_and_analytics(profile_id, vital_days),
}
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",
},
}