import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' import dayjs from 'dayjs' const fmtDate = (d) => dayjs(d).format('DD.MM.') function insightBulletStripe(tone) { if (tone === 'good') return getStatusColor('good') if (tone === 'bad') return getStatusColor('bad') if (tone === 'neutral') return '#6B7280' return getStatusColor('warn') } function ChartCard({ title, loading, error, children }) { return (
{title}
{loading && (
)} {error && (
{error}
)} {!loading && !error && children}
) } /** * Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). */ export default function RecoveryDashboardOverview({ period: periodProp, onPeriodChange, hidePeriodSelector = false, }) { const nav = useNavigate() const [internalPeriod, setInternalPeriod] = useState(28) const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' const period = controlled ? periodProp : internalPeriod const setPeriod = controlled ? onPeriodChange : setInternalPeriod 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]) if (loading) { return (
Erholung & Vitalwerte
) } if (err) { return (
Erholung & Vitalwerte
{err}
) } if (!viz?.has_recovery_data) { return (
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 kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, })) 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 showPeriodDropdown = !hidePeriodSelector && !controlled 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 ( <>
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
) } 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
) } const renderVitalsHistory = () => { 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 = Object.keys(series) const bullets = vh.analytics?.bullets || [] const corrNote = vh.metadata?.load_rhr_correlation const pairsN = vh.metadata?.load_rhr_pairs_n return (
{bullets.length > 0 ? (
Einordnung (Vital & Belastung)
{bullets.map((b) => (
{b.title}
{b.body}
))}
{corrNote != null && pairsN != null ? (
Korrelation Trainingsminuten (Tag) ↔ Ruhepuls (Folgetag): r ≈ {corrNote} (n = {pairsN} Paare)
) : null}
) : null}
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 2–3 Messpunkten.
{keys.map((k) => { const m = series[k] const pts = m.points || [] if (pts.length === 0) return null const chartData = pts.map((p) => ({ ...p, d: fmtDate(p.date), })) const vals = pts.map((p) => p.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) : span * 0.12 return (
{m.label_de} ({m.unit}) {m.n != null ? ( · n = {m.n} ) : null} {m.mean != null ? ( · Ø {m.mean} ) : null}
{pts.length === 1 ? (
Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen.
) : (
)}
) })} {keys.length === 0 ? (
Keine Vital-Zeitreihen im Fenster.
) : null}
) } const renderVitalSigns = () => { if (!vitalsData) { return (
Keine Snapshot-Daten zur Vital-Matrix.
) } const meta = vitalsData.metadata || {} const items = meta.vital_items || [] const ins = meta.confidence === 'insufficient' if (ins && items.length === 0) { return (
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
) } const vitDate = meta.vitals_measured_at const bpDate = meta.blood_pressure_measured_at const disclaimer = meta.disclaimer_de return ( <> {items.length > 0 ? (
{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}
) })}
) : null}
{vitDate ? ( <> Baseline-Vitals (Snapshot): {fmtDate(vitDate)} ) : null} {vitDate && bpDate ? ' · ' : null} {bpDate ? ( <> Blutdruck: {fmtDate(bpDate)} ) : null} {!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage : null}
{disclaimer ? (
{disclaimer}
) : null} ) } return (
Erholung & Vitalwerte {showPeriodDropdown ? ( ) : null}

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

{insights.length > 0 ? (
Einschätzungen
{insights.map((ins) => (
{ins.title}
{ins.body}
))}
) : null}
Diagramme
{renderRecoveryScore()} {renderHrvRhr()} {renderVitalsHistory()} {renderSleepQuality()} {renderSleepDebt()} {renderVitalSigns()}
) }