feat: enhance vital signs matrix chart payload and visualization
- Introduced new functions to handle vital signs data retrieval and processing, including fallback mechanisms for missing values. - Updated SQL queries in `build_vital_signs_matrix_chart_payload` to improve date filtering and data accuracy. - Enhanced the frontend `RecoveryDashboardOverview` component to display vital signs with contextual coloring based on health tones. - Adjusted the data structure for chart rendering, ensuring a more informative and visually appealing representation of vital metrics.
This commit is contained in:
parent
d3cb9d4ad9
commit
d4868b3797
|
|
@ -19,6 +19,7 @@ from data_layer.recovery_metrics import (
|
|||
get_sleep_quality_data,
|
||||
)
|
||||
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]:
|
||||
|
|
@ -355,17 +356,40 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
|
|||
}
|
||||
|
||||
|
||||
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"):
|
||||
if row.get(k) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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]:
|
||||
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1)."""
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 30:
|
||||
days = 30
|
||||
if days > 365:
|
||||
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
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC
|
||||
|
|
@ -373,9 +397,26 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
|
|||
(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):
|
||||
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""",
|
||||
(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)
|
||||
|
||||
cur.execute(
|
||||
"""SELECT systolic, diastolic
|
||||
"""SELECT measured_at, systolic, diastolic
|
||||
FROM blood_pressure_log
|
||||
WHERE profile_id=%s AND measured_at::date >= %s::date
|
||||
ORDER BY measured_at DESC
|
||||
|
|
@ -383,74 +424,89 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
|
|||
(profile_id, cutoff),
|
||||
)
|
||||
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:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine aktuellen Vitalwerte",
|
||||
},
|
||||
}
|
||||
|
||||
labels = []
|
||||
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"]))
|
||||
if not _bp_row_complete(bp_row):
|
||||
cur.execute(
|
||||
"""SELECT measured_at, systolic, diastolic
|
||||
FROM blood_pressure_log
|
||||
WHERE profile_id=%s
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1""",
|
||||
(profile_id,),
|
||||
)
|
||||
bp_row = cur.fetchone()
|
||||
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:
|
||||
if bp_row["systolic"]:
|
||||
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"]))
|
||||
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
|
||||
|
||||
if not labels:
|
||||
items = build_vital_items_from_rows(vitals_for_items, bp_for_items)
|
||||
|
||||
if not items:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"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 {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"labels": labels_short,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Wert",
|
||||
"data": values,
|
||||
"backgroundColor": "#1D9E75",
|
||||
"borderColor": "#085041",
|
||||
"label": "Einschätzung (relativ)",
|
||||
"data": bar_values,
|
||||
"backgroundColor": colors,
|
||||
"borderColor": colors,
|
||||
"borderWidth": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"data_points": len(values),
|
||||
"note": "Latest measurements within last " + str(days) + " days",
|
||||
},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": "medium",
|
||||
"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
|
||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
) -> Dict:
|
||||
"""Vital signs matrix (R5)."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
|
|
@ -13,11 +14,26 @@ import {
|
|||
} from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor } from '../utils/interpret'
|
||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -303,35 +319,124 @@ export default function RecoveryDashboardOverview({
|
|||
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine aktuellen Vitalwerte
|
||||
{vitalsData?.metadata?.message || 'Keine aktuellen Vitalwerte'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = vitalsData.data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: vitalsData.data.datasets[0]?.data[i],
|
||||
const items = vitalsData.metadata?.vital_items || []
|
||||
const chartRows = items.map((it) => ({
|
||||
name: it.label_de,
|
||||
value: it.bar_value ?? 0,
|
||||
fill: barFillForTone(it.tone),
|
||||
tone: it.tone,
|
||||
}))
|
||||
|
||||
const vitDate = vitalsData.metadata?.vitals_measured_at
|
||||
const bpDate = vitalsData.metadata?.blood_pressure_measured_at
|
||||
const disclaimer = vitalsData.metadata?.disclaimer_de
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 20 }} layout="horizontal">
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#1D9E75" name="Wert" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage)
|
||||
{items.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
|
||||
{items.map((it) => {
|
||||
const stripe =
|
||||
it.tone === 'good'
|
||||
? getStatusColor('good')
|
||||
: it.tone === 'bad'
|
||||
? getStatusColor('bad')
|
||||
: it.tone === 'warn'
|
||||
? getStatusColor('warn')
|
||||
: '#6B7280'
|
||||
const bg =
|
||||
it.tone === 'good'
|
||||
? getStatusBg('good')
|
||||
: it.tone === 'bad'
|
||||
? getStatusBg('bad')
|
||||
: it.tone === 'warn'
|
||||
? 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}
|
||||
|
||||
{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>
|
||||
{disclaimer ? (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user