feat: add vitals history analytics to recovery dashboard
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Integrated the `build_vitals_history_and_analytics` function into the recovery dashboard to provide historical insights on vital signs.
- Updated the `get_recovery_dashboard_viz_bundle` function to include a new chart for vitals history, enhancing the data visualization capabilities.
- Enhanced the `RecoveryDashboardOverview` component to render vitals history, including improved messaging for insufficient data and visual representation of trends.
This commit is contained in:
Lars 2026-04-20 09:36:10 +02:00
parent 819914b7cc
commit e7bcdc3228
3 changed files with 422 additions and 97 deletions

View File

@ -14,6 +14,7 @@ from data_layer.recovery_chart_payloads import (
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,
@ -88,6 +89,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
"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"

View File

@ -0,0 +1,280 @@
"""
Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53).
Keine Diagnose deskriptive Trends, Korrelationen und Varianz-Hinweise.
"""
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
from db import get_db, get_cursor
from data_layer.utils import safe_float, serialize_dates
SERIES_CONFIG = (
("resting_hr", "Ruhepuls", "bpm", "#3B82F6"),
("hrv", "HRV", "ms", "#1D9E75"),
("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"),
("spo2", "SpO2", "%", "#0EA5E9"),
("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"),
)
def _date_to_ord(d: Any) -> float:
if hasattr(d, "toordinal"):
return float(d.toordinal())
if isinstance(d, str):
return float(datetime.fromisoformat(d[:10]).date().toordinal())
return 0.0
def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float:
if len(values) < 3 or len(dates) != len(values):
return 0.0
xs = [_date_to_ord(d) for d in dates]
ys = list(values)
n = len(xs)
mx = sum(xs) / n
my = sum(ys) / n
den = sum((x - mx) ** 2 for x in xs)
if den < 1e-9:
return 0.0
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]:
n = len(xs)
if n < 5 or len(ys) != n:
return None
mx = statistics.mean(xs)
my = statistics.mean(ys)
sx = statistics.pstdev(xs) if n > 1 else 0.0
sy = statistics.pstdev(ys) if n > 1 else 0.0
if sx < 1e-9 or sy < 1e-9:
return None
cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n
return cov / (sx * sy)
def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
"""Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy."""
cur.execute(
"""
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
FROM activity_log
WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0
GROUP BY date
ORDER BY date
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
return {r["d"]: float(r["minutes"]) for r in rows}
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
cur.execute(
"""
SELECT date::text AS d, resting_hr::float AS rhr
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL
ORDER BY date
""",
(profile_id, cutoff),
)
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, Any]:
"""
Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + Kurz-Analytik.
"""
if days < 7:
days = 7
if days > 365:
days = 365
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
series: Dict[str, Any] = {}
for key, label_de, unit, color in SERIES_CONFIG:
pts: List[Dict[str, Any]] = []
dates: List[Any] = []
vals: List[float] = []
for r in rows:
v = r.get(key)
if v is None:
continue
fv = safe_float(v)
d = r["date"]
d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10]
pts.append({"date": d_iso, "value": round(fv, 2)})
dates.append(d)
vals.append(fv)
if pts:
series[key] = {
"key": key,
"label_de": label_de,
"unit": unit,
"color": color,
"points": pts,
"n": len(pts),
"last": vals[-1] if vals else None,
"mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None,
"stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None,
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
}
bullets: List[Dict[str, Any]] = []
# VO2max-Trend
vo2 = series.get("vo2_max")
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
s = vo2["slope_per_day"]
if s > 0.002:
bullets.append(
{
"key": "vo2_trend_up",
"tone": "good",
"title": "VO2max-Verlauf",
"body": "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder besserer Datenlage vereinbar.",
}
)
elif s < -0.002:
bullets.append(
{
"key": "vo2_trend_down",
"tone": "warn",
"title": "VO2max-Verlauf",
"body": "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen entstehen; Verlauf beobachten.",
}
)
# Ruhepuls: letzte 7 vs davor (wenn genug Punkte)
rhr = series.get("resting_hr")
if rhr and rhr.get("points"):
pts = rhr["points"]
if len(pts) >= 10:
last7 = [p["value"] for p in pts[-7:]]
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
if before:
m7 = statistics.mean(last7)
mb = statistics.mean(before)
diff = m7 - mb
if diff > 3:
bullets.append(
{
"key": "rhr_short_high",
"tone": "warn",
"title": "Ruhepuls zuletzt höher",
"body": f"Die letzten 7 Messungen liegen im Mittel ca. {diff:.1f} bpm über dem vorangehenden Fenster — kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen.",
}
)
elif diff < -3:
bullets.append(
{
"key": "rhr_short_low",
"tone": "good",
"title": "Ruhepuls zuletzt niedriger",
"body": "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder besserer Regeneration vereinbar (individuell).",
}
)
if rhr.get("stdev") is not None and rhr["n"] >= 6:
bullets.append(
{
"key": "rhr_var",
"tone": "neutral",
"title": "Schwankung Ruhepuls",
"body": f"Standardabweichung im Fenster ca. {rhr['stdev']} bpm — kurzfristige Schwankungen sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten.",
}
)
# HRV: Varianz-Hinweis
hrv_s = series.get("hrv")
if hrv_s and hrv_s.get("stdev") and hrv_s["n"] >= 6:
bullets.append(
{
"key": "hrv_var",
"tone": "neutral",
"title": "HRV-Schwankung",
"body": f"HRV schwankt im Fenster (σ{hrv_s['stdev']} ms). Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte.",
}
)
# Belastung (Activity) vs Ruhepuls am Folgetag
with get_db() as conn:
cur = get_cursor(conn)
load_by_d = _daily_training_load(cur, profile_id, cutoff)
rhr_by_d = _rhr_by_date(cur, profile_id, cutoff)
pairs_load: List[float] = []
pairs_rhr: List[float] = []
for d_str, load_min in load_by_d.items():
try:
d0 = datetime.fromisoformat(d_str[:10]).date()
except ValueError:
continue
d1 = (d0 + timedelta(days=1)).isoformat()
if d1 in rhr_by_d and load_min > 0:
pairs_load.append(load_min)
pairs_rhr.append(rhr_by_d[d1])
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
if r_pearson is not None:
if r_pearson > 0.35:
bullets.append(
{
"key": "load_rhr_pos",
"tone": "warn",
"title": "Belastung und Ruhepuls (Folgetag)",
"body": "An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis).",
}
)
elif r_pearson < -0.25:
bullets.append(
{
"key": "load_rhr_neg",
"tone": "neutral",
"title": "Belastung und Ruhepuls",
"body": "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem Fenster — stark von Datenlage und Ausreißern abhängig.",
}
)
if not series:
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": {},
"analytics": {"bullets": []},
"metadata": {
"confidence": "insufficient",
"message": "Keine Vital-Zeitreihen im Fenster",
},
}
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": serialize_dates(series),
"analytics": {"bullets": bullets},
"metadata": {
"confidence": "medium",
"note": "Deskriptive Auswertung; keine medizinische Diagnose.",
"load_rhr_pairs_n": len(pairs_load),
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
},
}

View File

@ -1,17 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
LineChart,
Line,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from 'recharts'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor, getStatusBg } from '../utils/interpret'
@ -19,19 +8,11 @@ import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.')
function vitalToneToUi(tone) {
if (tone === 'good') return 'good'
if (tone === 'bad') return 'bad'
if (tone === 'neutral') return 'neutral'
return 'warn'
}
function barFillForTone(tone) {
const ui = vitalToneToUi(tone)
if (ui === 'good') return '#1D9E75'
if (ui === 'bad') return '#D85A30'
if (ui === 'neutral') return '#6B7280'
return '#EF9F27'
function insightBulletStripe(tone) {
if (tone === 'good') return getStatusColor('good')
if (tone === 'bad') return getStatusColor('bad')
if (tone === 'neutral') return '#6B7280'
return getStatusColor('warn')
}
function ChartCard({ title, loading, error, children }) {
@ -127,6 +108,7 @@ export default function RecoveryDashboardOverview({
const sleepData = viz.charts?.sleep_duration_quality
const debtData = viz.charts?.sleep_debt
const vitalsData = viz.charts?.vital_signs_matrix
const vitalsHistory = viz.charts?.vitals_history
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...t,
@ -315,51 +297,146 @@ export default function RecoveryDashboardOverview({
)
}
const renderVitalsHistory = () => {
const vh = vitalsHistory
if (!vh) {
return <div style={{ padding: 16, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
}
if (vh.metadata?.confidence === 'insufficient') {
return (
<div style={{ padding: 16, fontSize: 12, color: 'var(--text3)', lineHeight: 1.5 }}>
{vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
</div>
)
}
const series = vh.series || {}
const keys = Object.keys(series)
const bullets = vh.analytics?.bullets || []
const corrNote = vh.metadata?.load_rhr_correlation
const pairsN = vh.metadata?.load_rhr_pairs_n
return (
<div style={{ width: '100%', minWidth: 0 }}>
{bullets.length > 0 ? (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Einordnung (Vital & Belastung)
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{bullets.map((b) => (
<div
key={b.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${insightBulletStripe(b.tone)}`,
background: 'var(--surface2)',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{b.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{b.body}</div>
</div>
))}
</div>
{corrNote != null && pairsN != null ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)' }}>
Korrelation Trainingsminuten (Tag) Ruhepuls (Folgetag): r {corrNote} (n = {pairsN} Paare)
</div>
) : null}
</div>
) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 23 Messpunkten.
</div>
{keys.map((k) => {
const m = series[k]
const pts = m.points || []
if (pts.length === 0) return null
const chartData = pts.map((p) => ({
...p,
d: fmtDate(p.date),
}))
const vals = pts.map((p) => p.value)
const mn = Math.min(...vals)
const mx = Math.max(...vals)
const span = mx - mn
const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : span * 0.12
return (
<div key={k} style={{ marginBottom: 16, width: '100%' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text2)', marginBottom: 4 }}>
{m.label_de} ({m.unit})
{m.n != null ? (
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · n = {m.n}</span>
) : null}
{m.mean != null ? (
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · Ø {m.mean}</span>
) : null}
</div>
{pts.length === 1 ? (
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>
Ein Messpunkt ({m.last}) weiter erfassen, um einen Verlauf zu sehen.
</div>
) : (
<div style={{ width: '100%', height: 200, minHeight: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="d" tick={{ fontSize: 9, fill: 'var(--text3)' }} interval="preserveStartEnd" />
<YAxis
domain={[mn - pad, mx + pad]}
tick={{ fontSize: 9, fill: 'var(--text3)' }}
width={44}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={m.color || '#1D9E75'}
strokeWidth={2}
dot={{ r: 3 }}
name={m.label_de}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
)
})}
{keys.length === 0 ? (
<div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Vital-Zeitreihen im Fenster.</div>
) : null}
</div>
)
}
const renderVitalSigns = () => {
if (!vitalsData) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vital-Matrix-Daten
Keine Snapshot-Daten zur Vital-Matrix.
</div>
)
}
const meta = vitalsData.metadata || {}
const items = meta.vital_items || []
const ds0 = vitalsData.data?.datasets?.[0]
const hasRawChart =
Array.isArray(vitalsData.data?.labels) &&
vitalsData.data.labels.length > 0 &&
Array.isArray(ds0?.data) &&
ds0.data.length > 0
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0 && !hasRawChart) {
if (ins && items.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{meta.message || 'Keine aktuellen Vitalwerte'}
</div>
)
}
let chartRows = items.map((it) => ({
name: it.label_de,
value: Number(it.bar_value ?? 0),
fill: barFillForTone(it.tone),
tone: it.tone,
}))
if (chartRows.length === 0 && hasRawChart) {
const bg = ds0.backgroundColor
chartRows = vitalsData.data.labels.map((name, i) => ({
name,
value: Number(ds0.data[i] ?? 0),
fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75',
tone: 'neutral',
}))
}
if (items.length === 0 && chartRows.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
</div>
)
}
@ -424,54 +501,19 @@ export default function RecoveryDashboardOverview({
</div>
) : null}
{items.length === 0 && chartRows.length > 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Diagramm aus Server-Daten (ohne Zonen-Detail bitte App aktualisieren oder Cache leeren).
</div>
) : null}
{chartRows.length > 0 ? (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
Relative Einordnung (0100, nur Übersicht keine körperliche Messgröße)
</div>
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
/>
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
{chartRows.map((row, i) => (
<Cell key={`c-${i}`} fill={row.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</>
) : null}
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
{vitDate ? (
<>
Baseline-Vitals Stand: <strong>{fmtDate(vitDate)}</strong>
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
</>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
</>
) : null}
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage</> : null}
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
</div>
{disclaimer ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
@ -538,9 +580,10 @@ export default function RecoveryDashboardOverview({
<ChartCard title="📊 Recovery Score">{renderRecoveryScore()}</ChartCard>
<ChartCard title="📊 HRV & Ruhepuls">{renderHrvRhr()}</ChartCard>
<ChartCard title="📈 Vitalwerte — Verlauf (je Kennzahl)">{renderVitalsHistory()}</ChartCard>
<ChartCard title="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard>
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard>
<ChartCard title="📊 Vitalwerte Überblick">{renderVitalSigns()}</ChartCard>
<ChartCard title="📋 Vitalwerte — aktuelle Einordnung">{renderVitalSigns()}</ChartCard>
</div>
)
}