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 __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict, Optional
from db import get_db, get_cursor from db import get_db, get_cursor
from data_layer.recovery_metrics import ( 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: def _vitals_row_has_any_value(row: Any) -> bool:
if not row: if not row:
return False 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: if row.get(k) is not None:
return True return True
return False 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: def _bp_row_complete(row: Any) -> bool:
return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None) 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 days = 365
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
vitals_row = None
bp_row = None bp_row = None
vitals_measured_at = None vitals_measured_at = None
bp_measured_at = None bp_measured_at = None
vitals_for_items: Optional[Dict[str, Any]] = None
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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 FROM vitals_baseline
WHERE profile_id=%s AND date >= %s WHERE profile_id=%s AND date >= %s
ORDER BY date DESC ORDER BY date DESC
LIMIT 1""", LIMIT 200""",
(profile_id, cutoff), (profile_id, cutoff),
) )
vitals_row = cur.fetchone() vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_row and vitals_row.get("date") is not None: if vitals_merged is 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( cur.execute(
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate """SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline FROM vitals_baseline
WHERE profile_id=%s WHERE profile_id=%s
ORDER BY date DESC ORDER BY date DESC
LIMIT 1""", LIMIT 400""",
(profile_id,), (profile_id,),
) )
vitals_row = cur.fetchone() vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_row and vitals_row.get("date") is not None: if vitals_merged is not None:
d = vitals_row["date"] vitals_for_items = dict(vitals_merged)
vitals_measured_at = d.isoformat() if hasattr(d, "isoformat") else str(d) if vitals_date is not None:
vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date)
cur.execute( cur.execute(
"""SELECT measured_at, systolic, diastolic """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: if bp_row and bp_row.get("measured_at") is not None:
bp_measured_at = bp_row["measured_at"] 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 bp_for_items = None
if bp_row: if bp_row:
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} 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 = () => { const renderVitalSigns = () => {
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { if (!vitalsData) {
return ( return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}> <div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{vitalsData?.metadata?.message || 'Keine aktuellen Vitalwerte'} Keine Vital-Matrix-Daten
</div> </div>
) )
} }
const items = vitalsData.metadata?.vital_items || [] const meta = vitalsData.metadata || {}
const chartRows = items.map((it) => ({ 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, name: it.label_de,
value: it.bar_value ?? 0, value: Number(it.bar_value ?? 0),
fill: barFillForTone(it.tone), fill: barFillForTone(it.tone),
tone: 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 if (items.length === 0 && chartRows.length === 0) {
const bpDate = vitalsData.metadata?.blood_pressure_measured_at return (
const disclaimer = vitalsData.metadata?.disclaimer_de <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 ( return (
<> <>
@ -391,6 +424,12 @@ export default function RecoveryDashboardOverview({
</div> </div>
) : null} ) : 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 ? ( {chartRows.length > 0 ? (
<> <>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>