From d4868b3797cf5f69acd2fbd82a60532d414dc0bc Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:36:45 +0200 Subject: [PATCH 1/2] feat: enhance vital signs matrix chart payload and visualization - Introduced new functions to handle vital signs data retrieval and processing, including fallback mechanisms for missing values. - Updated SQL queries in `build_vital_signs_matrix_chart_payload` to improve date filtering and data accuracy. - Enhanced the frontend `RecoveryDashboardOverview` component to display vital signs with contextual coloring based on health tones. - Adjusted the data structure for chart rendering, ensuring a more informative and visually appealing representation of vital metrics. --- backend/data_layer/recovery_chart_payloads.py | 160 ++++++++++++------ backend/data_layer/recovery_viz.py | 3 +- backend/data_layer/vital_signs_assessment.py | 153 +++++++++++++++++ backend/routers/charts.py | 2 +- .../components/RecoveryDashboardOverview.jsx | 151 ++++++++++++++--- 5 files changed, 392 insertions(+), 77 deletions(-) create mode 100644 backend/data_layer/vital_signs_assessment.py 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 (
@@ -303,35 +319,124 @@ export default function RecoveryDashboardOverview({ if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { return (
- Keine aktuellen Vitalwerte + {vitalsData?.metadata?.message || 'Keine aktuellen Vitalwerte'}
) } - const chartData = vitalsData.data.labels.map((label, i) => ({ - name: label, - value: vitalsData.data.datasets[0]?.data[i], + const items = vitalsData.metadata?.vital_items || [] + const chartRows = items.map((it) => ({ + name: it.label_de, + value: it.bar_value ?? 0, + fill: barFillForTone(it.tone), + tone: it.tone, })) + + const vitDate = vitalsData.metadata?.vitals_measured_at + const bpDate = vitalsData.metadata?.blood_pressure_measured_at + const disclaimer = vitalsData.metadata?.disclaimer_de + return ( <> - - - - - - - - - -
- Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage) + {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} + + {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)} + + ) : null} + {vitDate && bpDate ? ' · ' : null} + {bpDate ? ( + <> + Blutdruck Stand: {fmtDate(bpDate)} + + ) : null} + {!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage : null}
+ {disclaimer ? ( +
{disclaimer}
+ ) : null} ) } -- 2.43.0 From 819914b7cc457a833d222cf706eaf7b86343d744 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:44:25 +0200 Subject: [PATCH 2/2] refactor: improve vital signs data handling and frontend display - Introduced a new function `_merge_vitals_baseline_rows` to streamline the retrieval and merging of vital signs data, ensuring the latest non-empty values are prioritized. - Updated SQL queries in `build_vital_signs_matrix_chart_payload` to enhance data retrieval efficiency and accuracy. - Refactored the `renderVitalSigns` function in the `RecoveryDashboardOverview` component to improve handling of vital signs data, including better fallback messaging and chart rendering logic. - Enhanced user feedback by providing clearer messages when no vital data is available, improving overall user experience. --- backend/data_layer/recovery_chart_payloads.py | 54 +++++++++++------- .../components/RecoveryDashboardOverview.jsx | 55 ++++++++++++++++--- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index f58b4ab..34d4f50 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 +from typing import Any, Dict, Optional from db import get_db, get_cursor from data_layer.recovery_metrics import ( @@ -356,15 +356,38 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] } +VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate") + + 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"): + for k in VITAL_BASELINE_KEYS: if row.get(k) is not None: return True return False +def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]: + """ + Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC). + So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist. + """ + if not rows: + return None, None + merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS} + for row in rows: + for k in VITAL_BASELINE_KEYS: + if merged[k] is None and row.get(k) is not None: + merged[k] = row[k] + if all(merged[k] is not None for k in VITAL_BASELINE_KEYS): + break + if not _vitals_row_has_any_value(merged): + return None, None + newest_date = rows[0].get("date") if rows else None + return merged, newest_date + + def _bp_row_complete(row: Any) -> bool: return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None) @@ -381,10 +404,10 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s 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 + vitals_for_items: Optional[Dict[str, Any]] = None with get_db() as conn: cur = get_cursor(conn) @@ -393,27 +416,24 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s FROM vitals_baseline WHERE profile_id=%s AND date >= %s ORDER BY date DESC - LIMIT 1""", + LIMIT 200""", (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): + vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall()) + if vitals_merged is None: 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""", + LIMIT 400""", (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) + vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall()) + if vitals_merged is not None: + vitals_for_items = dict(vitals_merged) + if vitals_date is not None: + vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date) cur.execute( """SELECT measured_at, systolic, diastolic @@ -440,10 +460,6 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s 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: bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index 6a3b558..633f3e3 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -316,24 +316,57 @@ export default function RecoveryDashboardOverview({ } const renderVitalSigns = () => { - if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { + if (!vitalsData) { return (
- {vitalsData?.metadata?.message || 'Keine aktuellen Vitalwerte'} + Keine Vital-Matrix-Daten
) } - const items = vitalsData.metadata?.vital_items || [] - const chartRows = items.map((it) => ({ + 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) { + return ( +
+ {meta.message || 'Keine aktuellen Vitalwerte'} +
+ ) + } + + let chartRows = items.map((it) => ({ name: it.label_de, - value: it.bar_value ?? 0, + 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', + })) + } - const vitDate = vitalsData.metadata?.vitals_measured_at - const bpDate = vitalsData.metadata?.blood_pressure_measured_at - const disclaimer = vitalsData.metadata?.disclaimer_de + if (items.length === 0 && chartRows.length === 0) { + return ( +
+ Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten). +
+ ) + } + + const vitDate = meta.vitals_measured_at + const bpDate = meta.blood_pressure_measured_at + const disclaimer = meta.disclaimer_de return ( <> @@ -391,6 +424,12 @@ 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 ? ( <>
-- 2.43.0