import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
LineChart,
Line,
BarChart,
Bar,
Cell,
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 vitalToneToUi(tone) {
if (tone === 'good') return 'good'
if (tone === 'bad') return 'bad'
if (tone === 'neutral') return 'neutral'
return 'warn'
}
function barFillForTone(tone) {
const ui = vitalToneToUi(tone)
if (ui === 'good') return '#1D9E75'
if (ui === 'bad') return '#D85A30'
if (ui === 'neutral') return '#6B7280'
return '#EF9F27'
}
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 (
)
}
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.
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 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 renderVitalSigns = () => {
if (!vitalsData) {
return (
Keine Vital-Matrix-Daten
)
}
const meta = vitalsData.metadata || {}
const items = meta.vital_items || []
const ds0 = vitalsData.data?.datasets?.[0]
const hasRawChart =
Array.isArray(vitalsData.data?.labels) &&
vitalsData.data.labels.length > 0 &&
Array.isArray(ds0?.data) &&
ds0.data.length > 0
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0 && !hasRawChart) {
return (
{meta.message || 'Keine aktuellen Vitalwerte'}
)
}
let chartRows = items.map((it) => ({
name: it.label_de,
value: Number(it.bar_value ?? 0),
fill: barFillForTone(it.tone),
tone: it.tone,
}))
if (chartRows.length === 0 && hasRawChart) {
const bg = ds0.backgroundColor
chartRows = vitalsData.data.labels.map((name, i) => ({
name,
value: Number(ds0.data[i] ?? 0),
fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75',
tone: 'neutral',
}))
}
if (items.length === 0 && chartRows.length === 0) {
return (
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
)
}
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}
{items.length === 0 && chartRows.length > 0 ? (
Diagramm aus Server-Daten (ohne Zonen-Detail — bitte App aktualisieren oder Cache leeren).
) : null}
{chartRows.length > 0 ? (
<>
Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße)
[`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
/>
{chartRows.map((row, i) => (
|
))}
>
) : null}
{vitDate ? (
<>
Baseline-Vitals Stand: {fmtDate(vitDate)}
>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck Stand: {fmtDate(bpDate)}
>
) : null}
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage> : null}
{disclaimer ? (
{disclaimer}
) : null}
>
)
}
return (
Erholung & Vitalwerte
{showPeriodDropdown ? (
Zeitraum
setPeriod(Number(e.target.value))}
>
7 Tage
28 Tage
90 Tage
Gesamt
) : 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) => (
))}
) : null}
Diagramme
{renderRecoveryScore()}
{renderHrvRhr()}
{renderSleepQuality()}
{renderSleepDebt()}
{renderVitalSigns()}
)
}