diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index cc83848..6959d7a 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -11,12 +11,15 @@ from typing import Any, Dict, Optional, Set from db import get_db, get_cursor from data_layer.recovery_metrics import ( + SLEEP_DEBT_ROLLING_WINDOW_DAYS, + SLEEP_DEBT_TARGET_HOURS_DEFAULT, calculate_hrv_vs_baseline_pct, calculate_recovery_score_v2, calculate_rhr_vs_baseline_pct, calculate_sleep_debt_hours, get_sleep_duration_data, get_sleep_quality_data, + sleep_debt_sum_hours_in_window, ) from data_layer.utils import calculate_confidence, safe_float, serialize_dates from data_layer.vital_signs_assessment import build_vital_items_from_rows @@ -86,7 +89,7 @@ def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, "labels": labels, "datasets": [ { - "label": "Recovery Score (proxy)", + "label": "HRV (ms, auf 0–100 begrenzt) — nicht der KPI Recovery-Score", "data": values, "borderColor": "#1D9E75", "backgroundColor": "rgba(29, 158, 117, 0.1)", @@ -101,7 +104,10 @@ def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, "confidence": calculate_confidence(len(rows), days, "general"), "data_points": len(rows), "current_score": current_score, - "note": "Score based on HRV proxy; true recovery score calculation in development", + "chart_series_kind": "hrv_ms_clamped", + "kpi_score_source": "calculate_recovery_score_v2", + "note": "Kurve = HRV-Rohwert (ms) begrenzt auf 0–100, nur Verlaufsorientierung. " + "KPI-Kachel «Recovery-Score» = gewichteter Score (HRV, RHR, Schlaf, …).", } ), } @@ -293,7 +299,9 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] }, } - cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + chart_cutoff = (datetime.now() - timedelta(days=days)).date() + # Historie vor dem Chart-Fenster, damit das rollierende 14-Tage-Fenster früh korrekt gefüllt ist + ext_cutoff = (datetime.now() - timedelta(days=days + SLEEP_DEBT_ROLLING_WINDOW_DAYS + 3)).strftime("%Y-%m-%d") with get_db() as conn: cur = get_cursor(conn) @@ -301,12 +309,20 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] """SELECT date, duration_minutes FROM sleep_log WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff), + AND duration_minutes IS NOT NULL + ORDER BY date ASC""", + (profile_id, ext_cutoff), ) - rows = cur.fetchall() + all_rows = [dict(r) for r in cur.fetchall()] - if not rows: + visible = [] + for r in all_rows: + rd = r.get("date") + d = rd.date() if isinstance(rd, datetime) else rd + if d >= chart_cutoff: + visible.append(r) + + if not visible: return { "chart_type": "line", "data": {"labels": [], "datasets": []}, @@ -317,17 +333,13 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] }, } - labels = [row["date"].isoformat() for row in rows] - - target_hours = 8.0 - cumulative_debt = 0.0 + labels = [] debt_values = [] - - for row in rows: - actual_hours = safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else 0 - daily_deficit = target_hours - actual_hours - cumulative_debt += daily_deficit - debt_values.append(cumulative_debt) + for r in visible: + rd = r.get("date") + end_d = rd.date() if isinstance(rd, datetime) else rd + labels.append(end_d.isoformat() if hasattr(end_d, "isoformat") else str(end_d)) + debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d)) return { "chart_type": "line", @@ -335,7 +347,7 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] "labels": labels, "datasets": [ { - "label": "Schlafschuld (Stunden)", + "label": f"Schlafschuld (h), rollierend {SLEEP_DEBT_ROLLING_WINDOW_DAYS} Tage — wie KPI", "data": debt_values, "borderColor": "#EF4444", "backgroundColor": "rgba(239, 68, 68, 0.1)", @@ -347,10 +359,14 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] }, "metadata": serialize_dates( { - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), + "confidence": calculate_confidence(len(visible), days, "general"), + "data_points": len(visible), "current_debt_hours": round(float(current_debt), 1), - "final_debt_hours": round(float(cumulative_debt), 1), + "sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT, + "rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS, + "note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. " + f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster " + "(jeder Punkt = Fensterende an dem Datum). Ziel aktuell nicht in den Profileinstellungen änderbar.", } ), } diff --git a/backend/data_layer/recovery_metrics.py b/backend/data_layer/recovery_metrics.py index 9b03e9b..4bab2c1 100644 --- a/backend/data_layer/recovery_metrics.py +++ b/backend/data_layer/recovery_metrics.py @@ -21,6 +21,11 @@ from datetime import datetime, timedelta, date from db import get_db, get_cursor from data_layer.utils import calculate_confidence, safe_float, safe_int +# ── Schlafschuld (KPI + Charts): eine Zielschlafdauer, bis ein Profil-Feld existiert +SLEEP_DEBT_TARGET_HOURS_DEFAULT = 7.5 +SLEEP_DEBT_ROLLING_WINDOW_DAYS = 14 +SLEEP_DEBT_MIN_NIGHTS_FOR_KPI = 10 + def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]: """JSONB kann dict/list/str sein; ungültig → None.""" @@ -744,34 +749,70 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]: return round(avg_hours, 1) +def _row_date_as_date(d: Any) -> Optional[date]: + if d is None: + return None + if isinstance(d, datetime): + return d.date() + if isinstance(d, date): + return d + return None + + +def sleep_debt_sum_hours_in_window( + night_rows: List[Dict[str, Any]], + window_end: date, + *, + target_hours: float = SLEEP_DEBT_TARGET_HOURS_DEFAULT, + window_days: int = SLEEP_DEBT_ROLLING_WINDOW_DAYS, + min_nights: int = SLEEP_DEBT_MIN_NIGHTS_FOR_KPI, +) -> Optional[float]: + """ + Summe der nächtlichen Defizite (nur Unter-Ziel, kein „Überschuss-Guthaben“) im Fenster + (window_end − window_days … window_end], Kalendertage). + Gleiche Logik wie KPI calculate_sleep_debt_hours für window_end = heute. + """ + start = window_end - timedelta(days=window_days) + tmin = target_hours * 60.0 + total_min = 0.0 + nights = 0 + for row in night_rows: + rd = _row_date_as_date(row.get("date")) + if rd is None or rd < start or rd > window_end: + continue + dm = row.get("duration_minutes") + if dm is None: + continue + nights += 1 + total_min += max(0.0, tmin - float(dm)) + if nights < min_nights: + return None + return round(total_min / 60.0, 1) + + def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]: """ - Calculate accumulated sleep debt (hours) last 14 days - Assumes 7.5h target per night + Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute — + Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar). """ - target_hours = 7.5 - + today = datetime.now().date() with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" - SELECT duration_minutes + cur.execute( + """ + SELECT date, duration_minutes FROM sleep_log WHERE profile_id = %s - AND date >= CURRENT_DATE - INTERVAL '14 days' + AND date >= %s::date - INTERVAL '14 days' + AND date <= %s::date AND duration_minutes IS NOT NULL ORDER BY date DESC - """, (profile_id,)) + """, + (profile_id, today, today), + ) + rows = [dict(r) for r in cur.fetchall()] - sleep_data = [row['duration_minutes'] for row in cur.fetchall()] - - if len(sleep_data) < 10: # Need at least 10 days - return None - - # Calculate cumulative debt - total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data) - debt_hours = total_debt_min / 60 - - return round(debt_hours, 1) + return sleep_debt_sum_hours_in_window(rows, today) def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]: diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index a45c3d8..f826030 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -331,11 +331,19 @@ export default function RecoveryDashboardOverview({ fontSize: 11, }} /> - + -
- Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge +
+ KPI Recovery-Score (aktuell): {recoveryData.metadata.current_score}/100 · Datenpunkte Kurve:{' '} + {recoveryData.metadata.data_points}
) @@ -479,7 +487,15 @@ export default function RecoveryDashboardOverview({ fontSize: 11, }} /> - +
@@ -680,8 +696,11 @@ export default function RecoveryDashboardOverview({ hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben." /> {renderRecoveryScore()} @@ -695,7 +714,14 @@ export default function RecoveryDashboardOverview({ > {renderSleepQuality()} - + {renderSleepDebt()}