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 ? ( <>