Merge pull request 'feat: enhance recovery metrics and dashboard with sleep debt calculations and improved visualizations' (#98) from develop into main
Reviewed-on: #98
This commit is contained in:
commit
d66eadf88f
|
|
@ -11,12 +11,15 @@ from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from data_layer.recovery_metrics import (
|
from data_layer.recovery_metrics import (
|
||||||
|
SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
||||||
|
SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
||||||
calculate_hrv_vs_baseline_pct,
|
calculate_hrv_vs_baseline_pct,
|
||||||
calculate_recovery_score_v2,
|
calculate_recovery_score_v2,
|
||||||
calculate_rhr_vs_baseline_pct,
|
calculate_rhr_vs_baseline_pct,
|
||||||
calculate_sleep_debt_hours,
|
calculate_sleep_debt_hours,
|
||||||
get_sleep_duration_data,
|
get_sleep_duration_data,
|
||||||
get_sleep_quality_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.utils import calculate_confidence, safe_float, serialize_dates
|
||||||
from data_layer.vital_signs_assessment import build_vital_items_from_rows
|
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,
|
"labels": labels,
|
||||||
"datasets": [
|
"datasets": [
|
||||||
{
|
{
|
||||||
"label": "Recovery Score (proxy)",
|
"label": "HRV (ms, auf 0–100 begrenzt) — nicht der KPI Recovery-Score",
|
||||||
"data": values,
|
"data": values,
|
||||||
"borderColor": "#1D9E75",
|
"borderColor": "#1D9E75",
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
"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"),
|
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||||
"data_points": len(rows),
|
"data_points": len(rows),
|
||||||
"current_score": current_score,
|
"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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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
|
"""SELECT date, duration_minutes
|
||||||
FROM sleep_log
|
FROM sleep_log
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s AND date >= %s
|
||||||
ORDER BY date""",
|
AND duration_minutes IS NOT NULL
|
||||||
(profile_id, cutoff),
|
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 {
|
return {
|
||||||
"chart_type": "line",
|
"chart_type": "line",
|
||||||
"data": {"labels": [], "datasets": []},
|
"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]
|
labels = []
|
||||||
|
|
||||||
target_hours = 8.0
|
|
||||||
cumulative_debt = 0.0
|
|
||||||
debt_values = []
|
debt_values = []
|
||||||
|
for r in visible:
|
||||||
for row in rows:
|
rd = r.get("date")
|
||||||
actual_hours = safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else 0
|
end_d = rd.date() if isinstance(rd, datetime) else rd
|
||||||
daily_deficit = target_hours - actual_hours
|
labels.append(end_d.isoformat() if hasattr(end_d, "isoformat") else str(end_d))
|
||||||
cumulative_debt += daily_deficit
|
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
|
||||||
debt_values.append(cumulative_debt)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"chart_type": "line",
|
"chart_type": "line",
|
||||||
|
|
@ -335,7 +347,7 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"datasets": [
|
"datasets": [
|
||||||
{
|
{
|
||||||
"label": "Schlafschuld (Stunden)",
|
"label": f"Schlafschuld (h), rollierend {SLEEP_DEBT_ROLLING_WINDOW_DAYS} Tage — wie KPI",
|
||||||
"data": debt_values,
|
"data": debt_values,
|
||||||
"borderColor": "#EF4444",
|
"borderColor": "#EF4444",
|
||||||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
"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(
|
"metadata": serialize_dates(
|
||||||
{
|
{
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
"confidence": calculate_confidence(len(visible), days, "general"),
|
||||||
"data_points": len(rows),
|
"data_points": len(visible),
|
||||||
"current_debt_hours": round(float(current_debt), 1),
|
"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.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
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]]:
|
def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
|
||||||
"""JSONB kann dict/list/str sein; ungültig → None."""
|
"""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)
|
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]:
|
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Calculate accumulated sleep debt (hours) last 14 days
|
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
|
||||||
Assumes 7.5h target per night
|
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
|
||||||
"""
|
"""
|
||||||
target_hours = 7.5
|
today = datetime.now().date()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
SELECT duration_minutes
|
"""
|
||||||
|
SELECT date, duration_minutes
|
||||||
FROM sleep_log
|
FROM sleep_log
|
||||||
WHERE profile_id = %s
|
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
|
AND duration_minutes IS NOT NULL
|
||||||
ORDER BY date DESC
|
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()]
|
return sleep_debt_sum_hours_in_window(rows, today)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
||||||
|
|
|
||||||
|
|
@ -331,11 +331,19 @@ export default function RecoveryDashboardOverview({
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="score" stroke="#1D9E75" strokeWidth={2} name="Recovery Score" dot={{ r: 2 }} />
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="score"
|
||||||
|
stroke="#1D9E75"
|
||||||
|
strokeWidth={2}
|
||||||
|
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}>
|
||||||
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
|
KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '}
|
||||||
|
{recoveryData.metadata.data_points}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -479,7 +487,15 @@ export default function RecoveryDashboardOverview({
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="debt" stroke="#EF4444" strokeWidth={2} name="Schlafschuld (h)" dot={{ r: 2 }} />
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="debt"
|
||||||
|
stroke="#EF4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||||
|
|
@ -680,8 +696,11 @@ export default function RecoveryDashboardOverview({
|
||||||
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
|
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
|
||||||
/>
|
/>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Recovery Score"
|
title="HRV-Verlauf (kein Recovery-Score)"
|
||||||
description="0–100, Verlauf im Chart-Fenster. Höher ist in der Regel günstiger."
|
description={
|
||||||
|
'Kurve = HRV-Rohwert (ms), auf 0–100 begrenzt — nur zur Einordnung des Verlaufs. ' +
|
||||||
|
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{renderRecoveryScore()}
|
{renderRecoveryScore()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
@ -695,7 +714,14 @@ export default function RecoveryDashboardOverview({
|
||||||
>
|
>
|
||||||
{renderSleepQuality()}
|
{renderSleepQuality()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
|
<ChartCard
|
||||||
|
title="Schlafschuld"
|
||||||
|
description={
|
||||||
|
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' +
|
||||||
|
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' +
|
||||||
|
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.'
|
||||||
|
}
|
||||||
|
>
|
||||||
{renderSleepDebt()}
|
{renderSleepDebt()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user