mitai-jinkendo/frontend/src/components/RecoveryDashboardOverview.jsx
Lars e7bcdc3228
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: add vitals history analytics to recovery dashboard
- Integrated the `build_vitals_history_and_analytics` function into the recovery dashboard to provide historical insights on vital signs.
- Updated the `get_recovery_dashboard_viz_bundle` function to include a new chart for vitals history, enhancing the data visualization capabilities.
- Enhanced the `RecoveryDashboardOverview` component to render vitals history, including improved messaging for insufficient data and visual representation of trends.
2026-04-20 09:36:10 +02:00

590 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } 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 bullets = vh.analytics?.bullets || []
const corrNote = vh.metadata?.load_rhr_correlation
const pairsN = vh.metadata?.load_rhr_pairs_n
return (
<div style={{ width: '100%', minWidth: 0 }}>
{bullets.length > 0 ? (
<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>
{corrNote != null && pairsN != null ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)' }}>
Korrelation Trainingsminuten (Tag) Ruhepuls (Folgetag): r {corrNote} (n = {pairsN} Paare)
</div>
) : null}
</div>
) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 23 Messpunkten.
</div>
{keys.map((k) => {
const m = series[k]
const pts = m.points || []
if (pts.length === 0) return null
const chartData = pts.map((p) => ({
...p,
d: fmtDate(p.date),
}))
const vals = pts.map((p) => p.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
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: 200, minHeight: 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,
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={m.color || '#1D9E75'}
strokeWidth={2}
dot={{ r: 3 }}
name={m.label_de}
/>
</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
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: 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>
)
}