mitai-jinkendo/frontend/src/components/RecoveryDashboardOverview.jsx
Lars e20b321b64
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
feat: add recovery_history_viz widget and enhance configuration handling
- Introduced the `recovery_history_viz` widget to the dashboard, enabling users to visualize recovery history data.
- Updated widget configuration to include `recovery_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `recovery_history_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `recovery_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
2026-04-22 10:18:02 +02:00

841 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div style={{ marginTop: compactTop ? 8 : 20, marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)' }}>{title}</div>
{hint ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4, lineHeight: 1.45 }}>{hint}</div>
) : null}
</div>
)
}
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 (
<div
style={{
marginBottom: 10,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: hintBg,
fontSize: 11,
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Letzte Einordnung (Snapshot):</span>
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
</div>
)
}
/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */
function HeartAutonomicGuide() {
return (
<details style={{ marginBottom: 12, fontSize: 11, color: 'var(--text2)' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text3)', listStyle: 'none' }}>
Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm
</summary>
<div style={{ marginTop: 8, lineHeight: 1.5, paddingLeft: 2 }}>
<p style={{ margin: '0 0 8px' }}>
Es handelt sich um <strong>Abweichungen in %</strong> vom älteren Referenzmittel (kurzfristiges Mittel vs. längere
Basis) nicht um absolute Normalwerte.
</p>
<ul style={{ margin: 0, paddingLeft: 18 }}>
<li>
<strong>HRV</strong>: Positive % = zuletzt oft über der älteren Basis; sehr personenabhängig.
</li>
<li>
<strong>Ruhepuls</strong>: Negative % = niedriger als die Referenz; bei Training oft unkritisch günstig.
</li>
</ul>
<p style={{ margin: '8px 0 0' }}>
Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert
anzeigen.
</p>
</div>
</details>
)
}
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 (
<div
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4, color: 'var(--text1)' }}>{ins.title_de}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
</div>
)
}
function SnapshotCards({ items }) {
if (!items?.length) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 12 }}>
{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 (
<div
key={it.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>{it.label_de}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{it.value_display}</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
background: 'var(--surface)',
color: stripe,
border: `1px solid ${stripe}`,
}}
>
{it.zone_label_de}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45 }}>{it.hint_de}</div>
</div>
)
})}
</div>
)
}
function ChartCard({ title, loading, error, children, description }) {
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: description ? 4 : 8 }}>{title}</div>
{description ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>{description}</div>
) : null}
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)}
{error && (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{error}</div>
)}
{!loading && !error && children}
</div>
)
}
/**
* Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics).
* @param {number} [props.externalPeriod] — Widget: feste Tage (790)
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [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 (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
}
if (!viz?.has_recovery_data) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst
oder importierst, erscheinen Auswertungen hier.
</p>
<button type="button" className="btn btn-primary" onClick={() => nav('/vitals')}>
Zu Vitalwerten
</button>
</div>
)
}
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 (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Recovery-Daten im Fenster
</div>
)
}
const chartData = recoveryData.data.labels.map((label, i) => ({
date: fmtDate(label),
score: recoveryData.data.datasets[0]?.data[i],
}))
return (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/>
<YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
domain={[0, 100]}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
tickCount={6}
width={36}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line
type="monotone"
dataKey="score"
stroke="#1D9E75"
strokeWidth={2}
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
dot={{ r: 2 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}>
KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '}
{recoveryData.metadata.data_points}
</div>
</>
)
}
const renderHrvRhr = () => {
if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vitalwerte im Fenster
</div>
)
}
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 (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} />
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
</div>
</>
)
}
const renderSleepQuality = () => {
if (!sleepData || sleepData.metadata?.confidence === 'insufficient') {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Schlafdaten im Fenster
</div>
)
}
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 (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/>
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} />
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
</div>
</>
)
}
const renderSleepDebt = () => {
if (!debtData || debtData.metadata?.confidence === 'insufficient') {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Schlafdaten für Schulden-Berechnung
</div>
)
}
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 (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line
type="monotone"
dataKey="debt"
stroke="#EF4444"
strokeWidth={2}
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
dot={{ r: 2 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
</div>
</>
)
}
/** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => {
const vh = vitalsHistory
if (!vh) {
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
}
if (vh.metadata?.confidence === 'insufficient') {
return (
<div style={{ padding: 12, fontSize: 12, color: 'var(--text3)', lineHeight: 1.5 }}>
{vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
</div>
)
}
const series = vh.series || {}
const keys = VITAL_TREND_ONLY_KEYS.filter((k) => series[k]?.points?.length)
if (keys.length === 0) {
return (
<div style={{ padding: 8, fontSize: 12, color: 'var(--text3)' }}>
Keine zusätzlichen Vital-Verläufe (VO2max, SpO2, Atemfrequenz) im Fenster oder nur Ruhepuls/HRV erfasst.
</div>
)
}
return (
<div style={{ width: '100%', minWidth: 0 }}>
{vo2Insights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{vo2Insights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
begrenzt.
</div>
{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 (
<div key={k} style={{ marginBottom: 18, width: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 6 }}>
{m.label_de} ({m.unit})
{m.n != null ? <span style={{ fontWeight: 400, color: 'var(--text3)' }}> · n = {m.n}</span> : null}
{m.mean != null ? (
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · Ø {formatAxisTick(m.mean)}</span>
) : null}
</div>
<VitalZoneHint item={zoneItem} />
{pts.length === 1 ? (
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>
Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen.
</div>
) : (
<div style={{ width: '100%', minWidth: 0, height: hasMa ? chartHVitals : chartH, minHeight: hasMa ? chartHVitals : chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="d" tick={{ fontSize: 9, fill: 'var(--text3)' }} interval="preserveStartEnd" />
<YAxis
domain={[mn - pad, mx + pad]}
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick}
tickCount={6}
width={48}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(value) => (value != null ? formatAxisTick(value) : '')}
/>
{hasMa ? <Legend wrapperStyle={{ fontSize: 10, paddingTop: 4 }} /> : null}
<Line
type="monotone"
dataKey="value"
stroke={m.color || '#1D9E75'}
strokeWidth={2}
dot={{ r: 3 }}
name="Messwert"
/>
{hasMa ? (
<Line
type="monotone"
dataKey="value_ma"
stroke={m.color || '#1D9E75'}
strokeOpacity={0.55}
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
name="Ø (max. 7 Messungen)"
connectNulls
/>
) : null}
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
)
})}
</div>
)
}
return (
<div className={outerClass || undefined}>
{!embedded && (
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Erholung & Vitalwerte</span>
{showPeriodDropdown ? (
<label
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
>
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
>
<option value={7}>7 Tage</option>
<option value={28}>28 Tage</option>
<option value={90}>90 Tage</option>
<option value={9999}>Gesamt</option>
</select>
</label>
) : null}
</div>
)}
{display.show_layer_meta ? (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
Vital-Snapshot <strong>{vDays}</strong> Tage.
</p>
) : null}
{kpiTilesShown.length > 0 ? (
<KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" marginBottom={16} />
) : null}
{display.show_progress_insights && insights.length > 0 ? (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Überblick: Recovery & Schlaf
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{insights.map((ins) => {
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
return (
<div
key={ins.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${getStatusColor(t)}`,
background: getStatusBg(t),
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{ins.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
</div>
)
})}
</div>
</div>
) : null}
{display.show_sleep_section_heading ? (
<SectionHeading
compactTop
title="Schlaf & Erholung"
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/>
) : null}
{display.show_chart_recovery_score ? (
<ChartCard
title="HRV-Verlauf (kein Recovery-Score)"
description={
'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' +
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
}
>
{renderRecoveryScore()}
</ChartCard>
) : null}
{display.show_chart_sleep_quality ? (
<ChartCard
title="Schlaf: Dauer & Qualität"
description={
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.`
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
}
>
{renderSleepQuality()}
</ChartCard>
) : null}
{display.show_chart_sleep_debt ? (
<ChartCard
title="Schlafschuld"
description={
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' +
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' +
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.'
}
>
{renderSleepDebt()}
</ChartCard>
) : null}
{display.show_heart_section_heading ? (
<SectionHeading
title="Herz & Kreislauf"
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
/>
) : null}
{display.show_heart_context_card ? (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
<HeartAutonomicGuide />
{heartSectionInsights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{heartSectionInsights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
<SnapshotCards items={heartSnapshotItems} />
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
{vitalsData?.metadata?.vitals_measured_at ? (
<>
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
</>
) : null}
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
{vitalsData?.metadata?.blood_pressure_measured_at ? (
<>
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
</>
) : null}
</div>
) : null}
{vitalsData?.metadata?.disclaimer_de ? (
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
{vitalsData.metadata.disclaimer_de}
</div>
) : null}
</div>
) : null}
{display.show_chart_hrv_rhr ? (
<ChartCard
title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
>
{renderHrvRhr()}
</ChartCard>
) : null}
{display.show_vitals_extra_heading ? (
<SectionHeading
title="Weitere Vitalparameter (Verlauf)"
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
/>
) : null}
{display.show_vitals_extra_trends ? (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div>
) : null}
{footer}
</div>
)
}