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 (