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,
}}
/>
-