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) => (
+
+ ))}
+
+ ) : 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) => (
-
- ))}
-
-
- ) : 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()}
)
}