refactor: improve vital signs data handling and frontend display
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- 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.
This commit is contained in:
Lars 2026-04-20 08:44:25 +02:00
parent d4868b3797
commit 819914b7cc
2 changed files with 82 additions and 27 deletions

View File

@ -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")}

View File

@ -316,24 +316,57 @@ export default function RecoveryDashboardOverview({
}
const renderVitalSigns = () => {
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
if (!vitalsData) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{vitalsData?.metadata?.message || 'Keine aktuellen Vitalwerte'}
Keine Vital-Matrix-Daten
</div>
)
}
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 (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{meta.message || 'Keine aktuellen Vitalwerte'}
</div>
)
}
let chartRows = items.map((it) => ({
name: it.label_de,
value: 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 (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
</div>
)
}
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({
</div>
) : null}
{items.length === 0 && chartRows.length > 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Diagramm aus Server-Daten (ohne Zonen-Detail bitte App aktualisieren oder Cache leeren).
</div>
) : null}
{chartRows.length > 0 ? (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>