diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 34a8850..8749e2b 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -91,11 +91,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A "hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days), "sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days), "sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days), - "vital_signs_matrix": build_vital_signs_matrix_chart_payload( - profile_id, - vital_days, - omit_snapshot_keys={"resting_hr", "hrv"}, - ), + "vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days), "vitals_history": build_vitals_history_and_analytics( profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f ), diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index 9fc1397..d08781b 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -8,6 +8,18 @@ import dayjs from 'dayjs' const fmtDate = (d) => dayjs(d).format('DD.MM.') +/** Nur diese Kennzahlen als eigene Verläufe — Ruhepuls/HRV nur im kombinierten Diagramm (keine Doppelung). */ +const VITAL_TREND_ONLY_KEYS = ['vo2_max', 'spo2', 'respiratory_rate'] + +function formatAxisTick(v) { + const n = Number(v) + if (!Number.isFinite(n)) return '' + const a = Math.abs(n) + if (a >= 100) return String(Math.round(n)) + if (a >= 10) return n.toFixed(1) + return Number(n.toFixed(2)).toString() +} + function insightBulletStripe(tone) { if (tone === 'good') return getStatusColor('good') if (tone === 'bad') return getStatusColor('bad') @@ -15,10 +27,51 @@ function insightBulletStripe(tone) { return getStatusColor('warn') } -function ChartCard({ title, loading, error, children }) { +function SectionHeading({ title, hint, compactTop }) { + return ( +
+
{title}
+ {hint ? ( +
{hint}
+ ) : null} +
+ ) +} + +function VitalZoneHint({ item }) { + if (!item) return null + const stripe = insightBulletStripe(item.tone) + const t = item.tone + const hintBg = + t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)' + return ( +
+ Zuletzt (Snapshot): + {item.zone_label_de} + {item.hint_de} +
+ ) +} + +function ChartCard({ title, loading, error, children, description }) { return (
-
{title}
+
{title}
+ {description ? ( +
{description}
+ ) : null} {loading && (
@@ -110,6 +163,17 @@ export default function RecoveryDashboardOverview({ const vitalsData = viz.charts?.vital_signs_matrix const vitalsHistory = viz.charts?.vitals_history + const vitalItemsByKey = {} + ;(vitalsData?.metadata?.vital_items || []).forEach((it) => { + vitalItemsByKey[it.key] = it + }) + + const showVitalNarrativeBlock = + vitalsHistory && + vitalsHistory.metadata?.confidence !== 'insufficient' && + (((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 || + (vitalsHistory.analytics?.bullets || []).length > 0)) + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: @@ -145,7 +209,14 @@ export default function RecoveryDashboardOverview({ tickLine={false} interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} /> - + (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} + tickCount={6} + width={36} + /> - - + + { + /** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */ + const renderVitalBelastungNarrative = () => { + 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 ( +
+ {showParagraphs ? ( +
+ {paragraphs.map((text, i) => ( +

+ {text} +

+ ))} +
+ ) : null} + {showBulletsFallback ? ( +
+ {bullets.map((b) => ( +
+
{b.title}
+
{b.body}
+
+ ))} +
+ ) : null} +
+ ) + } + + /** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */ + const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => { const vh = vitalsHistory if (!vh) { - return
Keine Verlaufs-Daten (Bundle).
+ return
Keine Verlaufs-Daten (Bundle).
} if (vh.metadata?.confidence === 'insufficient') { return ( -
+
{vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
) } const series = vh.series || {} - const keys = Object.keys(series) - const paragraphs = vh.analytics?.consolidated_paragraphs || [] - const bullets = vh.analytics?.bullets || [] - const showParagraphs = paragraphs.length > 0 - const showBulletsFallback = !showParagraphs && bullets.length > 0 + const keys = VITAL_TREND_ONLY_KEYS.filter((k) => series[k]?.points?.length) + if (keys.length === 0) { + return ( +
+ Keine zusätzlichen Vital-Verläufe (VO2max, SpO2, Atemfrequenz) im Fenster — oder nur Ruhepuls/HRV erfasst. +
+ ) + } return (
- {showParagraphs ? ( -
-
- Einordnung (Vital & Belastung) -
-
- {paragraphs.map((text, i) => ( -

- {text} -

- ))} -
-
- ) : null} - - {showBulletsFallback ? ( -
-
- Einordnung (Vital & Belastung) -
-
- {bullets.map((b) => ( -
-
{b.title}
-
{b.body}
-
- ))} -
-
- ) : null} - -
- Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende - Messungen), glättet starke Einzelschwankungen. +
+ Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich + begrenzt.
- {keys.map((k) => { const m = series[k] const pts = m.points || [] const maPts = m.points_ma7 || [] - if (pts.length === 0) return null + const zoneItem = vitalItemsByKey[k] const chartData = pts.map((p, i) => ({ ...p, d: fmtDate(p.date), @@ -391,23 +484,22 @@ export default function RecoveryDashboardOverview({ const mn = Math.min(...vals) const mx = Math.max(...vals) const span = mx - mn - const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : span * 0.12 + const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : Math.max(span * 0.12, 0.01) const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null) return ( -
-
+
+
{m.label_de} ({m.unit}) - {m.n != null ? ( - · n = {m.n} - ) : null} + {m.n != null ? · n = {m.n} : null} {m.mean != null ? ( - · Ø {m.mean} + · Ø {formatAxisTick(m.mean)} ) : null}
+ {pts.length === 1 ? (
- Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen. + Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
) : (
@@ -418,7 +510,9 @@ export default function RecoveryDashboardOverview({ (value != null ? formatAxisTick(value) : '')} /> {hasMa ? : null} ) })} - - {keys.length === 0 ? ( -
Keine Vital-Zeitreihen im Fenster.
- ) : null}
) } @@ -487,17 +578,9 @@ export default function RecoveryDashboardOverview({ const vitDate = meta.vitals_measured_at const bpDate = meta.blood_pressure_measured_at const disclaimer = meta.disclaimer_de - const hasRhrCard = items.some((it) => it.key === 'resting_hr') - const hasHrvCard = items.some((it) => it.key === 'hrv') return ( <> - {items.length > 0 && !hasRhrCard && !hasHrvCard ? ( -

- Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt — die Einordnung - steht oben im Vital-Verlauf und in der KPI-Kachel „Herz & autonomes System“. -

- ) : null} {items.length > 0 ? (
{items.map((it) => { @@ -597,44 +680,90 @@ export default function RecoveryDashboardOverview({ ) : null}
-

- Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. {eff} Tage · Charts{' '} - {cDays} Tage · Vital-Matrix {vDays} Tage. +

+ Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · + Vital-Snapshot {vDays} Tage.

- + - {insights.length > 0 ? ( -
+ {insights.length > 0 || showVitalNarrativeBlock ? ( +
Einschätzungen
-
- {insights.map((ins) => ( -
-
{ins.title}
-
{ins.body}
+
+ {insights.map((ins) => { + const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' + return ( +
+
{ins.title}
+
{ins.body}
+
+ ) + })} + {showVitalNarrativeBlock ? ( +
+
+ Vitalverlauf & Belastung (Text) +
+ {renderVitalBelastungNarrative()}
- ))} + ) : null}
) : null} -
Diagramme
+ + + {renderRecoveryScore()} + + + {renderSleepQuality()} + + + {renderSleepDebt()} + - {renderRecoveryScore()} - {renderHrvRhr()} - {renderVitalsHistory()} - {renderSleepQuality()} - {renderSleepDebt()} - {renderVitalSigns()} + + + {renderHrvRhr()} + + + +
+
Verläufe
+ {renderWeitereVitalVerlaeufe(vitalItemsByKey)} +
+ + + {renderVitalSigns()}
) }