import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts' import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' import { RECOVERY_HISTORY_VIZ_HISTORY_FULL, filterRecoveryHistoryKpiTiles, normalizeRecoveryHistoryVizConfig, } from '../widgetSystem/recoveryHistoryVizConfig' 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') if (tone === 'neutral') return '#6B7280' return getStatusColor('warn') } 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 (
Letzte Einordnung (Snapshot): {item.zone_label_de} {item.hint_de}
) } /** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */ function HeartAutonomicGuide() { return (
Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm

Es handelt sich um Abweichungen in % vom älteren Referenzmittel (kurzfristiges Mittel vs. längere Basis) — nicht um absolute Normalwerte.

Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert anzeigen.

) } 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 (
{ins.title_de}
{ins.body}
) } function SnapshotCards({ items }) { if (!items?.length) return null return (
{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 (
{it.label_de} {it.value_display} {it.zone_label_de}
{it.hint_de}
) })}
) } function ChartCard({ title, loading, error, children, description }) { return (
{title}
{description ? (
{description}
) : null} {loading && (
)} {error && (
{error}
)} {!loading && !error && children}
) } /** * Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). * @param {number} [props.externalPeriod] — Widget: feste Tage (7–90) * @param {boolean} [props.embedded] * @param {Record} [props.visibility] — Dashboard-Config; undefined = voller Verlauf * @param {import('react').ReactNode} [props.footer] */ export default function RecoveryDashboardOverview({ period: periodProp, onPeriodChange, hidePeriodSelector = false, externalPeriod, embedded = false, visibility, footer = null, }) { const nav = useNavigate() const [internalPeriod, setInternalPeriod] = useState(28) const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' const period = externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod const setPeriod = externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod const display = visibility === undefined ? RECOVERY_HISTORY_VIZ_HISTORY_FULL : normalizeRecoveryHistoryVizConfig(visibility) const chartH = embedded ? 176 : 200 const chartHVitals = embedded ? 200 : 220 const [viz, setViz] = useState(null) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setErr(null) api .getRecoveryDashboardViz(period) .then((v) => { if (!cancelled) setViz(v) }) .catch((e) => { if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [period]) const outerClass = embedded ? '' : 'card section-gap' const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled if (loading) { return (
{!embedded &&
Erholung & Vitalwerte
}
) } if (err) { return (
{!embedded &&
Erholung & Vitalwerte
}
{err}
) } if (!viz?.has_recovery_data) { return (
{!embedded &&
Erholung & Vitalwerte
}

{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst oder importierst, erscheinen Auswertungen hier.

) } const recoveryData = viz.charts?.recovery_score const hrvRhrData = viz.charts?.hrv_rhr const sleepData = viz.charts?.sleep_duration_quality const debtData = viz.charts?.sleep_debt 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 sectionInsights = vitalsHistory?.analytics?.section_insights || [] const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart') const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2') const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean) const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, })) const kpiTilesShown = display.show_kpis ? filterRecoveryHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') : [] const insights = viz.progress_insights || [] const eff = viz.effective_window_days const cDays = viz.chart_days_used const vDays = viz.vital_matrix_days_used const renderRecoveryScore = () => { if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { return (
Keine Recovery-Daten im Fenster
) } const chartData = recoveryData.data.labels.map((label, i) => ({ date: fmtDate(label), score: recoveryData.data.datasets[0]?.data[i], })) return ( <>
(Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} tickCount={6} width={36} />
KPI Recovery-Score (aktuell): {recoveryData.metadata.current_score}/100 · Datenpunkte Kurve:{' '} {recoveryData.metadata.data_points}
) } const renderHrvRhr = () => { if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { return (
Keine Vitalwerte im Fenster
) } const chartData = hrvRhrData.data.labels.map((label, i) => ({ date: fmtDate(label), hrv: hrvRhrData.data.datasets[0]?.data[i], rhr: hrvRhrData.data.datasets[1]?.data[i], })) return ( <>
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
) } const renderSleepQuality = () => { if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { return (
Keine Schlafdaten im Fenster
) } const chartData = sleepData.data.labels.map((label, i) => ({ date: fmtDate(label), duration: sleepData.data.datasets[0]?.data[i], quality: sleepData.data.datasets[1]?.data[i], })) return ( <>
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
) } const renderSleepDebt = () => { if (!debtData || debtData.metadata?.confidence === 'insufficient') { return (
Keine Schlafdaten für Schulden-Berechnung
) } const chartData = debtData.data.labels.map((label, i) => ({ date: fmtDate(label), debt: debtData.data.datasets[0]?.data[i], })) const curDebt = debtData.metadata?.current_debt_hours return ( <>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
) } /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */ const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => { const vh = vitalsHistory if (!vh) { 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 = 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 (
{vo2Insights.length > 0 ? (
{vo2Insights.map((ins) => ( ))}
) : null}
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 || [] const zoneItem = vitalItemsByKey[k] const chartData = pts.map((p, i) => ({ ...p, d: fmtDate(p.date), value_ma: maPts[i]?.value != null ? maPts[i].value : null, })) const vals = [] pts.forEach((p, i) => { vals.push(p.value) if (maPts[i]?.value != null) vals.push(maPts[i].value) }) 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) : 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.mean != null ? ( · Ø {formatAxisTick(m.mean)} ) : null}
{pts.length === 1 ? (
Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
) : (
(value != null ? formatAxisTick(value) : '')} /> {hasMa ? : null} {hasMa ? ( ) : null}
)}
) })}
) } return (
{!embedded && (
Erholung & Vitalwerte {showPeriodDropdown ? ( ) : null}
)} {display.show_layer_meta ? (

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

) : null} {kpiTilesShown.length > 0 ? ( ) : null} {display.show_progress_insights && insights.length > 0 ? (
Überblick: Recovery & Schlaf
{insights.map((ins) => { const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' return (
{ins.title}
{ins.body}
) })}
) : null} {display.show_sleep_section_heading ? ( ) : null} {display.show_chart_recovery_score ? ( {renderRecoveryScore()} ) : null} {display.show_chart_sleep_quality ? ( {renderSleepQuality()} ) : null} {display.show_chart_sleep_debt ? ( {renderSleepDebt()} ) : null} {display.show_heart_section_heading ? ( ) : null} {display.show_heart_context_card ? (
Einordnung & Kontext
{heartSectionInsights.length > 0 ? (
{heartSectionInsights.map((ins) => ( ))}
) : null}
Letzte Messwerte (Zonen)
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
{vitalsData?.metadata?.vitals_measured_at ? ( <> Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} ) : null} {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} {vitalsData?.metadata?.blood_pressure_measured_at ? ( <> Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} ) : null}
) : null} {vitalsData?.metadata?.disclaimer_de ? (
{vitalsData.metadata.disclaimer_de}
) : null}
) : null} {display.show_chart_hrv_rhr ? ( {renderHrvRhr()} ) : null} {display.show_vitals_extra_heading ? ( ) : null} {display.show_vitals_extra_trends ? (
Verläufe
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
) : null} {footer}
) }