From e7bcdc3228ae39df6c71ec808ce5e5a6be695708 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 09:36:10 +0200 Subject: [PATCH 1/5] feat: add vitals history analytics to recovery dashboard - 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. --- backend/data_layer/recovery_viz.py | 2 + backend/data_layer/vitals_fitness_insights.py | 280 ++++++++++++++++++ .../components/RecoveryDashboardOverview.jsx | 237 +++++++++------ 3 files changed, 422 insertions(+), 97 deletions(-) create mode 100644 backend/data_layer/vitals_fitness_insights.py 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()} ) } -- 2.43.0 From 8cb5ad992f54572ca09420e2c241281689cf3670 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 10:29:43 +0200 Subject: [PATCH 2/5] feat: enhance recovery dashboard with vital signs analytics and visualization improvements - Updated the `build_vital_signs_matrix_chart_payload` function to accept optional keys for omitting specific snapshot data, improving flexibility in data presentation. - Enhanced the `build_recovery_dashboard_kpi_tiles` function to conditionally merge heart and autonomic tiles based on new parameters, refining the dashboard's insights. - Integrated new analytics features in the `RecoveryDashboardOverview` component, including consolidated paragraphs for better narrative context and visual representation of trends. - Improved the handling of vital signs data in the frontend, ensuring clearer messaging and enhanced user experience when displaying vital metrics. --- backend/data_layer/recovery_chart_payloads.py | 19 +- backend/data_layer/recovery_interpretation.py | 111 +++++---- backend/data_layer/recovery_viz.py | 13 +- backend/data_layer/vital_signs_assessment.py | 9 +- backend/data_layer/vitals_fitness_insights.py | 221 ++++++++++-------- backend/routers/charts.py | 13 +- .../components/RecoveryDashboardOverview.jsx | 79 +++++-- 7 files changed, 299 insertions(+), 166 deletions(-) diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index 34d4f50..cc83848 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -7,7 +7,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1). from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Set from db import get_db, get_cursor from data_layer.recovery_metrics import ( @@ -396,8 +396,15 @@ def _tone_to_bar_value(tone: str) -> float: return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0) -def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: - """Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).""" +def build_vital_signs_matrix_chart_payload( + profile_id: str, + days: int, + omit_snapshot_keys: Optional[Set[str]] = None, +) -> Dict[str, Any]: + """Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1). + + omit_snapshot_keys: z. B. {'resting_hr','hrv'} wenn dieselbe Einordnung bereits im Vital-Verlauf steht. + """ if days < 7: days = 7 if days > 365: @@ -464,7 +471,11 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s if bp_row: bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} - items = build_vital_items_from_rows(vitals_for_items, bp_for_items) + items = build_vital_items_from_rows( + vitals_for_items, bp_for_items, omit_keys=omit_snapshot_keys + ) + if not items and vitals_for_items and omit_snapshot_keys: + items = build_vital_items_from_rows(vitals_for_items, bp_for_items, omit_keys=None) if not items: return { diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py index 8be9863..ae75071 100644 --- a/backend/data_layer/recovery_interpretation.py +++ b/backend/data_layer/recovery_interpretation.py @@ -41,6 +41,7 @@ def build_recovery_dashboard_kpi_tiles( avg_sleep_hours: Optional[float], hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], + merge_heart_autonomic_tiles: bool = True, ) -> List[Dict[str, Any]]: tiles: List[Dict[str, Any]] = [] @@ -93,46 +94,76 @@ def build_recovery_dashboard_kpi_tiles( } ) - h_s = ( - "good" - if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 - else "warn" - if hrv_vs_baseline_pct is not None - else "warn" - ) - tiles.append( - { - "key": "hrv_baseline", - "category": "HRV vs. Basis", - "icon": "〰️", - "value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + if merge_heart_autonomic_tiles and ( + hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None + ): + h_s = ( + "good" + if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 + else "warn" if hrv_vs_baseline_pct is not None - else "—", - "sublabel": "Letzte 3 Tage vs. ältere Basis", - "status": h_s, - "verdict": _verdict(h_s), - "hoverTop": "Abweichung HRV vom Referenzmittel", - "hoverBody": "calculate_hrv_vs_baseline_pct", - "keys": ["hrv_vs_baseline"], - } - ) + else "warn" + ) + parts: List[str] = [] + if hrv_vs_baseline_pct is not None: + parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ",")) + if rhr_vs_baseline_pct is not None: + parts.append(f"RHR {rhr_vs_baseline_pct:+.1f} %".replace(".", ",")) + tiles.append( + { + "key": "herz_autonom", + "category": "Herz & autonomes System", + "icon": "❤️‍🩹", + "value": " · ".join(parts) if parts else "—", + "sublabel": "HRV/Ruhepuls vs. Referenz (3-Tage-Mittel vs. ältere Basis)", + "status": h_s, + "verdict": _verdict(h_s), + "hoverTop": "HRV und Ruhepuls relativ zur persönlichen Basis", + "hoverBody": "calculate_hrv_vs_baseline_pct · calculate_rhr_vs_baseline_pct", + "keys": ["hrv_vs_baseline", "rhr_vs_baseline"], + } + ) + else: + h_s = ( + "good" + if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 + else "warn" + if hrv_vs_baseline_pct is not None + else "warn" + ) + tiles.append( + { + "key": "hrv_baseline", + "category": "HRV vs. Basis", + "icon": "〰️", + "value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + if hrv_vs_baseline_pct is not None + else "—", + "sublabel": "Letzte 3 Tage vs. ältere Basis", + "status": h_s, + "verdict": _verdict(h_s), + "hoverTop": "Abweichung HRV vom Referenzmittel", + "hoverBody": "calculate_hrv_vs_baseline_pct", + "keys": ["hrv_vs_baseline"], + } + ) - tiles.append( - { - "key": "rhr_baseline", - "category": "Ruhepuls vs. Basis", - "icon": "❤️", - "value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",") - if rhr_vs_baseline_pct is not None - else "—", - "sublabel": "Niedriger oft günstiger", - "status": "good", - "verdict": "Gut", - "hoverTop": "Abweichung Ruhepuls", - "hoverBody": "calculate_rhr_vs_baseline_pct", - "keys": ["rhr_vs_baseline"], - } - ) + tiles.append( + { + "key": "rhr_baseline", + "category": "Ruhepuls vs. Basis", + "icon": "❤️", + "value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + if rhr_vs_baseline_pct is not None + else "—", + "sublabel": "Niedriger oft günstiger", + "status": "good", + "verdict": "Gut", + "hoverTop": "Abweichung Ruhepuls", + "hoverBody": "calculate_rhr_vs_baseline_pct", + "keys": ["rhr_vs_baseline"], + } + ) return tiles @@ -141,7 +172,9 @@ def build_recovery_progress_insights( recovery_score: Optional[int], sleep_debt_hours: Optional[float], hrv_vs_baseline_pct: Optional[float], + include_autonomic_hrv_narrative: bool = False, ) -> List[Dict[str, Any]]: + """HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs).""" out: List[Dict[str, Any]] = [] if recovery_score is not None: @@ -168,7 +201,7 @@ def build_recovery_progress_insights( } ) - if hrv_vs_baseline_pct is not None: + if include_autonomic_hrv_narrative and hrv_vs_baseline_pct is not None: tone = "good" if hrv_vs_baseline_pct >= 0 else "warn" out.append( { diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 558fb0a..34a8850 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -83,13 +83,22 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A float(hrv_dev) if hrv_dev is not None else None, ) + hrv_f = float(hrv_dev) if hrv_dev is not None else None + rhr_f = float(rhr_dev) if rhr_dev is not None else None + charts = { "recovery_score": build_recovery_score_chart_payload(profile_id, chart_days), "hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days), "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), + "vital_signs_matrix": build_vital_signs_matrix_chart_payload( + profile_id, + vital_days, + omit_snapshot_keys={"resting_hr", "hrv"}, + ), + "vitals_history": build_vitals_history_and_analytics( + profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f + ), } conf = "medium" diff --git a/backend/data_layer/vital_signs_assessment.py b/backend/data_layer/vital_signs_assessment.py index 5339e4b..be9da0b 100644 --- a/backend/data_layer/vital_signs_assessment.py +++ b/backend/data_layer/vital_signs_assessment.py @@ -5,7 +5,7 @@ Keine Diagnose — typische Referenzbereiche für UI/Coaching. from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from data_layer.utils import safe_float @@ -104,20 +104,23 @@ def assess_vo2_max(value: float) -> tuple: def build_vital_items_from_rows( vitals_row: Optional[Dict[str, Any]], bp_row: Optional[Dict[str, Any]], + omit_keys: Optional[Set[str]] = None, ) -> List[Dict[str, Any]]: + """omit_keys: z. B. {'resting_hr','hrv'} wenn Einordnung zentral im Herz-/Autonomie-Block steht.""" + skip = omit_keys or set() items: List[Dict[str, Any]] = [] order = 0 if vitals_row: rhr = vitals_row.get("resting_hr") - if rhr is not None: + if rhr is not None and "resting_hr" not in skip: v = safe_float(rhr) t, z, h = assess_resting_hr(v) items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order)) order += 1 hrv = vitals_row.get("hrv") - if hrv is not None: + if hrv is not None and "hrv" not in skip: v = safe_float(hrv) t, z, h = assess_hrv_ms(v) items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order)) diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index d75c1c8..964a3af 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -8,7 +8,7 @@ from __future__ import annotations import statistics from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence from db import get_db, get_cursor from data_layer.utils import safe_float, serialize_dates @@ -74,6 +74,94 @@ def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, fl return {r["d"]: float(r["minutes"]) for r in rows} +def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]: + """Gleitender Mittelwert über die letzten bis zu `window` aufeinanderfolgenden Messungen (nicht Kalendertage).""" + out: List[float] = [] + for i in range(len(vals)): + chunk = vals[max(0, i - window + 1) : i + 1] + out.append(round(statistics.mean(chunk), 2)) + return out + + +def _build_consolidated_paragraphs( + series: Dict[str, Any], + hrv_vs_baseline_pct: Optional[float], + rhr_vs_baseline_pct: Optional[float], + r_pearson: Optional[float], + pairs_n: int, +) -> List[str]: + """Eine zusammenhängende Einordnung statt vieler einzelner Karten zu Puls/HRV/Basis.""" + paras: List[str] = [] + + basis_bits: List[str] = [] + if hrv_vs_baseline_pct is not None: + basis_bits.append( + f"HRV liegt gegenüber der älteren Referenz bei {hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + ) + if rhr_vs_baseline_pct is not None: + basis_bits.append( + f"Ruhepuls relativ zur Referenz bei {rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + ) + if basis_bits: + paras.append( + " ".join(basis_bits) + + " (Vergleich kurzfristiges Mittel vs. ältere Basis — individuell interpretieren)." + ) + + rhr = series.get("resting_hr") + hrv_s = series.get("hrv") + var_bits: List[str] = [] + if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 3: + var_bits.append(f"Ruhepuls Schwankungsbreite im Fenster etwa σ = {rhr['stdev']} bpm") + if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 3: + var_bits.append(f"HRV etwa σ = {hrv_s['stdev']} ms") + if var_bits: + paras.append( + "Einzelwerte können stark springen; die gestrichelte Linie zeigt einen gleitenden Mittelwert (max. 7 Messungen). " + + "Im Fenster: " + "; ".join(var_bits) + "." + ) + + if rhr and rhr.get("points") and len(rhr["points"]) >= 10: + pts = rhr["points"] + 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 abs(diff) > 3: + paras.append( + f"Kurzfristig liegt der Ruhepuls im Mittel der letzten 7 Messungen " + f"{'über' if diff > 0 else 'unter'} dem vorherigen Fenster (Δ ca. {abs(diff):.1f} bpm) — Kontext: Belastung, Schlaf, Stress." + ) + + 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: + paras.append( + "VO2max steigt im gewählten Fenster tendenziell — oft mit Trainingsreiz oder stabilen Messungen vereinbar." + ) + elif s < -0.002: + paras.append( + "VO2max fällt im Fenster leicht — kann Pause, Krankheit oder Messrauschen widerspiegeln." + ) + + if r_pearson is not None and pairs_n >= 8: + if r_pearson > 0.35: + paras.append( + f"Korrelation Trainingsminuten (Tag) → Ruhepuls (Folgetag): r ≈ {r_pearson:.2f} (n = {pairs_n} Paare). " + "Höhere Belastung und etwas höherer Ruhepuls am nächsten Morgen kommen in den Daten häufig zusammen — kein Kausalbeweis." + ) + elif r_pearson < -0.25: + paras.append( + f"Zwischen Tages-Belastung und Folge-Ruhepuls zeigt sich ein leicht negatives Zusammenspiel (r ≈ {r_pearson:.2f}, n = {pairs_n}). " + "Stark von Ausreißern und Datenlücken abhängig." + ) + + return [p for p in paras if p] + + def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: cur.execute( """ @@ -87,9 +175,16 @@ def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: 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]: +def build_vitals_history_and_analytics( + profile_id: str, + days: int, + hrv_vs_baseline_pct: Optional[float] = None, + rhr_vs_baseline_pct: Optional[float] = None, +) -> Dict[str, Any]: """ - Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + Kurz-Analytik. + Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + zusammengefasste Einordnung. + + Optional: Abweichung HRV/Ruhepuls zur älteren Basis — für einen Absatz statt doppelter KPI-Texte. """ if days < 7: days = 7 @@ -126,12 +221,17 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, dates.append(d) vals.append(fv) if pts: + ma_vals = _trailing_window_means(vals, window=7) + points_ma7 = [ + {"date": pts[i]["date"], "value": ma_vals[i]} for i in range(len(pts)) + ] series[key] = { "key": key, "label_de": label_de, "unit": unit, "color": color, "points": pts, + "points_ma7": points_ma7, "n": len(pts), "last": vals[-1] if vals else None, "mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None, @@ -139,82 +239,6 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, "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) @@ -234,35 +258,27 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, 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.", - } - ) + pairs_n = len(pairs_load) + + consolidated = _build_consolidated_paragraphs( + series, + hrv_vs_baseline_pct, + rhr_vs_baseline_pct, + r_pearson, + pairs_n, + ) if not series: return { "chart_type": "vitals_dashboard", "window_days": days, "series": {}, - "analytics": {"bullets": []}, + "analytics": {"bullets": [], "consolidated_paragraphs": consolidated}, "metadata": { "confidence": "insufficient", "message": "Keine Vital-Zeitreihen im Fenster", + "load_rhr_pairs_n": pairs_n, + "load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None, }, } @@ -270,11 +286,14 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, "chart_type": "vitals_dashboard", "window_days": days, "series": serialize_dates(series), - "analytics": {"bullets": bullets}, + "analytics": { + "bullets": [], + "consolidated_paragraphs": consolidated, + }, "metadata": { "confidence": "medium", "note": "Deskriptive Auswertung; keine medizinische Diagnose.", - "load_rhr_pairs_n": len(pairs_load), + "load_rhr_pairs_n": pairs_n, "load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None, }, } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index c2d15f5..1a285fe 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -22,7 +22,7 @@ Version: 1.0 """ from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from datetime import datetime, timedelta from auth import require_auth @@ -1432,11 +1432,18 @@ def get_sleep_debt_chart( @router.get("/vital-signs-matrix") def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=365), - session: dict = Depends(require_auth) + omit_snapshot_keys: Optional[str] = Query( + default=None, + description="Optional: Komma-getrennte Keys ausblenden (z. B. resting_hr,hrv) wenn Einordnung woanders steht.", + ), + session: dict = Depends(require_auth), ) -> Dict: """Vital signs matrix (R5).""" profile_id = session["profile_id"] - return build_vital_signs_matrix_chart_payload(profile_id, days) + omit_set: Optional[Set[str]] = None + if omit_snapshot_keys and omit_snapshot_keys.strip(): + omit_set = {x.strip() for x in omit_snapshot_keys.split(",") if x.strip()} + return build_vital_signs_matrix_chart_payload(profile_id, days, omit_snapshot_keys=omit_set) # ── Correlation Charts ────────────────────────────────────────────────────── diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index bc02dd8..9fc1397 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts' import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' @@ -311,13 +311,39 @@ export default function RecoveryDashboardOverview({ } const series = vh.series || {} const keys = Object.keys(series) + const paragraphs = vh.analytics?.consolidated_paragraphs || [] const bullets = vh.analytics?.bullets || [] - const corrNote = vh.metadata?.load_rhr_correlation - const pairsN = vh.metadata?.load_rhr_pairs_n + const showParagraphs = paragraphs.length > 0 + const showBulletsFallback = !showParagraphs && bullets.length > 0 return (
- {bullets.length > 0 ? ( + {showParagraphs ? ( +
+
+ Einordnung (Vital & Belastung) +
+
+ {paragraphs.map((text, i) => ( +

+ {text} +

+ ))} +
+
+ ) : null} + + {showBulletsFallback ? (
Einordnung (Vital & Belastung) @@ -339,31 +365,34 @@ export default function RecoveryDashboardOverview({
))}
- {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. + Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende + Messungen), glättet starke Einzelschwankungen.
{keys.map((k) => { const m = series[k] const pts = m.points || [] + const maPts = m.points_ma7 || [] if (pts.length === 0) return null - const chartData = pts.map((p) => ({ + const chartData = pts.map((p, i) => ({ ...p, d: fmtDate(p.date), + value_ma: maPts[i]?.value != null ? maPts[i].value : null, })) - const vals = pts.map((p) => p.value) + const vals = [] + pts.forEach((p, i) => { + vals.push(p.value) + if (maPts[i]?.value != null) vals.push(maPts[i].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 + const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null) return (
@@ -381,7 +410,7 @@ export default function RecoveryDashboardOverview({ Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen.
) : ( -
+
@@ -399,14 +428,28 @@ export default function RecoveryDashboardOverview({ fontSize: 11, }} /> + {hasMa ? : null} + {hasMa ? ( + + ) : null}
@@ -444,9 +487,17 @@ export default function RecoveryDashboardOverview({ const vitDate = meta.vitals_measured_at const bpDate = meta.blood_pressure_measured_at const disclaimer = meta.disclaimer_de + const hasRhrCard = items.some((it) => it.key === 'resting_hr') + const hasHrvCard = items.some((it) => it.key === 'hrv') return ( <> + {items.length > 0 && !hasRhrCard && !hasHrvCard ? ( +

+ Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt — die Einordnung + steht oben im Vital-Verlauf und in der KPI-Kachel „Herz & autonomes System“. +

+ ) : null} {items.length > 0 ? (
{items.map((it) => { -- 2.43.0 From ce84f330f0b7746348e86a0dbd957dcb8dee372f Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 10:55:49 +0200 Subject: [PATCH 3/5] feat: add German number formatting functions and enhance narrative context in vital signs insights - Introduced `_de_num` and `_de_num_signed` functions for formatting decimal numbers with a comma, improving text presentation in German. - Updated `_build_consolidated_paragraphs` to utilize new formatting functions for HRV and resting heart rate comparisons, enhancing clarity in insights. - Refined narrative descriptions for better contextual understanding of vital signs trends and their implications. --- backend/data_layer/vitals_fitness_insights.py | 106 ++++++++++++++---- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index 964a3af..b64caa0 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -83,6 +83,16 @@ def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]: return out +def _de_num(x: float) -> str: + """Dezimalzahl mit Komma für Fließtext.""" + return f"{x:.1f}".replace(".", ",") + + +def _de_num_signed(x: float) -> str: + """Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen).""" + return f"{x:+.1f}".replace(".", ",") + + def _build_consolidated_paragraphs( series: Dict[str, Any], hrv_vs_baseline_pct: Optional[float], @@ -90,37 +100,33 @@ def _build_consolidated_paragraphs( r_pearson: Optional[float], pairs_n: int, ) -> List[str]: - """Eine zusammenhängende Einordnung statt vieler einzelner Karten zu Puls/HRV/Basis.""" + """ + Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets), + ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden). + """ paras: List[str] = [] + # ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt basis_bits: List[str] = [] if hrv_vs_baseline_pct is not None: basis_bits.append( - f"HRV liegt gegenüber der älteren Referenz bei {hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %" ) if rhr_vs_baseline_pct is not None: basis_bits.append( - f"Ruhepuls relativ zur Referenz bei {rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %" ) if basis_bits: paras.append( " ".join(basis_bits) - + " (Vergleich kurzfristiges Mittel vs. ältere Basis — individuell interpretieren)." + + " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren." ) rhr = series.get("resting_hr") hrv_s = series.get("hrv") - var_bits: List[str] = [] - if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 3: - var_bits.append(f"Ruhepuls Schwankungsbreite im Fenster etwa σ = {rhr['stdev']} bpm") - if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 3: - var_bits.append(f"HRV etwa σ = {hrv_s['stdev']} ms") - if var_bits: - paras.append( - "Einzelwerte können stark springen; die gestrichelte Linie zeigt einen gleitenden Mittelwert (max. 7 Messungen). " - + "Im Fenster: " + "; ".join(var_bits) + "." - ) + # ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten) + rhr_short_compare = "" if rhr and rhr.get("points") and len(rhr["points"]) >= 10: pts = rhr["points"] last7 = [p["value"] for p in pts[-7:]] @@ -129,34 +135,86 @@ def _build_consolidated_paragraphs( m7 = statistics.mean(last7) mb = statistics.mean(before) diff = m7 - mb - if abs(diff) > 3: - paras.append( - f"Kurzfristig liegt der Ruhepuls im Mittel der letzten 7 Messungen " - f"{'über' if diff > 0 else 'unter'} dem vorherigen Fenster (Δ ca. {abs(diff):.1f} bpm) — Kontext: Belastung, Schlaf, Stress." + if diff > 3: + rhr_short_compare = ( + f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — " + "kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen." + ) + elif diff < -3: + rhr_short_compare = ( + "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " + "besserer Regeneration vereinbar (individuell)." ) + # ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise + rhr_var_sentence = "" + if ( + rhr + and rhr.get("stdev") is not None + and rhr.get("n", 0) >= 6 + ): + rhr_var_sentence = ( + f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; " + "extreme Sprünge mit Kontext (Training, Schlaf) betrachten." + ) + + hrv_var_sentence = "" + if ( + hrv_s + and hrv_s.get("stdev") is not None + and hrv_s.get("n", 0) >= 6 + ): + hrv_var_sentence = ( + f"HRV schwankt im Fenster (σ ≈ {_de_num(float(hrv_s['stdev']))} ms). " + "Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte." + ) + + # Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt) + ma_hint = ( + "Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert " + "über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)." + ) + + block_b_parts: List[str] = [] + if rhr_short_compare: + block_b_parts.append(rhr_short_compare) + if rhr_var_sentence: + block_b_parts.append(rhr_var_sentence) + if hrv_var_sentence: + block_b_parts.append(hrv_var_sentence) + if block_b_parts: + paras.append(ma_hint + " " + " ".join(block_b_parts)) + elif series: + # Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm) + paras.append(ma_hint) + + # ── VO2max: Wortlaut wie in den früheren Bullet-Karten 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: paras.append( - "VO2max steigt im gewählten Fenster tendenziell — oft mit Trainingsreiz oder stabilen Messungen vereinbar." + "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " + "besserer Datenlage vereinbar." ) elif s < -0.002: paras.append( - "VO2max fällt im Fenster leicht — kann Pause, Krankheit oder Messrauschen widerspiegeln." + "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " + "entstehen; Verlauf beobachten." ) + # ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet if r_pearson is not None and pairs_n >= 8: if r_pearson > 0.35: paras.append( - f"Korrelation Trainingsminuten (Tag) → Ruhepuls (Folgetag): r ≈ {r_pearson:.2f} (n = {pairs_n} Paare). " - "Höhere Belastung und etwas höherer Ruhepuls am nächsten Morgen kommen in den Daten häufig zusammen — kein Kausalbeweis." + "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). " + f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren." ) elif r_pearson < -0.25: paras.append( - f"Zwischen Tages-Belastung und Folge-Ruhepuls zeigt sich ein leicht negatives Zusammenspiel (r ≈ {r_pearson:.2f}, n = {pairs_n}). " - "Stark von Ausreißern und Datenlücken abhängig." + "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem " + f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare." ) return [p for p in paras if p] -- 2.43.0 From 857cc1043a8a6838af280eae28aa3f9df005c9f7 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 11:09:16 +0200 Subject: [PATCH 4/5] refactor: streamline vital signs matrix handling and enhance recovery dashboard layout - Removed unnecessary snapshot key omission in the `build_vital_signs_matrix_chart_payload` function for improved data clarity. - Introduced new components for better organization and presentation of vital signs insights, including `SectionHeading` and `VitalZoneHint`. - Enhanced axis tick formatting in the `RecoveryDashboardOverview` component for clearer data representation. - Updated narrative rendering logic to improve user experience and contextual understanding of vital metrics. --- backend/data_layer/recovery_viz.py | 6 +- .../components/RecoveryDashboardOverview.jsx | 363 ++++++++++++------ 2 files changed, 247 insertions(+), 122 deletions(-) diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 34a8850..8749e2b 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -91,11 +91,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A "hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days), "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, - omit_snapshot_keys={"resting_hr", "hrv"}, - ), + "vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days), "vitals_history": build_vitals_history_and_analytics( profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f ), diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index 9fc1397..d08781b 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -8,6 +8,18 @@ import dayjs from 'dayjs' const fmtDate = (d) => dayjs(d).format('DD.MM.') +/** Nur diese Kennzahlen als eigene Verläufe — Ruhepuls/HRV nur im kombinierten Diagramm (keine Doppelung). */ +const VITAL_TREND_ONLY_KEYS = ['vo2_max', 'spo2', 'respiratory_rate'] + +function formatAxisTick(v) { + const n = Number(v) + if (!Number.isFinite(n)) return '' + const a = Math.abs(n) + if (a >= 100) return String(Math.round(n)) + if (a >= 10) return n.toFixed(1) + return Number(n.toFixed(2)).toString() +} + function insightBulletStripe(tone) { if (tone === 'good') return getStatusColor('good') if (tone === 'bad') return getStatusColor('bad') @@ -15,10 +27,51 @@ function insightBulletStripe(tone) { return getStatusColor('warn') } -function ChartCard({ title, loading, error, children }) { +function SectionHeading({ title, hint, compactTop }) { + return ( +
+
{title}
+ {hint ? ( +
{hint}
+ ) : null} +
+ ) +} + +function VitalZoneHint({ item }) { + if (!item) return null + const stripe = insightBulletStripe(item.tone) + const t = item.tone + const hintBg = + t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)' + return ( +
+ Zuletzt (Snapshot): + {item.zone_label_de} + {item.hint_de} +
+ ) +} + +function ChartCard({ title, loading, error, children, description }) { return (
-
{title}
+
{title}
+ {description ? ( +
{description}
+ ) : null} {loading && (
@@ -110,6 +163,17 @@ export default function RecoveryDashboardOverview({ const vitalsData = viz.charts?.vital_signs_matrix const vitalsHistory = viz.charts?.vitals_history + const vitalItemsByKey = {} + ;(vitalsData?.metadata?.vital_items || []).forEach((it) => { + vitalItemsByKey[it.key] = it + }) + + const showVitalNarrativeBlock = + vitalsHistory && + vitalsHistory.metadata?.confidence !== 'insufficient' && + (((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 || + (vitalsHistory.analytics?.bullets || []).length > 0)) + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: @@ -145,7 +209,14 @@ export default function RecoveryDashboardOverview({ tickLine={false} interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} /> - + (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} + tickCount={6} + width={36} + /> - - + + { + /** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */ + const renderVitalBelastungNarrative = () => { + const vh = vitalsHistory + if (!vh || vh.metadata?.confidence === 'insufficient') return null + const paragraphs = vh.analytics?.consolidated_paragraphs || [] + const bullets = vh.analytics?.bullets || [] + const showParagraphs = paragraphs.length > 0 + const showBulletsFallback = !showParagraphs && bullets.length > 0 + if (!showParagraphs && !showBulletsFallback) return null + return ( +
+ {showParagraphs ? ( +
+ {paragraphs.map((text, i) => ( +

+ {text} +

+ ))} +
+ ) : null} + {showBulletsFallback ? ( +
+ {bullets.map((b) => ( +
+
{b.title}
+
{b.body}
+
+ ))} +
+ ) : null} +
+ ) + } + + /** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */ + const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => { const vh = vitalsHistory if (!vh) { - return
Keine Verlaufs-Daten (Bundle).
+ 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 paragraphs = vh.analytics?.consolidated_paragraphs || [] - const bullets = vh.analytics?.bullets || [] - const showParagraphs = paragraphs.length > 0 - const showBulletsFallback = !showParagraphs && bullets.length > 0 + const keys = VITAL_TREND_ONLY_KEYS.filter((k) => series[k]?.points?.length) + if (keys.length === 0) { + return ( +
+ Keine zusätzlichen Vital-Verläufe (VO2max, SpO2, Atemfrequenz) im Fenster — oder nur Ruhepuls/HRV erfasst. +
+ ) + } return (
- {showParagraphs ? ( -
-
- Einordnung (Vital & Belastung) -
-
- {paragraphs.map((text, i) => ( -

- {text} -

- ))} -
-
- ) : null} - - {showBulletsFallback ? ( -
-
- Einordnung (Vital & Belastung) -
-
- {bullets.map((b) => ( -
-
{b.title}
-
{b.body}
-
- ))} -
-
- ) : null} - -
- Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende - Messungen), glättet starke Einzelschwankungen. +
+ Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich + begrenzt.
- {keys.map((k) => { const m = series[k] const pts = m.points || [] const maPts = m.points_ma7 || [] - if (pts.length === 0) return null + const zoneItem = vitalItemsByKey[k] const chartData = pts.map((p, i) => ({ ...p, d: fmtDate(p.date), @@ -391,23 +484,22 @@ export default function RecoveryDashboardOverview({ 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 + const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : Math.max(span * 0.12, 0.01) const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null) return ( -
-
+
+
{m.label_de} ({m.unit}) - {m.n != null ? ( - · n = {m.n} - ) : null} + {m.n != null ? · n = {m.n} : null} {m.mean != null ? ( - · Ø {m.mean} + · Ø {formatAxisTick(m.mean)} ) : null}
+ {pts.length === 1 ? (
- Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen. + Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
) : (
@@ -418,7 +510,9 @@ export default function RecoveryDashboardOverview({ (value != null ? formatAxisTick(value) : '')} /> {hasMa ? : null} ) })} - - {keys.length === 0 ? ( -
Keine Vital-Zeitreihen im Fenster.
- ) : null}
) } @@ -487,17 +578,9 @@ export default function RecoveryDashboardOverview({ const vitDate = meta.vitals_measured_at const bpDate = meta.blood_pressure_measured_at const disclaimer = meta.disclaimer_de - const hasRhrCard = items.some((it) => it.key === 'resting_hr') - const hasHrvCard = items.some((it) => it.key === 'hrv') return ( <> - {items.length > 0 && !hasRhrCard && !hasHrvCard ? ( -

- Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt — die Einordnung - steht oben im Vital-Verlauf und in der KPI-Kachel „Herz & autonomes System“. -

- ) : null} {items.length > 0 ? (
{items.map((it) => { @@ -597,44 +680,90 @@ export default function RecoveryDashboardOverview({ ) : null}
-

- Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. {eff} Tage · Charts{' '} - {cDays} Tage · Vital-Matrix {vDays} Tage. +

+ Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · + Vital-Snapshot {vDays} Tage.

- + - {insights.length > 0 ? ( -
+ {insights.length > 0 || showVitalNarrativeBlock ? ( +
Einschätzungen
-
- {insights.map((ins) => ( -
-
{ins.title}
-
{ins.body}
+
+ {insights.map((ins) => { + const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' + return ( +
+
{ins.title}
+
{ins.body}
+
+ ) + })} + {showVitalNarrativeBlock ? ( +
+
+ Vitalverlauf & Belastung (Text) +
+ {renderVitalBelastungNarrative()}
- ))} + ) : null}
) : null} -
Diagramme
+ + + {renderRecoveryScore()} + + + {renderSleepQuality()} + + + {renderSleepDebt()} + - {renderRecoveryScore()} - {renderHrvRhr()} - {renderVitalsHistory()} - {renderSleepQuality()} - {renderSleepDebt()} - {renderVitalSigns()} + + + {renderHrvRhr()} + + + +
+
Verläufe
+ {renderWeitereVitalVerlaeufe(vitalItemsByKey)} +
+ + + {renderVitalSigns()}
) } -- 2.43.0 From 61738cecb70afb56702bf241a2a013af19f93bbd Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 11:43:56 +0200 Subject: [PATCH 5/5] feat: enhance recovery dashboard with optional average sleep KPI and structured insights - Added an `include_avg_sleep_kpi` parameter to the `build_recovery_dashboard_kpi_tiles` function to conditionally include average sleep data in the dashboard. - Updated the `get_recovery_dashboard_viz_bundle` function to pass the new parameter, ensuring flexibility in data presentation. - Refactored the insights generation in the `vitals_fitness_insights.py` file to utilize a new structured approach for better organization of heart and VO2 insights. - Introduced new components in the frontend for displaying insights, improving the user experience and clarity of vital metrics. --- backend/data_layer/recovery_interpretation.py | 30 +- backend/data_layer/recovery_viz.py | 1 + backend/data_layer/vitals_fitness_insights.py | 163 +++++--- .../components/RecoveryDashboardOverview.jsx | 355 +++++++++--------- 4 files changed, 291 insertions(+), 258 deletions(-) diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py index ae75071..5880bfb 100644 --- a/backend/data_layer/recovery_interpretation.py +++ b/backend/data_layer/recovery_interpretation.py @@ -42,6 +42,7 @@ def build_recovery_dashboard_kpi_tiles( hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], merge_heart_autonomic_tiles: bool = True, + include_avg_sleep_kpi: bool = True, ) -> List[Dict[str, Any]]: tiles: List[Dict[str, Any]] = [] @@ -79,20 +80,21 @@ def build_recovery_dashboard_kpi_tiles( } ) - tiles.append( - { - "key": "avg_sleep", - "category": "Ø Schlafdauer", - "icon": "🌙", - "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—", - "sublabel": "Im gewählten Fenster", - "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", - "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", - "hoverTop": "Durchschnittliche Schlafdauer", - "hoverBody": "get_sleep_duration_data", - "keys": ["sleep_duration_avg"], - } - ) + if include_avg_sleep_kpi: + tiles.append( + { + "key": "avg_sleep", + "category": "Ø Schlafdauer", + "icon": "🌙", + "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—", + "sublabel": "Im gewählten Fenster", + "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", + "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", + "hoverTop": "Durchschnittliche Schlafdauer", + "hoverBody": "get_sleep_duration_data", + "keys": ["sleep_duration_avg"], + } + ) if merge_heart_autonomic_tiles and ( hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 8749e2b..f04ea04 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -75,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A avg_sleep, float(hrv_dev) if hrv_dev is not None else None, float(rhr_dev) if rhr_dev is not None else None, + include_avg_sleep_kpi=False, ) insights = build_recovery_progress_insights( diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index b64caa0..a751942 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -93,40 +93,56 @@ def _de_num_signed(x: float) -> str: return f"{x:+.1f}".replace(".", ",") -def _build_consolidated_paragraphs( +def _ins( + key: str, + section: str, + title_de: str, + body: str, + tone: str = "neutral", +) -> Dict[str, Any]: + """Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2).""" + return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone} + + +def _build_section_insights( series: Dict[str, Any], hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], r_pearson: Optional[float], pairs_n: int, -) -> List[str]: +) -> List[Dict[str, Any]]: """ - Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets), - ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden). + Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt. + section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf. """ - paras: List[str] = [] + out: List[Dict[str, Any]] = [] - # ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt basis_bits: List[str] = [] if hrv_vs_baseline_pct is not None: basis_bits.append( - f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %" + f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %" ) if rhr_vs_baseline_pct is not None: basis_bits.append( - f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %" + f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %" ) if basis_bits: - paras.append( - " ".join(basis_bits) - + " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren." + out.append( + _ins( + "heart_baseline", + "heart", + "Kurzfristiges Mittel vs. ältere Basis", + " ".join(basis_bits) + + " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).", + "neutral", + ) ) rhr = series.get("resting_hr") hrv_s = series.get("hrv") - # ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten) - rhr_short_compare = "" + rhr_short_body = "" + r_short_tone = "neutral" if rhr and rhr.get("points") and len(rhr["points"]) >= 10: pts = rhr["points"] last7 = [p["value"] for p in pts[-7:]] @@ -136,88 +152,110 @@ def _build_consolidated_paragraphs( mb = statistics.mean(before) diff = m7 - mb if diff > 3: - rhr_short_compare = ( + rhr_short_body = ( f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — " "kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen." ) + r_short_tone = "warn" elif diff < -3: - rhr_short_compare = ( + rhr_short_body = ( "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " "besserer Regeneration vereinbar (individuell)." ) + r_short_tone = "good" - # ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise rhr_var_sentence = "" - if ( - rhr - and rhr.get("stdev") is not None - and rhr.get("n", 0) >= 6 - ): + if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6: rhr_var_sentence = ( - f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; " - "extreme Sprünge mit Kontext (Training, Schlaf) betrachten." + f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen " + "sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten." ) hrv_var_sentence = "" - if ( - hrv_s - and hrv_s.get("stdev") is not None - and hrv_s.get("n", 0) >= 6 - ): + if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6: hrv_var_sentence = ( - f"HRV schwankt im Fenster (σ ≈ {_de_num(float(hrv_s['stdev']))} ms). " + f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — " "Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte." ) - # Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt) ma_hint = ( - "Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert " + "Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert " "über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)." ) - block_b_parts: List[str] = [] - if rhr_short_compare: - block_b_parts.append(rhr_short_compare) + streuung_parts: List[str] = [ma_hint] if rhr_var_sentence: - block_b_parts.append(rhr_var_sentence) + streuung_parts.append(rhr_var_sentence) if hrv_var_sentence: - block_b_parts.append(hrv_var_sentence) - if block_b_parts: - paras.append(ma_hint + " " + " ".join(block_b_parts)) - elif series: - # Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm) - paras.append(ma_hint) + streuung_parts.append(hrv_var_sentence) + if rhr or hrv_s: + out.append( + _ins( + "heart_streuung_ma", + "heart", + "Streuung & gleitender Mittelwert", + " ".join(streuung_parts), + "neutral", + ) + ) + + if rhr_short_body: + out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone)) - # ── VO2max: Wortlaut wie in den früheren Bullet-Karten 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: - paras.append( - "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " - "besserer Datenlage vereinbar." + out.append( + _ins( + "vo2_trend_up", + "vo2", + "VO2max-Verlauf", + "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " + "besserer Datenlage vereinbar.", + "good", + ) ) elif s < -0.002: - paras.append( - "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " - "entstehen; Verlauf beobachten." + out.append( + _ins( + "vo2_trend_down", + "vo2", + "VO2max-Verlauf", + "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " + "entstehen; Verlauf beobachten.", + "warn", + ) ) - # ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet if r_pearson is not None and pairs_n >= 8: if r_pearson > 0.35: - paras.append( - "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). " - f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren." + out.append( + _ins( + "heart_load_rhr", + "heart", + "Training und Folge-Ruhepuls", + ( + "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). " + f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren." + ), + "warn", + ) ) elif r_pearson < -0.25: - paras.append( - "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem " - f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare." + out.append( + _ins( + "heart_load_rhr_neg", + "heart", + "Training und Folge-Ruhepuls", + "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem " + f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.", + "neutral", + ) ) - return [p for p in paras if p] + return out def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: @@ -318,7 +356,7 @@ def build_vitals_history_and_analytics( r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None pairs_n = len(pairs_load) - consolidated = _build_consolidated_paragraphs( + section_insights = _build_section_insights( series, hrv_vs_baseline_pct, rhr_vs_baseline_pct, @@ -331,7 +369,11 @@ def build_vitals_history_and_analytics( "chart_type": "vitals_dashboard", "window_days": days, "series": {}, - "analytics": {"bullets": [], "consolidated_paragraphs": consolidated}, + "analytics": { + "bullets": [], + "consolidated_paragraphs": [], + "section_insights": section_insights, + }, "metadata": { "confidence": "insufficient", "message": "Keine Vital-Zeitreihen im Fenster", @@ -346,7 +388,8 @@ def build_vitals_history_and_analytics( "series": serialize_dates(series), "analytics": { "bullets": [], - "consolidated_paragraphs": consolidated, + "consolidated_paragraphs": [], + "section_insights": section_insights, }, "metadata": { "confidence": "medium", diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index d08781b..a45c3d8 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -58,13 +58,120 @@ function VitalZoneHint({ item }) { lineHeight: 1.45, }} > - Zuletzt (Snapshot): + Letzte Einordnung (Snapshot): {item.zone_label_de} {item.hint_de}
) } +/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */ +function HeartAutonomicGuide() { + return ( +
+ + Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm + +
+

+ Es handelt sich um Abweichungen in % vom älteren Referenzmittel (kurzfristiges Mittel vs. längere + Basis) — nicht um absolute Normalwerte. +

+
    +
  • + HRV: Positive % = zuletzt oft über der älteren Basis; sehr personenabhängig. +
  • +
  • + Ruhepuls: Negative % = niedriger als die Referenz; bei Training oft unkritisch günstig. +
  • +
+

+ Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert + anzeigen. +

+
+
+ ) +} + +function SectionInsightCard({ ins }) { + const t = ['good', 'warn', 'bad', 'neutral'].includes(ins.tone) ? ins.tone : 'neutral' + const stripe = insightBulletStripe(t) + const bg = + t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)' + return ( +
+
{ins.title_de}
+
{ins.body}
+
+ ) +} + +function SnapshotCards({ items }) { + if (!items?.length) return null + return ( +
+ {items.map((it) => { + const stripe = + it.tone === 'good' + ? getStatusColor('good') + : it.tone === 'bad' + ? getStatusColor('bad') + : it.tone === 'warn' + ? getStatusColor('warn') + : '#6B7280' + const bg = + it.tone === 'good' + ? getStatusBg('good') + : it.tone === 'bad' + ? getStatusBg('bad') + : it.tone === 'warn' + ? getStatusBg('warn') + : 'var(--surface2)' + return ( +
+
+ {it.label_de} + {it.value_display} + + {it.zone_label_de} + +
+
{it.hint_de}
+
+ ) + })} +
+ ) +} + function ChartCard({ title, loading, error, children, description }) { return (
@@ -168,11 +275,10 @@ export default function RecoveryDashboardOverview({ vitalItemsByKey[it.key] = it }) - const showVitalNarrativeBlock = - vitalsHistory && - vitalsHistory.metadata?.confidence !== 'insufficient' && - (((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 || - (vitalsHistory.analytics?.bullets || []).length > 0)) + const sectionInsights = vitalsHistory?.analytics?.section_insights || [] + const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart') + const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2') + const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean) const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, @@ -383,62 +489,8 @@ export default function RecoveryDashboardOverview({ ) } - /** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */ - const renderVitalBelastungNarrative = () => { - const vh = vitalsHistory - if (!vh || vh.metadata?.confidence === 'insufficient') return null - const paragraphs = vh.analytics?.consolidated_paragraphs || [] - const bullets = vh.analytics?.bullets || [] - const showParagraphs = paragraphs.length > 0 - const showBulletsFallback = !showParagraphs && bullets.length > 0 - if (!showParagraphs && !showBulletsFallback) return null - return ( -
- {showParagraphs ? ( -
- {paragraphs.map((text, i) => ( -

- {text} -

- ))} -
- ) : null} - {showBulletsFallback ? ( -
- {bullets.map((b) => ( -
-
{b.title}
-
{b.body}
-
- ))} -
- ) : null} -
- ) - } - - /** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */ - const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => { + /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */ + const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => { const vh = vitalsHistory if (!vh) { return
Keine Verlaufs-Daten (Bundle).
@@ -462,6 +514,13 @@ export default function RecoveryDashboardOverview({ return (
+ {vo2Insights.length > 0 ? ( +
+ {vo2Insights.map((ins) => ( + + ))} +
+ ) : null}
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich begrenzt. @@ -556,106 +615,6 @@ export default function RecoveryDashboardOverview({ ) } - const renderVitalSigns = () => { - if (!vitalsData) { - return ( -
- Keine Snapshot-Daten zur Vital-Matrix. -
- ) - } - const meta = vitalsData.metadata || {} - const items = meta.vital_items || [] - const ins = meta.confidence === 'insufficient' - if (ins && items.length === 0) { - return ( -
- {meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'} -
- ) - } - - const vitDate = meta.vitals_measured_at - const bpDate = meta.blood_pressure_measured_at - const disclaimer = meta.disclaimer_de - - return ( - <> - {items.length > 0 ? ( -
- {items.map((it) => { - const stripe = - it.tone === 'good' - ? getStatusColor('good') - : it.tone === 'bad' - ? getStatusColor('bad') - : it.tone === 'warn' - ? getStatusColor('warn') - : '#6B7280' - const bg = - it.tone === 'good' - ? getStatusBg('good') - : it.tone === 'bad' - ? getStatusBg('bad') - : it.tone === 'warn' - ? getStatusBg('warn') - : 'var(--surface2)' - return ( -
-
- {it.label_de} - {it.value_display} - - {it.zone_label_de} - -
-
{it.hint_de}
-
- ) - })} -
- ) : null} - -
- {vitDate ? ( - <> - Baseline-Vitals (Snapshot): {fmtDate(vitDate)} - - ) : null} - {vitDate && bpDate ? ' · ' : null} - {bpDate ? ( - <> - Blutdruck: {fmtDate(bpDate)} - - ) : null} - {!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage : null} -
- {disclaimer ? ( -
{disclaimer}
- ) : null} - - ) - } - return (
@@ -687,9 +646,11 @@ export default function RecoveryDashboardOverview({ - {insights.length > 0 || showVitalNarrativeBlock ? ( + {insights.length > 0 ? (
-
Einschätzungen
+
+ Überblick: Recovery & Schlaf +
{insights.map((ins) => { const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' @@ -709,14 +670,6 @@ export default function RecoveryDashboardOverview({
) })} - {showVitalNarrativeBlock ? ( -
-
- Vitalverlauf & Belastung (Text) -
- {renderVitalBelastungNarrative()} -
- ) : null}
) : null} @@ -732,7 +685,14 @@ export default function RecoveryDashboardOverview({ > {renderRecoveryScore()} - + {renderSleepQuality()} @@ -741,29 +701,56 @@ export default function RecoveryDashboardOverview({ +
+
Einordnung & Kontext
+ + {heartSectionInsights.length > 0 ? ( +
+ {heartSectionInsights.map((ins) => ( + + ))} +
+ ) : null} +
Letzte Messwerte (Zonen)
+ + {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( +
+ {vitalsData?.metadata?.vitals_measured_at ? ( + <> + Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} + + ) : null} + {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} + {vitalsData?.metadata?.blood_pressure_measured_at ? ( + <> + Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} + + ) : null} +
+ ) : null} + {vitalsData?.metadata?.disclaimer_de ? ( +
+ {vitalsData.metadata.disclaimer_de} +
+ ) : null} +
{renderHrvRhr()}
Verläufe
- {renderWeitereVitalVerlaeufe(vitalItemsByKey)} + {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
- - - {renderVitalSigns()}
) } -- 2.43.0