mitai-jinkendo/frontend/src/components/RecoveryDashboardOverview.jsx

770 lines
29 KiB
React
Raw Normal View History

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 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 }}>Zuletzt (Snapshot):</span>
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
</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).
*/
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 (
<div className="card section-gap">
<div className="card-title">Erholung & Vitalwerte</div>
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div className="card section-gap">
<div className="card-title">Erholung & Vitalwerte</div>
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
}
if (!viz?.has_recovery_data) {
return (
<div className="card section-gap">
<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 showVitalNarrativeBlock =
vitalsHistory &&
vitalsHistory.metadata?.confidence !== 'insufficient' &&
(((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 ||
(vitalsHistory.analytics?.bullets || []).length > 0))
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 (
<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 (
<>
<ResponsiveContainer width="100%" height={200}>
<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="Recovery Score" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
</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 (
<>
<ResponsiveContainer width="100%" height={200}>
<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 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 (
<>
<ResponsiveContainer width="100%" height={200}>
<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 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 (
<>
<ResponsiveContainer width="100%" height={200}>
<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="Schlafschuld (h)" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
</div>
</>
)
}
/** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */
const renderVitalBelastungNarrative = () => {
const vh = vitalsHistory
if (!vh || vh.metadata?.confidence === 'insufficient') return null
const paragraphs = vh.analytics?.consolidated_paragraphs || []
const bullets = vh.analytics?.bullets || []
const showParagraphs = paragraphs.length > 0
const showBulletsFallback = !showParagraphs && bullets.length > 0
if (!showParagraphs && !showBulletsFallback) return null
return (
<div style={{ marginBottom: 0 }}>
{showParagraphs ? (
<div
style={{
borderRadius: 8,
padding: '12px 14px',
border: '1px solid var(--border)',
borderLeft: '4px solid var(--accent)',
background: 'var(--surface2)',
fontSize: 12,
color: 'var(--text2)',
lineHeight: 1.55,
}}
>
{paragraphs.map((text, i) => (
<p key={i} style={{ margin: i === 0 ? 0 : '10px 0 0' }}>
{text}
</p>
))}
</div>
) : null}
{showBulletsFallback ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{bullets.map((b) => (
<div
key={b.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${insightBulletStripe(b.tone)}`,
background: 'var(--surface2)',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{b.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{b.body}</div>
</div>
))}
</div>
) : null}
</div>
)
}
/** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */
const renderWeitereVitalVerlaeufe = (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 }}>
<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%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}>
<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>
)
}
const renderVitalSigns = () => {
if (!vitalsData) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Snapshot-Daten zur Vital-Matrix.
</div>
)
}
const meta = vitalsData.metadata || {}
const items = meta.vital_items || []
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
</div>
)
}
const vitDate = meta.vitals_measured_at
const bpDate = meta.blood_pressure_measured_at
const disclaimer = meta.disclaimer_de
return (
<>
{items.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
{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>
) : null}
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
{vitDate ? (
<>
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
</>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
</>
) : null}
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
</div>
{disclaimer ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
) : null}
</>
)
}
return (
<div className="card section-gap">
<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>
<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>
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
{insights.length > 0 || showVitalNarrativeBlock ? (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</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>
)
})}
{showVitalNarrativeBlock ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Vitalverlauf & Belastung (Text)
</div>
{renderVitalBelastungNarrative()}
</div>
) : null}
</div>
</div>
) : null}
<SectionHeading
compactTop
title="Schlaf & Erholung"
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/>
<ChartCard
title="Recovery Score"
description="0–100, Verlauf im Chart-Fenster. Höher ist in der Regel günstiger."
>
{renderRecoveryScore()}
</ChartCard>
<ChartCard title="Schlaf: Dauer & Qualität" description="Dauer (h) und Qualitätsanteil (%) — eigene Achsen.">
{renderSleepQuality()}
</ChartCard>
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
{renderSleepDebt()}
</ChartCard>
<SectionHeading
title="Herz & Kreislauf"
hint="Ruhepuls und HRV nur hier im kombinierten Verlauf — keine zweite Darstellung darunter."
/>
<ChartCard
title="HRV & Ruhepuls"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleiche Tage wie oben."
>
{renderHrvRhr()}
</ChartCard>
<SectionHeading
title="Weitere Vitalparameter (Verlauf)"
hint="VO2max, SpO2 und Atemfrequenz mit Zonen-Einordnung zum letzten Snapshot (farbig unter dem Titel)."
/>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vitalItemsByKey)}
</div>
<SectionHeading
title="Aktuelle Messwerte & Zonen"
hint="Neueste Baseline-Vitals und Blutdruck im Fenster — gleiche Logik wie Vital-Seite (keine Diagnose)."
/>
<ChartCard title="Snapshot & Einordnung">{renderVitalSigns()}</ChartCard>
</div>
)
}