- 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.
841 lines
33 KiB
JavaScript
841 lines
33 KiB
JavaScript
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 (7–90)
|
||
* @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 0–100 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>
|
||
)
|
||
}
|