- Updated the `build_vital_signs_matrix_chart_payload` function to accept optional keys for omitting specific snapshot data, improving flexibility in data presentation. - Enhanced the `build_recovery_dashboard_kpi_tiles` function to conditionally merge heart and autonomic tiles based on new parameters, refining the dashboard's insights. - Integrated new analytics features in the `RecoveryDashboardOverview` component, including consolidated paragraphs for better narrative context and visual representation of trends. - Improved the handling of vital signs data in the frontend, ensuring clearer messaging and enhanced user experience when displaying vital metrics.
641 lines
25 KiB
JavaScript
641 lines
25 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 dayjs from 'dayjs'
|
|
|
|
const fmtDate = (d) => dayjs(d).format('DD.MM.')
|
|
|
|
function insightBulletStripe(tone) {
|
|
if (tone === 'good') return getStatusColor('good')
|
|
if (tone === 'bad') return getStatusColor('bad')
|
|
if (tone === 'neutral') return '#6B7280'
|
|
return getStatusColor('warn')
|
|
}
|
|
|
|
function ChartCard({ title, loading, error, children }) {
|
|
return (
|
|
<div className="card" style={{ marginBottom: 12 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{title}</div>
|
|
{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 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]} />
|
|
<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} />
|
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
|
<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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const renderVitalsHistory = () => {
|
|
const vh = vitalsHistory
|
|
if (!vh) {
|
|
return <div style={{ padding: 16, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
|
|
}
|
|
if (vh.metadata?.confidence === 'insufficient') {
|
|
return (
|
|
<div style={{ padding: 16, 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 = Object.keys(series)
|
|
const paragraphs = vh.analytics?.consolidated_paragraphs || []
|
|
const bullets = vh.analytics?.bullets || []
|
|
const showParagraphs = paragraphs.length > 0
|
|
const showBulletsFallback = !showParagraphs && bullets.length > 0
|
|
|
|
return (
|
|
<div style={{ width: '100%', minWidth: 0 }}>
|
|
{showParagraphs ? (
|
|
<div style={{ marginBottom: 14 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
|
Einordnung (Vital & Belastung)
|
|
</div>
|
|
<div
|
|
style={{
|
|
borderRadius: 8,
|
|
padding: '12px 14px',
|
|
border: '1px solid var(--border)',
|
|
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>
|
|
</div>
|
|
) : null}
|
|
|
|
{showBulletsFallback ? (
|
|
<div style={{ marginBottom: 14 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
|
Einordnung (Vital & Belastung)
|
|
</div>
|
|
<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>
|
|
</div>
|
|
) : null}
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
|
Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende
|
|
Messungen), glättet starke Einzelschwankungen.
|
|
</div>
|
|
|
|
{keys.map((k) => {
|
|
const m = series[k]
|
|
const pts = m.points || []
|
|
const maPts = m.points_ma7 || []
|
|
if (pts.length === 0) return null
|
|
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) : span * 0.12
|
|
const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null)
|
|
|
|
return (
|
|
<div key={k} style={{ marginBottom: 16, width: '100%' }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text2)', marginBottom: 4 }}>
|
|
{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)' }}> · Ø {m.mean}</span>
|
|
) : null}
|
|
</div>
|
|
{pts.length === 1 ? (
|
|
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>
|
|
Ein Messpunkt ({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)' }}
|
|
width={44}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: 'var(--surface)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 8,
|
|
fontSize: 11,
|
|
}}
|
|
/>
|
|
{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>
|
|
)
|
|
})}
|
|
|
|
{keys.length === 0 ? (
|
|
<div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Vital-Zeitreihen im Fenster.</div>
|
|
) : null}
|
|
</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
|
|
const hasRhrCard = items.some((it) => it.key === 'resting_hr')
|
|
const hasHrvCard = items.some((it) => it.key === 'hrv')
|
|
|
|
return (
|
|
<>
|
|
{items.length > 0 && !hasRhrCard && !hasHrvCard ? (
|
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
|
Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt — die Einordnung
|
|
steht oben im Vital-Verlauf und in der KPI-Kachel „Herz & autonomes System“.
|
|
</p>
|
|
) : null}
|
|
{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: 10 }}>
|
|
Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. <strong>{eff}</strong> Tage · Charts{' '}
|
|
<strong>{cDays}</strong> Tage · Vital-Matrix <strong>{vDays}</strong> Tage.
|
|
</p>
|
|
|
|
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
|
|
|
|
{insights.length > 0 ? (
|
|
<div style={{ marginBottom: 14 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{insights.map((ins) => (
|
|
<div
|
|
key={ins.key}
|
|
style={{
|
|
borderRadius: 8,
|
|
padding: '10px 12px',
|
|
border: '1px solid var(--border)',
|
|
borderLeft: `4px solid ${getStatusColor(['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn')}`,
|
|
background: 'var(--surface2)',
|
|
}}
|
|
>
|
|
<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}
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>Diagramme</div>
|
|
|
|
<ChartCard title="📊 Recovery Score">{renderRecoveryScore()}</ChartCard>
|
|
<ChartCard title="📊 HRV & Ruhepuls">{renderHrvRhr()}</ChartCard>
|
|
<ChartCard title="📈 Vitalwerte — Verlauf (je Kennzahl)">{renderVitalsHistory()}</ChartCard>
|
|
<ChartCard title="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard>
|
|
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard>
|
|
<ChartCard title="📋 Vitalwerte — aktuelle Einordnung">{renderVitalSigns()}</ChartCard>
|
|
</div>
|
|
)
|
|
}
|