Merge pull request 'Fitness History + recovery' (#96) from develop into main
Reviewed-on: #96
This commit is contained in:
commit
6743814904
|
|
@ -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 (
|
||||||
|
|
@ -19,6 +19,7 @@ from data_layer.recovery_metrics import (
|
||||||
get_sleep_quality_data,
|
get_sleep_quality_data,
|
||||||
)
|
)
|
||||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
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]:
|
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
|
|
@ -355,27 +356,87 @@ 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 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)
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
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:
|
if days < 7:
|
||||||
days = 7
|
days = 7
|
||||||
if days > 30:
|
if days > 365:
|
||||||
days = 30
|
days = 365
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
bp_row = None
|
||||||
|
vitals_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)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT 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 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_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 400""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
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(
|
cur.execute(
|
||||||
"""SELECT systolic, diastolic
|
"""SELECT measured_at, systolic, diastolic
|
||||||
FROM blood_pressure_log
|
FROM blood_pressure_log
|
||||||
WHERE profile_id=%s AND measured_at::date >= %s::date
|
WHERE profile_id=%s AND measured_at::date >= %s::date
|
||||||
ORDER BY measured_at DESC
|
ORDER BY measured_at DESC
|
||||||
|
|
@ -383,74 +444,85 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
|
||||||
(profile_id, cutoff),
|
(profile_id, cutoff),
|
||||||
)
|
)
|
||||||
bp_row = cur.fetchone()
|
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:
|
if not _bp_row_complete(bp_row):
|
||||||
return {
|
cur.execute(
|
||||||
"chart_type": "bar",
|
"""SELECT measured_at, systolic, diastolic
|
||||||
"data": {"labels": [], "datasets": []},
|
FROM blood_pressure_log
|
||||||
"metadata": {
|
WHERE profile_id=%s
|
||||||
"confidence": "insufficient",
|
ORDER BY measured_at DESC
|
||||||
"data_points": 0,
|
LIMIT 1""",
|
||||||
"message": "Keine aktuellen Vitalwerte",
|
(profile_id,),
|
||||||
},
|
)
|
||||||
}
|
bp_row = cur.fetchone()
|
||||||
|
if bp_row and bp_row.get("measured_at") is not None:
|
||||||
labels = []
|
bp_measured_at = bp_row["measured_at"]
|
||||||
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"]))
|
|
||||||
|
|
||||||
|
bp_for_items = None
|
||||||
if bp_row:
|
if bp_row:
|
||||||
if bp_row["systolic"]:
|
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
|
||||||
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"]))
|
|
||||||
|
|
||||||
if not labels:
|
items = build_vital_items_from_rows(vitals_for_items, bp_for_items)
|
||||||
|
|
||||||
|
if not items:
|
||||||
return {
|
return {
|
||||||
"chart_type": "bar",
|
"chart_type": "bar",
|
||||||
"data": {"labels": [], "datasets": []},
|
"data": {"labels": [], "datasets": []},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": 0,
|
"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 {
|
return {
|
||||||
"chart_type": "bar",
|
"chart_type": "bar",
|
||||||
"data": {
|
"data": {
|
||||||
"labels": labels,
|
"labels": labels_short,
|
||||||
"datasets": [
|
"datasets": [
|
||||||
{
|
{
|
||||||
"label": "Wert",
|
"label": "Einschätzung (relativ)",
|
||||||
"data": values,
|
"data": bar_values,
|
||||||
"backgroundColor": "#1D9E75",
|
"backgroundColor": colors,
|
||||||
"borderColor": "#085041",
|
"borderColor": colors,
|
||||||
"borderWidth": 1,
|
"borderWidth": 1,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": serialize_dates(
|
||||||
"confidence": "medium",
|
{
|
||||||
"data_points": len(values),
|
"confidence": "medium",
|
||||||
"note": "Latest measurements within last " + str(days) + " days",
|
"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.",
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,8 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
|
||||||
all_history = days >= 9999
|
all_history = days >= 9999
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||||
chart_days = min(90, max(7, min(eff_days, 365)))
|
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)
|
recovery_score_val = calculate_recovery_score_v2(profile_id)
|
||||||
sleep_debt = calculate_sleep_debt_hours(profile_id)
|
sleep_debt = calculate_sleep_debt_hours(profile_id)
|
||||||
|
|
|
||||||
153
backend/data_layer/vital_signs_assessment.py
Normal file
153
backend/data_layer/vital_signs_assessment.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -1431,7 +1431,7 @@ def get_sleep_debt_chart(
|
||||||
|
|
||||||
@router.get("/vital-signs-matrix")
|
@router.get("/vital-signs-matrix")
|
||||||
def get_vital_signs_matrix_chart(
|
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)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Vital signs matrix (R5)."""
|
"""Vital signs matrix (R5)."""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Line,
|
Line,
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
|
Cell,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -13,11 +14,26 @@ import {
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import KpiTilesOverview from './KpiTilesOverview'
|
import KpiTilesOverview from './KpiTilesOverview'
|
||||||
import { getStatusColor } from '../utils/interpret'
|
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const fmtDate = (d) => dayjs(d).format('DD.MM.')
|
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 }) {
|
function ChartCard({ title, loading, error, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
|
@ -300,38 +316,166 @@ 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 }}>
|
||||||
Keine aktuellen Vitalwerte
|
Keine Vital-Matrix-Daten
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const chartData = vitalsData.data.labels.map((label, i) => ({
|
const meta = vitalsData.metadata || {}
|
||||||
name: label,
|
const items = meta.vital_items || []
|
||||||
value: vitalsData.data.datasets[0]?.data[i],
|
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: 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',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={250}>
|
{items.length > 0 ? (
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 20 }} layout="horizontal">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
{items.map((it) => {
|
||||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
const stripe =
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={120} />
|
it.tone === 'good'
|
||||||
<Tooltip
|
? getStatusColor('good')
|
||||||
contentStyle={{
|
: it.tone === 'bad'
|
||||||
background: 'var(--surface)',
|
? getStatusColor('bad')
|
||||||
border: '1px solid var(--border)',
|
: it.tone === 'warn'
|
||||||
borderRadius: 8,
|
? getStatusColor('warn')
|
||||||
fontSize: 11,
|
: '#6B7280'
|
||||||
}}
|
const bg =
|
||||||
/>
|
it.tone === 'good'
|
||||||
<Bar dataKey="value" fill="#1D9E75" name="Wert" />
|
? getStatusBg('good')
|
||||||
</BarChart>
|
: it.tone === 'bad'
|
||||||
</ResponsiveContainer>
|
? getStatusBg('bad')
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
: it.tone === 'warn'
|
||||||
Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage)
|
? getStatusBg('warn')
|
||||||
|
: 'var(--surface2)'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.key}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: `4px solid ${stripe}`,
|
||||||
|
background: bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>{it.label_de}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{it.value_display}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: stripe,
|
||||||
|
border: `1px solid ${stripe}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.zone_label_de}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45 }}>{it.hint_de}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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 }}>
|
||||||
|
Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße)
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
|
||||||
|
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
|
||||||
|
{chartRows.map((row, i) => (
|
||||||
|
<Cell key={`c-${i}`} fill={row.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
{vitDate ? (
|
||||||
|
<>
|
||||||
|
Baseline-Vitals Stand: <strong>{fmtDate(vitDate)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{vitDate && bpDate ? ' · ' : null}
|
||||||
|
{bpDate ? (
|
||||||
|
<>
|
||||||
|
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage</> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{disclaimer ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user