diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index b0f8be6..558fb0a 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -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" diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py new file mode 100644 index 0000000..d75c1c8 --- /dev/null +++ b/backend/data_layer/vitals_fitness_insights.py @@ -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, + }, + } diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index 633f3e3..bc02dd8 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -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
Keine Verlaufs-Daten (Bundle).
+ } + if (vh.metadata?.confidence === 'insufficient') { + return ( +
+ {vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'} +
+ ) + } + 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 ( +
+ {bullets.length > 0 ? ( +
+
+ Einordnung (Vital & Belastung) +
+
+ {bullets.map((b) => ( +
+
{b.title}
+
{b.body}
+
+ ))} +
+ {corrNote != null && pairsN != null ? ( +
+ Korrelation Trainingsminuten (Tag) ↔ Ruhepuls (Folgetag): r ≈ {corrNote} (n = {pairsN} Paare) +
+ ) : null} +
+ ) : null} + +
+ Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 2–3 Messpunkten. +
+ + {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 ( +
+
+ {m.label_de} ({m.unit}) + {m.n != null ? ( + · n = {m.n} + ) : null} + {m.mean != null ? ( + · Ø {m.mean} + ) : null} +
+ {pts.length === 1 ? ( +
+ Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen. +
+ ) : ( +
+ + + + + + + + + +
+ )} +
+ ) + })} + + {keys.length === 0 ? ( +
Keine Vital-Zeitreihen im Fenster.
+ ) : null} +
+ ) + } + const renderVitalSigns = () => { if (!vitalsData) { return (
- Keine Vital-Matrix-Daten + Keine Snapshot-Daten zur Vital-Matrix.
) } 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 (
- {meta.message || 'Keine aktuellen Vitalwerte'} -
- ) - } - - 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 ( -
- Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten). + {meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
) } @@ -424,54 +501,19 @@ export default function RecoveryDashboardOverview({ ) : null} - {items.length === 0 && chartRows.length > 0 ? ( -
- Diagramm aus Server-Daten (ohne Zonen-Detail — bitte App aktualisieren oder Cache leeren). -
- ) : null} - - {chartRows.length > 0 ? ( - <> -
- Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße) -
- - - - - - [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']} - /> - - {chartRows.map((row, i) => ( - - ))} - - - - - ) : null} -
{vitDate ? ( <> - Baseline-Vitals Stand: {fmtDate(vitDate)} + Baseline-Vitals (Snapshot): {fmtDate(vitDate)} ) : null} {vitDate && bpDate ? ' · ' : null} {bpDate ? ( <> - Blutdruck Stand: {fmtDate(bpDate)} + Blutdruck: {fmtDate(bpDate)} ) : null} - {!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage : null} + {!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage : null}
{disclaimer ? (
{disclaimer}
@@ -538,9 +580,10 @@ export default function RecoveryDashboardOverview({ {renderRecoveryScore()} {renderHrvRhr()} + {renderVitalsHistory()} {renderSleepQuality()} {renderSleepDebt()} - {renderVitalSigns()} + {renderVitalSigns()} ) }