diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index 149ad26..f58b4ab 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -19,6 +19,7 @@ from data_layer.recovery_metrics import ( get_sleep_quality_data, ) from data_layer.utils import calculate_confidence, safe_float, serialize_dates +from data_layer.vital_signs_assessment import build_vital_items_from_rows def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: @@ -355,17 +356,40 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] } +def _vitals_row_has_any_value(row: Any) -> bool: + if not row: + return False + for k in ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate"): + if row.get(k) is not None: + return True + return False + + +def _bp_row_complete(row: Any) -> bool: + return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None) + + +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).""" if days < 7: days = 7 - if days > 30: - days = 30 + if days > 365: + days = 365 cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + vitals_row = None + bp_row = None + vitals_measured_at = None + bp_measured_at = None + with get_db() as conn: cur = get_cursor(conn) cur.execute( - """SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate + """SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate FROM vitals_baseline WHERE profile_id=%s AND date >= %s ORDER BY date DESC @@ -373,9 +397,26 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s (profile_id, cutoff), ) vitals_row = cur.fetchone() + if vitals_row and vitals_row.get("date") is not None: + d = vitals_row["date"] + vitals_measured_at = d.isoformat() if hasattr(d, "isoformat") else str(d) + + if not _vitals_row_has_any_value(vitals_row): + cur.execute( + """SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate + FROM vitals_baseline + WHERE profile_id=%s + ORDER BY date DESC + LIMIT 1""", + (profile_id,), + ) + vitals_row = cur.fetchone() + if vitals_row and vitals_row.get("date") is not None: + d = vitals_row["date"] + vitals_measured_at = d.isoformat() if hasattr(d, "isoformat") else str(d) cur.execute( - """SELECT systolic, diastolic + """SELECT measured_at, systolic, diastolic FROM blood_pressure_log WHERE profile_id=%s AND measured_at::date >= %s::date ORDER BY measured_at DESC @@ -383,74 +424,89 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s (profile_id, cutoff), ) bp_row = cur.fetchone() + if bp_row and bp_row.get("measured_at") is not None: + bp_measured_at = bp_row["measured_at"] - if not vitals_row and not bp_row: - return { - "chart_type": "bar", - "data": {"labels": [], "datasets": []}, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine aktuellen Vitalwerte", - }, - } - - labels = [] - values = [] - - if vitals_row: - if vitals_row["resting_hr"]: - labels.append("Ruhepuls (bpm)") - values.append(safe_float(vitals_row["resting_hr"])) - if vitals_row["hrv"]: - labels.append("HRV (ms)") - values.append(safe_float(vitals_row["hrv"])) - if vitals_row["vo2_max"]: - labels.append("VO2 Max") - values.append(safe_float(vitals_row["vo2_max"])) - if vitals_row["spo2"]: - labels.append("SpO2 (%)") - values.append(safe_float(vitals_row["spo2"])) - if vitals_row["respiratory_rate"]: - labels.append("Atemfrequenz") - values.append(safe_float(vitals_row["respiratory_rate"])) + if not _bp_row_complete(bp_row): + cur.execute( + """SELECT measured_at, systolic, diastolic + FROM blood_pressure_log + WHERE profile_id=%s + ORDER BY measured_at DESC + LIMIT 1""", + (profile_id,), + ) + bp_row = cur.fetchone() + if bp_row and bp_row.get("measured_at") is not None: + bp_measured_at = bp_row["measured_at"] + # Dict-like rows for assessment (exclude date/measured_at from value checks) + vitals_for_items = dict(vitals_row) if vitals_row else None + if vitals_for_items and "date" in vitals_for_items: + vitals_for_items = {k: v for k, v in vitals_for_items.items() if k != "date"} + bp_for_items = None if bp_row: - if bp_row["systolic"]: - labels.append("Blutdruck sys (mmHg)") - values.append(safe_float(bp_row["systolic"])) - if bp_row["diastolic"]: - labels.append("Blutdruck dia (mmHg)") - values.append(safe_float(bp_row["diastolic"])) + bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} - if not labels: + items = build_vital_items_from_rows(vitals_for_items, bp_for_items) + + if not items: return { "chart_type": "bar", "data": {"labels": [], "datasets": []}, "metadata": { "confidence": "insufficient", "data_points": 0, - "message": "Keine Vitalwerte verfügbar", + "message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.", + "vital_items": [], + "vitals_measured_at": vitals_measured_at, + "blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None, }, } + for it in items: + it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1) + + labels_short = [it["label_de"] for it in items] + bar_values = [it["bar_value"] for it in items] + colors = [] + for it in items: + t = it["tone"] + if t == "good": + colors.append("#1D9E75") + elif t == "warn": + colors.append("#EF9F27") + elif t == "bad": + colors.append("#D85A30") + else: + colors.append("#6B7280") + return { "chart_type": "bar", "data": { - "labels": labels, + "labels": labels_short, "datasets": [ { - "label": "Wert", - "data": values, - "backgroundColor": "#1D9E75", - "borderColor": "#085041", + "label": "Einschätzung (relativ)", + "data": bar_values, + "backgroundColor": colors, + "borderColor": colors, "borderWidth": 1, } ], }, - "metadata": { - "confidence": "medium", - "data_points": len(values), - "note": "Latest measurements within last " + str(days) + " days", - }, + "metadata": serialize_dates( + { + "confidence": "medium", + "data_points": len(items), + "note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).", + "vital_items": items, + "bar_is_relative_score": True, + "vitals_measured_at": vitals_measured_at, + "blood_pressure_measured_at": bp_measured_at.isoformat() + if bp_measured_at and hasattr(bp_measured_at, "isoformat") + else (str(bp_measured_at) if bp_measured_at else None), + "disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.", + } + ), } diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 2decd00..b0f8be6 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -55,7 +55,8 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A all_history = days >= 9999 eff_days = 3650 if all_history else max(7, min(int(days), 3650)) chart_days = min(90, max(7, min(eff_days, 365))) - vital_days = min(30, max(7, chart_days)) + # Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“ + vital_days = min(365, max(30, min(eff_days, 365))) recovery_score_val = calculate_recovery_score_v2(profile_id) sleep_debt = calculate_sleep_debt_hours(profile_id) diff --git a/backend/data_layer/vital_signs_assessment.py b/backend/data_layer/vital_signs_assessment.py new file mode 100644 index 0000000..5339e4b --- /dev/null +++ b/backend/data_layer/vital_signs_assessment.py @@ -0,0 +1,153 @@ +""" +Orientierende Zonen-Einschätzungen für Vitalwerte (Layer 1, Issue 53). +Keine Diagnose — typische Referenzbereiche für UI/Coaching. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from data_layer.utils import safe_float + +Tone = str # good | warn | bad | neutral + + +def _item( + key: str, + label_de: str, + value_display: str, + tone: Tone, + zone_label_de: str, + hint_de: str, + sort_order: int, +) -> Dict[str, Any]: + return { + "key": key, + "label_de": label_de, + "value_display": value_display, + "tone": tone, + "zone_label_de": zone_label_de, + "hint_de": hint_de, + "sort_order": sort_order, + } + + +def assess_resting_hr(bpm: float) -> tuple: + if bpm < 50: + return ( + "warn", + "Niedrig", + "Unter 50 bpm kann bei Sportlern normal sein — sonst ärztlich klären, wenn neu oder mit Beschwerden.", + ) + if bpm < 60: + return ("good", "Günstig / athletisch", "Häufig bei gut trainierten Personen im unteren Normbereich.") + if bpm <= 100: + return ("good", "Im üblichen Normbereich", "Typischer Ruhepuls bei Erwachsenen oft ca. 60–100 bpm.") + if bpm <= 110: + return ("warn", "Leicht erhöht", "Kann durch Stress, Krankheit, Koffein oder Untrainiertheit erhöht sein — Verlauf beobachten.") + return ("bad", "Deutlich erhöht", "Bei anhaltend hohem Ruhepuls medizinische Abklärung sinnvoll.") + + +def assess_hrv_ms(ms: float) -> tuple: + _ = ms + return ( + "neutral", + "Individuell", + "HRV (ms) ist sehr personenabhängig; Aussagekraft vor allem im Vergleich zu deiner eigenen Basis/Trend.", + ) + + +def assess_blood_pressure(systolic: float, diastolic: float) -> tuple: + sys_, dia = systolic, diastolic + if sys_ >= 180 or dia >= 110: + return ("bad", "Sehr hoch", "Sehr hohe Werte — bei Beschwerden oder neu aufgetreten ärztlich zeitnah abklären.") + if sys_ >= 140 or dia >= 90: + return ( + "bad", + "Erhöht", + "Liegt in einem Bereich, der oft als Hypertonie eingestuft wird — Bestätigung und Beratung durch ärztliche Messung.", + ) + if sys_ >= 130 or dia >= 85: + return ("warn", "Hochnormal", "Oberer Normal-/hochnormaler Bereich — Lebensstil und Verlauf beachten.") + if sys_ < 120 and dia < 80: + return ("good", "Optimal", "Liegt in einem oft als günstig beschriebenen Bereich (<120/80 mmHg).") + return ("good", "Normal", "Im gängigen Zielbereich für viele Erwachsene.") + + +def assess_spo2(pct: float) -> tuple: + if pct >= 97: + return ("good", "Günstig", "Sauerstoffsättigung im üblichen Zielbereich.") + if pct >= 95: + return ("good", "Unauffällig", "Häufig noch als normal eingestuft; Verlauf bei Atembeschwerden beobachten.") + if pct >= 90: + return ("warn", "Leicht vermindert", "Unter 95 % kann je nach Kontext relevant sein — bei Symptomen abklären.") + return ("bad", "Niedrig", "Niedrige SpO2 — bei anhaltend unter 90 % oder Beschwerden ärztlich vorstellen.") + + +def assess_respiratory_rate(rpm: float) -> tuple: + if 12 <= rpm <= 20: + return ("good", "Im üblichen Bereich", "Ruheatmung oft ca. 12–20/min.") + if 10 <= rpm < 12 or 20 < rpm <= 24: + return ("warn", "Grenzbereich", "Leicht außerhalb des häufig zitierten Ruhebereichs — Kontext (Belastung, Stress) beachten.") + return ("bad", "Auffällig", "Deutlich außerhalb typischer Ruhewerte — bei Beschwerden medizinisch abklären.") + + +def assess_vo2_max(value: float) -> tuple: + _ = value + return ( + "neutral", + "Orientativ", + "VO2max hängt stark von Alter, Geschlecht und Messmethode ab; Trends in der App sind aussagekräftiger als Einzelwerte.", + ) + + +def build_vital_items_from_rows( + vitals_row: Optional[Dict[str, Any]], + bp_row: Optional[Dict[str, Any]], +) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + order = 0 + + if vitals_row: + rhr = vitals_row.get("resting_hr") + if rhr is not None: + 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: + v = safe_float(hrv) + t, z, h = assess_hrv_ms(v) + items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order)) + order += 1 + + vo2 = vitals_row.get("vo2_max") + if vo2 is not None: + v = safe_float(vo2) + t, z, h = assess_vo2_max(v) + items.append(_item("vo2_max", "VO2max", f"{v:.1f} ml/kg/min", t, z, h, order)) + order += 1 + + spo2 = vitals_row.get("spo2") + if spo2 is not None: + v = safe_float(spo2) + t, z, h = assess_spo2(v) + items.append(_item("spo2", "SpO2", f"{v:.0f} %", t, z, h, order)) + order += 1 + + rr = vitals_row.get("respiratory_rate") + if rr is not None: + v = safe_float(rr) + t, z, h = assess_respiratory_rate(v) + items.append(_item("respiratory_rate", "Atemfrequenz", f"{v:.0f} /min", t, z, h, order)) + order += 1 + + if bp_row and bp_row.get("systolic") is not None and bp_row.get("diastolic") is not None: + sys_v = safe_float(bp_row["systolic"]) + dia_v = safe_float(bp_row["diastolic"]) + t, z, h = assess_blood_pressure(sys_v, dia_v) + items.append(_item("blood_pressure", "Blutdruck", f"{sys_v:.0f}/{dia_v:.0f} mmHg", t, z, h, order)) + + return items diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 508cff0..c2d15f5 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -1431,7 +1431,7 @@ def get_sleep_debt_chart( @router.get("/vital-signs-matrix") def get_vital_signs_matrix_chart( - days: int = Query(default=7, ge=7, le=30), + days: int = Query(default=7, ge=7, le=365), session: dict = Depends(require_auth) ) -> Dict: """Vital signs matrix (R5).""" diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index 31f0802..6a3b558 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -5,6 +5,7 @@ import { Line, BarChart, Bar, + Cell, XAxis, YAxis, Tooltip, @@ -13,11 +14,26 @@ import { } from 'recharts' import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' -import { getStatusColor } from '../utils/interpret' +import { getStatusColor, getStatusBg } from '../utils/interpret' 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 ChartCard({ title, loading, error, children }) { return (