feat: enhance recovery dashboard with optional average sleep KPI and structured insights
- Added an `include_avg_sleep_kpi` parameter to the `build_recovery_dashboard_kpi_tiles` function to conditionally include average sleep data in the dashboard. - Updated the `get_recovery_dashboard_viz_bundle` function to pass the new parameter, ensuring flexibility in data presentation. - Refactored the insights generation in the `vitals_fitness_insights.py` file to utilize a new structured approach for better organization of heart and VO2 insights. - Introduced new components in the frontend for displaying insights, improving the user experience and clarity of vital metrics.
This commit is contained in:
parent
857cc1043a
commit
61738cecb7
|
|
@ -42,6 +42,7 @@ def build_recovery_dashboard_kpi_tiles(
|
||||||
hrv_vs_baseline_pct: Optional[float],
|
hrv_vs_baseline_pct: Optional[float],
|
||||||
rhr_vs_baseline_pct: Optional[float],
|
rhr_vs_baseline_pct: Optional[float],
|
||||||
merge_heart_autonomic_tiles: bool = True,
|
merge_heart_autonomic_tiles: bool = True,
|
||||||
|
include_avg_sleep_kpi: bool = True,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
tiles: List[Dict[str, Any]] = []
|
tiles: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
@ -79,20 +80,21 @@ def build_recovery_dashboard_kpi_tiles(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
tiles.append(
|
if include_avg_sleep_kpi:
|
||||||
{
|
tiles.append(
|
||||||
"key": "avg_sleep",
|
{
|
||||||
"category": "Ø Schlafdauer",
|
"key": "avg_sleep",
|
||||||
"icon": "🌙",
|
"category": "Ø Schlafdauer",
|
||||||
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—",
|
"icon": "🌙",
|
||||||
"sublabel": "Im gewählten Fenster",
|
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—",
|
||||||
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
|
"sublabel": "Im gewählten Fenster",
|
||||||
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
|
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
|
||||||
"hoverTop": "Durchschnittliche Schlafdauer",
|
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
|
||||||
"hoverBody": "get_sleep_duration_data",
|
"hoverTop": "Durchschnittliche Schlafdauer",
|
||||||
"keys": ["sleep_duration_avg"],
|
"hoverBody": "get_sleep_duration_data",
|
||||||
}
|
"keys": ["sleep_duration_avg"],
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if merge_heart_autonomic_tiles and (
|
if merge_heart_autonomic_tiles and (
|
||||||
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
|
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
|
||||||
avg_sleep,
|
avg_sleep,
|
||||||
float(hrv_dev) if hrv_dev is not None else None,
|
float(hrv_dev) if hrv_dev is not None else None,
|
||||||
float(rhr_dev) if rhr_dev is not None else None,
|
float(rhr_dev) if rhr_dev is not None else None,
|
||||||
|
include_avg_sleep_kpi=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
insights = build_recovery_progress_insights(
|
insights = build_recovery_progress_insights(
|
||||||
|
|
|
||||||
|
|
@ -93,40 +93,56 @@ def _de_num_signed(x: float) -> str:
|
||||||
return f"{x:+.1f}".replace(".", ",")
|
return f"{x:+.1f}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
def _build_consolidated_paragraphs(
|
def _ins(
|
||||||
|
key: str,
|
||||||
|
section: str,
|
||||||
|
title_de: str,
|
||||||
|
body: str,
|
||||||
|
tone: str = "neutral",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
|
||||||
|
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_section_insights(
|
||||||
series: Dict[str, Any],
|
series: Dict[str, Any],
|
||||||
hrv_vs_baseline_pct: Optional[float],
|
hrv_vs_baseline_pct: Optional[float],
|
||||||
rhr_vs_baseline_pct: Optional[float],
|
rhr_vs_baseline_pct: Optional[float],
|
||||||
r_pearson: Optional[float],
|
r_pearson: Optional[float],
|
||||||
pairs_n: int,
|
pairs_n: int,
|
||||||
) -> List[str]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets),
|
Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
|
||||||
ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden).
|
section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
|
||||||
"""
|
"""
|
||||||
paras: List[str] = []
|
out: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
# ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt
|
|
||||||
basis_bits: List[str] = []
|
basis_bits: List[str] = []
|
||||||
if hrv_vs_baseline_pct is not None:
|
if hrv_vs_baseline_pct is not None:
|
||||||
basis_bits.append(
|
basis_bits.append(
|
||||||
f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %"
|
f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
|
||||||
)
|
)
|
||||||
if rhr_vs_baseline_pct is not None:
|
if rhr_vs_baseline_pct is not None:
|
||||||
basis_bits.append(
|
basis_bits.append(
|
||||||
f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %"
|
f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
|
||||||
)
|
)
|
||||||
if basis_bits:
|
if basis_bits:
|
||||||
paras.append(
|
out.append(
|
||||||
" ".join(basis_bits)
|
_ins(
|
||||||
+ " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren."
|
"heart_baseline",
|
||||||
|
"heart",
|
||||||
|
"Kurzfristiges Mittel vs. ältere Basis",
|
||||||
|
" ".join(basis_bits)
|
||||||
|
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
|
||||||
|
"neutral",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
rhr = series.get("resting_hr")
|
rhr = series.get("resting_hr")
|
||||||
hrv_s = series.get("hrv")
|
hrv_s = series.get("hrv")
|
||||||
|
|
||||||
# ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten)
|
rhr_short_body = ""
|
||||||
rhr_short_compare = ""
|
r_short_tone = "neutral"
|
||||||
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
|
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
|
||||||
pts = rhr["points"]
|
pts = rhr["points"]
|
||||||
last7 = [p["value"] for p in pts[-7:]]
|
last7 = [p["value"] for p in pts[-7:]]
|
||||||
|
|
@ -136,88 +152,110 @@ def _build_consolidated_paragraphs(
|
||||||
mb = statistics.mean(before)
|
mb = statistics.mean(before)
|
||||||
diff = m7 - mb
|
diff = m7 - mb
|
||||||
if diff > 3:
|
if diff > 3:
|
||||||
rhr_short_compare = (
|
rhr_short_body = (
|
||||||
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
|
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
|
||||||
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
|
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
|
||||||
)
|
)
|
||||||
|
r_short_tone = "warn"
|
||||||
elif diff < -3:
|
elif diff < -3:
|
||||||
rhr_short_compare = (
|
rhr_short_body = (
|
||||||
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
|
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
|
||||||
"besserer Regeneration vereinbar (individuell)."
|
"besserer Regeneration vereinbar (individuell)."
|
||||||
)
|
)
|
||||||
|
r_short_tone = "good"
|
||||||
|
|
||||||
# ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise
|
|
||||||
rhr_var_sentence = ""
|
rhr_var_sentence = ""
|
||||||
if (
|
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
|
||||||
rhr
|
|
||||||
and rhr.get("stdev") is not None
|
|
||||||
and rhr.get("n", 0) >= 6
|
|
||||||
):
|
|
||||||
rhr_var_sentence = (
|
rhr_var_sentence = (
|
||||||
f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; "
|
f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
|
||||||
"extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
|
"sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
|
||||||
)
|
)
|
||||||
|
|
||||||
hrv_var_sentence = ""
|
hrv_var_sentence = ""
|
||||||
if (
|
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
|
||||||
hrv_s
|
|
||||||
and hrv_s.get("stdev") is not None
|
|
||||||
and hrv_s.get("n", 0) >= 6
|
|
||||||
):
|
|
||||||
hrv_var_sentence = (
|
hrv_var_sentence = (
|
||||||
f"HRV schwankt im Fenster (σ ≈ {_de_num(float(hrv_s['stdev']))} ms). "
|
f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
|
||||||
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
|
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt)
|
|
||||||
ma_hint = (
|
ma_hint = (
|
||||||
"Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert "
|
"Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
|
||||||
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
|
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
|
||||||
)
|
)
|
||||||
|
|
||||||
block_b_parts: List[str] = []
|
streuung_parts: List[str] = [ma_hint]
|
||||||
if rhr_short_compare:
|
|
||||||
block_b_parts.append(rhr_short_compare)
|
|
||||||
if rhr_var_sentence:
|
if rhr_var_sentence:
|
||||||
block_b_parts.append(rhr_var_sentence)
|
streuung_parts.append(rhr_var_sentence)
|
||||||
if hrv_var_sentence:
|
if hrv_var_sentence:
|
||||||
block_b_parts.append(hrv_var_sentence)
|
streuung_parts.append(hrv_var_sentence)
|
||||||
if block_b_parts:
|
if rhr or hrv_s:
|
||||||
paras.append(ma_hint + " " + " ".join(block_b_parts))
|
out.append(
|
||||||
elif series:
|
_ins(
|
||||||
# Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm)
|
"heart_streuung_ma",
|
||||||
paras.append(ma_hint)
|
"heart",
|
||||||
|
"Streuung & gleitender Mittelwert",
|
||||||
|
" ".join(streuung_parts),
|
||||||
|
"neutral",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if rhr_short_body:
|
||||||
|
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
|
||||||
|
|
||||||
# ── VO2max: Wortlaut wie in den früheren Bullet-Karten
|
|
||||||
vo2 = series.get("vo2_max")
|
vo2 = series.get("vo2_max")
|
||||||
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
|
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
|
||||||
s = vo2["slope_per_day"]
|
s = vo2["slope_per_day"]
|
||||||
if s > 0.002:
|
if s > 0.002:
|
||||||
paras.append(
|
out.append(
|
||||||
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
|
_ins(
|
||||||
"besserer Datenlage vereinbar."
|
"vo2_trend_up",
|
||||||
|
"vo2",
|
||||||
|
"VO2max-Verlauf",
|
||||||
|
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
|
||||||
|
"besserer Datenlage vereinbar.",
|
||||||
|
"good",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif s < -0.002:
|
elif s < -0.002:
|
||||||
paras.append(
|
out.append(
|
||||||
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
|
_ins(
|
||||||
"entstehen; Verlauf beobachten."
|
"vo2_trend_down",
|
||||||
|
"vo2",
|
||||||
|
"VO2max-Verlauf",
|
||||||
|
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
|
||||||
|
"entstehen; Verlauf beobachten.",
|
||||||
|
"warn",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet
|
|
||||||
if r_pearson is not None and pairs_n >= 8:
|
if r_pearson is not None and pairs_n >= 8:
|
||||||
if r_pearson > 0.35:
|
if r_pearson > 0.35:
|
||||||
paras.append(
|
out.append(
|
||||||
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen "
|
_ins(
|
||||||
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). "
|
"heart_load_rhr",
|
||||||
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
|
"heart",
|
||||||
|
"Training und Folge-Ruhepuls",
|
||||||
|
(
|
||||||
|
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen "
|
||||||
|
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). "
|
||||||
|
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
|
||||||
|
),
|
||||||
|
"warn",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif r_pearson < -0.25:
|
elif r_pearson < -0.25:
|
||||||
paras.append(
|
out.append(
|
||||||
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
|
_ins(
|
||||||
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare."
|
"heart_load_rhr_neg",
|
||||||
|
"heart",
|
||||||
|
"Training und Folge-Ruhepuls",
|
||||||
|
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
|
||||||
|
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
|
||||||
|
"neutral",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return [p for p in paras if p]
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
||||||
|
|
@ -318,7 +356,7 @@ def build_vitals_history_and_analytics(
|
||||||
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
||||||
pairs_n = len(pairs_load)
|
pairs_n = len(pairs_load)
|
||||||
|
|
||||||
consolidated = _build_consolidated_paragraphs(
|
section_insights = _build_section_insights(
|
||||||
series,
|
series,
|
||||||
hrv_vs_baseline_pct,
|
hrv_vs_baseline_pct,
|
||||||
rhr_vs_baseline_pct,
|
rhr_vs_baseline_pct,
|
||||||
|
|
@ -331,7 +369,11 @@ def build_vitals_history_and_analytics(
|
||||||
"chart_type": "vitals_dashboard",
|
"chart_type": "vitals_dashboard",
|
||||||
"window_days": days,
|
"window_days": days,
|
||||||
"series": {},
|
"series": {},
|
||||||
"analytics": {"bullets": [], "consolidated_paragraphs": consolidated},
|
"analytics": {
|
||||||
|
"bullets": [],
|
||||||
|
"consolidated_paragraphs": [],
|
||||||
|
"section_insights": section_insights,
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"message": "Keine Vital-Zeitreihen im Fenster",
|
"message": "Keine Vital-Zeitreihen im Fenster",
|
||||||
|
|
@ -346,7 +388,8 @@ def build_vitals_history_and_analytics(
|
||||||
"series": serialize_dates(series),
|
"series": serialize_dates(series),
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"bullets": [],
|
"bullets": [],
|
||||||
"consolidated_paragraphs": consolidated,
|
"consolidated_paragraphs": [],
|
||||||
|
"section_insights": section_insights,
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,120 @@ function VitalZoneHint({ item }) {
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Zuletzt (Snapshot):</span>
|
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Letzte Einordnung (Snapshot):</span>
|
||||||
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
|
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
|
||||||
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
|
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */
|
||||||
|
function HeartAutonomicGuide() {
|
||||||
|
return (
|
||||||
|
<details style={{ marginBottom: 12, fontSize: 11, color: 'var(--text2)' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text3)', listStyle: 'none' }}>
|
||||||
|
Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 8, lineHeight: 1.5, paddingLeft: 2 }}>
|
||||||
|
<p style={{ margin: '0 0 8px' }}>
|
||||||
|
Es handelt sich um <strong>Abweichungen in %</strong> vom älteren Referenzmittel (kurzfristiges Mittel vs. längere
|
||||||
|
Basis) — nicht um absolute Normalwerte.
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
<li>
|
||||||
|
<strong>HRV</strong>: Positive % = zuletzt oft über der älteren Basis; sehr personenabhängig.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Ruhepuls</strong>: Negative % = niedriger als die Referenz; bei Training oft unkritisch günstig.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ margin: '8px 0 0' }}>
|
||||||
|
Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert
|
||||||
|
anzeigen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionInsightCard({ ins }) {
|
||||||
|
const t = ['good', 'warn', 'bad', 'neutral'].includes(ins.tone) ? ins.tone : 'neutral'
|
||||||
|
const stripe = insightBulletStripe(t)
|
||||||
|
const bg =
|
||||||
|
t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: `4px solid ${stripe}`,
|
||||||
|
background: bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4, color: 'var(--text1)' }}>{ins.title_de}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnapshotCards({ items }) {
|
||||||
|
if (!items?.length) return null
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 12 }}>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ChartCard({ title, loading, error, children, description }) {
|
function ChartCard({ title, loading, error, children, description }) {
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
|
@ -168,11 +275,10 @@ export default function RecoveryDashboardOverview({
|
||||||
vitalItemsByKey[it.key] = it
|
vitalItemsByKey[it.key] = it
|
||||||
})
|
})
|
||||||
|
|
||||||
const showVitalNarrativeBlock =
|
const sectionInsights = vitalsHistory?.analytics?.section_insights || []
|
||||||
vitalsHistory &&
|
const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart')
|
||||||
vitalsHistory.metadata?.confidence !== 'insufficient' &&
|
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
|
||||||
(((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 ||
|
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
|
||||||
(vitalsHistory.analytics?.bullets || []).length > 0))
|
|
||||||
|
|
||||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
|
|
@ -383,62 +489,8 @@ export default function RecoveryDashboardOverview({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */
|
/** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
|
||||||
const renderVitalBelastungNarrative = () => {
|
const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => {
|
||||||
const vh = vitalsHistory
|
|
||||||
if (!vh || vh.metadata?.confidence === 'insufficient') return null
|
|
||||||
const paragraphs = vh.analytics?.consolidated_paragraphs || []
|
|
||||||
const bullets = vh.analytics?.bullets || []
|
|
||||||
const showParagraphs = paragraphs.length > 0
|
|
||||||
const showBulletsFallback = !showParagraphs && bullets.length > 0
|
|
||||||
if (!showParagraphs && !showBulletsFallback) return null
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 0 }}>
|
|
||||||
{showParagraphs ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '12px 14px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderLeft: '4px solid var(--accent)',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--text2)',
|
|
||||||
lineHeight: 1.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{paragraphs.map((text, i) => (
|
|
||||||
<p key={i} style={{ margin: i === 0 ? 0 : '10px 0 0' }}>
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{showBulletsFallback ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{bullets.map((b) => (
|
|
||||||
<div
|
|
||||||
key={b.key}
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderLeft: `4px solid ${insightBulletStripe(b.tone)}`,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{b.title}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{b.body}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */
|
|
||||||
const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => {
|
|
||||||
const vh = vitalsHistory
|
const vh = vitalsHistory
|
||||||
if (!vh) {
|
if (!vh) {
|
||||||
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
|
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
|
||||||
|
|
@ -462,6 +514,13 @@ export default function RecoveryDashboardOverview({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', minWidth: 0 }}>
|
<div style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
{vo2Insights.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||||
|
{vo2Insights.map((ins) => (
|
||||||
|
<SectionInsightCard key={ins.key} ins={ins} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||||
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
|
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
|
||||||
begrenzt.
|
begrenzt.
|
||||||
|
|
@ -556,106 +615,6 @@ export default function RecoveryDashboardOverview({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderVitalSigns = () => {
|
|
||||||
if (!vitalsData) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
|
||||||
Keine Snapshot-Daten zur Vital-Matrix.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const meta = vitalsData.metadata || {}
|
|
||||||
const items = meta.vital_items || []
|
|
||||||
const ins = meta.confidence === 'insufficient'
|
|
||||||
if (ins && items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
|
||||||
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const vitDate = meta.vitals_measured_at
|
|
||||||
const bpDate = meta.blood_pressure_measured_at
|
|
||||||
const disclaimer = meta.disclaimer_de
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{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}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
||||||
{vitDate ? (
|
|
||||||
<>
|
|
||||||
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{vitDate && bpDate ? ' · ' : null}
|
|
||||||
{bpDate ? (
|
|
||||||
<>
|
|
||||||
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
|
|
||||||
</div>
|
|
||||||
{disclaimer ? (
|
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||||
|
|
@ -687,9 +646,11 @@ export default function RecoveryDashboardOverview({
|
||||||
|
|
||||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
|
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
|
||||||
|
|
||||||
{insights.length > 0 || showVitalNarrativeBlock ? (
|
{insights.length > 0 ? (
|
||||||
<div style={{ marginBottom: 18 }}>
|
<div style={{ marginBottom: 18 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||||
|
Überblick: Recovery & Schlaf
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{insights.map((ins) => {
|
{insights.map((ins) => {
|
||||||
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
|
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
|
||||||
|
|
@ -709,14 +670,6 @@ export default function RecoveryDashboardOverview({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{showVitalNarrativeBlock ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
|
||||||
Vitalverlauf & Belastung (Text)
|
|
||||||
</div>
|
|
||||||
{renderVitalBelastungNarrative()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -732,7 +685,14 @@ export default function RecoveryDashboardOverview({
|
||||||
>
|
>
|
||||||
{renderRecoveryScore()}
|
{renderRecoveryScore()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Schlaf: Dauer & Qualität" description="Dauer (h) und Qualitätsanteil (%) — eigene Achsen.">
|
<ChartCard
|
||||||
|
title="Schlaf: Dauer & Qualität"
|
||||||
|
description={
|
||||||
|
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
|
||||||
|
? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.`
|
||||||
|
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
|
||||||
|
}
|
||||||
|
>
|
||||||
{renderSleepQuality()}
|
{renderSleepQuality()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
|
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
|
||||||
|
|
@ -741,29 +701,56 @@ export default function RecoveryDashboardOverview({
|
||||||
|
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Herz & Kreislauf"
|
title="Herz & Kreislauf"
|
||||||
hint="Ruhepuls und HRV nur hier im kombinierten Verlauf — keine zweite Darstellung darunter."
|
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
|
||||||
/>
|
/>
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
|
||||||
|
<HeartAutonomicGuide />
|
||||||
|
{heartSectionInsights.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||||||
|
{heartSectionInsights.map((ins) => (
|
||||||
|
<SectionInsightCard key={ins.key} ins={ins} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
|
||||||
|
<SnapshotCards items={heartSnapshotItems} />
|
||||||
|
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||||||
|
{vitalsData?.metadata?.vitals_measured_at ? (
|
||||||
|
<>
|
||||||
|
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
|
||||||
|
{vitalsData?.metadata?.blood_pressure_measured_at ? (
|
||||||
|
<>
|
||||||
|
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{vitalsData?.metadata?.disclaimer_de ? (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
|
||||||
|
{vitalsData.metadata.disclaimer_de}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="HRV & Ruhepuls"
|
title="HRV & Ruhepuls — Zeitverlauf"
|
||||||
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleiche Tage wie oben."
|
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
|
||||||
>
|
>
|
||||||
{renderHrvRhr()}
|
{renderHrvRhr()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Weitere Vitalparameter (Verlauf)"
|
title="Weitere Vitalparameter (Verlauf)"
|
||||||
hint="VO2max, SpO2 und Atemfrequenz mit Zonen-Einordnung zum letzten Snapshot (farbig unter dem Titel)."
|
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
|
||||||
/>
|
/>
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
|
||||||
{renderWeitereVitalVerlaeufe(vitalItemsByKey)}
|
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionHeading
|
|
||||||
title="Aktuelle Messwerte & Zonen"
|
|
||||||
hint="Neueste Baseline-Vitals und Blutdruck im Fenster — gleiche Logik wie Vital-Seite (keine Diagnose)."
|
|
||||||
/>
|
|
||||||
<ChartCard title="Snapshot & Einordnung">{renderVitalSigns()}</ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user