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 (
} [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.
nav('/vitals')}>
Zu Vitalwerten
)
}
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 ? (
Zeitraum
setPeriod(Number(e.target.value))}
>
7 Tage
28 Tage
90 Tage
Gesamt
) : 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 (
)
})}
) : 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}
)
}