- Introduced a new function `_merge_vitals_baseline_rows` to streamline the retrieval and merging of vital signs data, ensuring the latest non-empty values are prioritized. - Updated SQL queries in `build_vital_signs_matrix_chart_payload` to enhance data retrieval efficiency and accuracy. - Refactored the `renderVitalSigns` function in the `RecoveryDashboardOverview` component to improve handling of vital signs data, including better fallback messaging and chart rendering logic. - Enhanced user feedback by providing clearer messages when no vital data is available, improving overall user experience.
547 lines
20 KiB
JavaScript
547 lines
20 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
LineChart,
|
||
Line,
|
||
BarChart,
|
||
Bar,
|
||
Cell,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
CartesianGrid,
|
||
} from 'recharts'
|
||
import { api } from '../utils/api'
|
||
import KpiTilesOverview from './KpiTilesOverview'
|
||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||
import dayjs from 'dayjs'
|
||
|
||
const fmtDate = (d) => dayjs(d).format('DD.MM.')
|
||
|
||
function vitalToneToUi(tone) {
|
||
if (tone === 'good') return 'good'
|
||
if (tone === 'bad') return 'bad'
|
||
if (tone === 'neutral') return 'neutral'
|
||
return 'warn'
|
||
}
|
||
|
||
function barFillForTone(tone) {
|
||
const ui = vitalToneToUi(tone)
|
||
if (ui === 'good') return '#1D9E75'
|
||
if (ui === 'bad') return '#D85A30'
|
||
if (ui === 'neutral') return '#6B7280'
|
||
return '#EF9F27'
|
||
}
|
||
|
||
function ChartCard({ title, loading, error, children }) {
|
||
return (
|
||
<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 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 renderVitalSigns = () => {
|
||
if (!vitalsData) {
|
||
return (
|
||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||
Keine Vital-Matrix-Daten
|
||
</div>
|
||
)
|
||
}
|
||
const meta = vitalsData.metadata || {}
|
||
const items = meta.vital_items || []
|
||
const ds0 = vitalsData.data?.datasets?.[0]
|
||
const hasRawChart =
|
||
Array.isArray(vitalsData.data?.labels) &&
|
||
vitalsData.data.labels.length > 0 &&
|
||
Array.isArray(ds0?.data) &&
|
||
ds0.data.length > 0
|
||
const ins = meta.confidence === 'insufficient'
|
||
if (ins && items.length === 0 && !hasRawChart) {
|
||
return (
|
||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||
{meta.message || 'Keine aktuellen Vitalwerte'}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
let chartRows = items.map((it) => ({
|
||
name: it.label_de,
|
||
value: Number(it.bar_value ?? 0),
|
||
fill: barFillForTone(it.tone),
|
||
tone: it.tone,
|
||
}))
|
||
if (chartRows.length === 0 && hasRawChart) {
|
||
const bg = ds0.backgroundColor
|
||
chartRows = vitalsData.data.labels.map((name, i) => ({
|
||
name,
|
||
value: Number(ds0.data[i] ?? 0),
|
||
fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75',
|
||
tone: 'neutral',
|
||
}))
|
||
}
|
||
|
||
if (items.length === 0 && chartRows.length === 0) {
|
||
return (
|
||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
|
||
</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}
|
||
|
||
{items.length === 0 && chartRows.length > 0 ? (
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
||
Diagramm aus Server-Daten (ohne Zonen-Detail — bitte App aktualisieren oder Cache leeren).
|
||
</div>
|
||
) : null}
|
||
|
||
{chartRows.length > 0 ? (
|
||
<>
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
|
||
Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße)
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
|
||
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
|
||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: 'var(--surface)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 8,
|
||
fontSize: 11,
|
||
}}
|
||
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
|
||
/>
|
||
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
|
||
{chartRows.map((row, i) => (
|
||
<Cell key={`c-${i}`} fill={row.fill} />
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</>
|
||
) : null}
|
||
|
||
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
{vitDate ? (
|
||
<>
|
||
Baseline-Vitals Stand: <strong>{fmtDate(vitDate)}</strong>
|
||
</>
|
||
) : null}
|
||
{vitDate && bpDate ? ' · ' : null}
|
||
{bpDate ? (
|
||
<>
|
||
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
|
||
</>
|
||
) : null}
|
||
{!vitDate && !bpDate ? <>Anzeige-Zeitraum 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="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard>
|
||
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard>
|
||
<ChartCard title="📊 Vitalwerte Überblick">{renderVitalSigns()}</ChartCard>
|
||
</div>
|
||
)
|
||
}
|