2026-04-19 21:27:12 +02:00
|
|
|
|
import { useState, useEffect } from 'react'
|
2026-04-20 08:04:50 +02:00
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
2026-04-19 21:27:12 +02:00
|
|
|
|
import {
|
|
|
|
|
|
BarChart,
|
|
|
|
|
|
Bar,
|
|
|
|
|
|
XAxis,
|
|
|
|
|
|
YAxis,
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
ResponsiveContainer,
|
|
|
|
|
|
CartesianGrid,
|
|
|
|
|
|
PieChart,
|
|
|
|
|
|
Pie,
|
2026-04-20 08:04:50 +02:00
|
|
|
|
LineChart,
|
|
|
|
|
|
Line,
|
|
|
|
|
|
Cell,
|
2026-04-19 21:27:12 +02:00
|
|
|
|
} from 'recharts'
|
|
|
|
|
|
import { api } from '../utils/api'
|
|
|
|
|
|
import KpiTilesOverview from './KpiTilesOverview'
|
2026-04-20 08:04:50 +02:00
|
|
|
|
import { getStatusColor } from '../utils/interpret'
|
|
|
|
|
|
import dayjs from 'dayjs'
|
2026-04-19 21:27:12 +02:00
|
|
|
|
|
|
|
|
|
|
const PERIODS = [
|
|
|
|
|
|
{ v: 7, label: '7 Tage' },
|
|
|
|
|
|
{ v: 28, label: '28 Tage' },
|
|
|
|
|
|
{ v: 90, label: '90 Tage' },
|
|
|
|
|
|
{ v: 9999, label: 'Gesamt' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
|
|
|
|
|
|
*/
|
2026-04-19 21:37:12 +02:00
|
|
|
|
export default function FitnessDashboardOverview({
|
|
|
|
|
|
period: periodProp,
|
|
|
|
|
|
onPeriodChange,
|
|
|
|
|
|
hidePeriodSelector = false,
|
|
|
|
|
|
}) {
|
2026-04-20 08:04:50 +02:00
|
|
|
|
const nav = useNavigate()
|
2026-04-19 21:37:12 +02:00
|
|
|
|
const [internalPeriod, setInternalPeriod] = useState(28)
|
|
|
|
|
|
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
|
|
|
|
|
|
const period = controlled ? periodProp : internalPeriod
|
|
|
|
|
|
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
|
2026-04-19 21:27:12 +02:00
|
|
|
|
const [viz, setViz] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [err, setErr] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setErr(null)
|
|
|
|
|
|
api
|
|
|
|
|
|
.getFitnessDashboardViz(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">Fitness-Übersicht</div>
|
|
|
|
|
|
<div className="spinner" style={{ margin: 24 }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">Fitness-Übersicht</div>
|
|
|
|
|
|
<div style={{ color: 'var(--danger)' }}>{err}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!viz?.has_activity_entries) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">Fitness-Übersicht</div>
|
2026-04-20 08:04:50 +02:00
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
|
|
|
|
|
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
|
2026-04-19 21:27:12 +02:00
|
|
|
|
</p>
|
2026-04-20 08:04:50 +02:00
|
|
|
|
<button type="button" className="btn btn-primary" onClick={() => nav('/activity')}>
|
|
|
|
|
|
Zur Erfassung
|
|
|
|
|
|
</button>
|
2026-04-19 21:27:12 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const vol = viz.charts?.training_volume
|
|
|
|
|
|
const typ = viz.charts?.training_type_distribution
|
2026-04-20 08:04:50 +02:00
|
|
|
|
const qual = viz.charts?.quality_sessions
|
|
|
|
|
|
const loadCh = viz.charts?.load_monitoring
|
|
|
|
|
|
|
2026-04-19 21:27:12 +02:00
|
|
|
|
const volRows = (vol?.data?.labels || []).map((name, i) => ({
|
|
|
|
|
|
name,
|
|
|
|
|
|
min: vol?.data?.datasets?.[0]?.data?.[i] ?? 0,
|
|
|
|
|
|
}))
|
|
|
|
|
|
const pieLabels = typ?.data?.labels || []
|
|
|
|
|
|
const pieVals = typ?.data?.datasets?.[0]?.data || []
|
|
|
|
|
|
const pieColors = typ?.data?.datasets?.[0]?.backgroundColor || []
|
|
|
|
|
|
const pieData = pieLabels.map((name, i) => ({
|
|
|
|
|
|
name,
|
|
|
|
|
|
value: pieVals[i],
|
|
|
|
|
|
fill: pieColors[i] || '#888780',
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-04-20 08:04:50 +02:00
|
|
|
|
const qualLabels = qual?.data?.labels || []
|
|
|
|
|
|
const qualVals = qual?.data?.datasets?.[0]?.data || []
|
|
|
|
|
|
const qualBg = qual?.data?.datasets?.[0]?.backgroundColor || []
|
|
|
|
|
|
const qualBar = qualLabels.map((name, i) => ({
|
|
|
|
|
|
name,
|
|
|
|
|
|
n: qualVals[i] ?? 0,
|
|
|
|
|
|
fill: qualBg[i] || '#1D9E75',
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const loadLabels = loadCh?.data?.labels || []
|
|
|
|
|
|
const loadVals = loadCh?.data?.datasets?.[0]?.data || []
|
|
|
|
|
|
const loadRows = loadLabels.map((iso, i) => ({
|
|
|
|
|
|
t: dayjs(iso).format('DD.MM.'),
|
|
|
|
|
|
load: loadVals[i] ?? 0,
|
|
|
|
|
|
}))
|
|
|
|
|
|
const loadMeta = loadCh?.metadata || {}
|
|
|
|
|
|
|
2026-04-19 21:27:12 +02:00
|
|
|
|
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
|
|
|
|
|
...t,
|
|
|
|
|
|
sublabel:
|
|
|
|
|
|
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-04-20 08:04:50 +02:00
|
|
|
|
const insights = viz.progress_insights || []
|
2026-04-19 21:27:12 +02:00
|
|
|
|
const eff = viz.effective_window_days
|
|
|
|
|
|
const wUsed = viz.training_volume_weeks_used
|
|
|
|
|
|
const dTyp = viz.training_type_dist_days_used
|
2026-04-20 08:04:50 +02:00
|
|
|
|
const loadDays = viz.load_chart_days_used
|
2026-04-19 21:27:12 +02:00
|
|
|
|
|
2026-04-19 21:37:12 +02:00
|
|
|
|
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
|
|
|
|
|
|
2026-04-19 21:27:12 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
|
|
|
|
|
<span>Fitness-Übersicht</span>
|
2026-04-19 21:37:12 +02:00
|
|
|
|
{showPeriodDropdown ? (
|
|
|
|
|
|
<label
|
|
|
|
|
|
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
|
2026-04-19 21:27:12 +02:00
|
|
|
|
>
|
2026-04-19 21:37:12 +02:00
|
|
|
|
Zeitraum
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
|
|
|
|
|
|
value={period}
|
|
|
|
|
|
onChange={(e) => setPeriod(Number(e.target.value))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{PERIODS.map((p) => (
|
|
|
|
|
|
<option key={p.v} value={p.v}>
|
|
|
|
|
|
{p.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : null}
|
2026-04-19 21:27:12 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
2026-04-20 08:04:50 +02:00
|
|
|
|
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
|
|
|
|
|
|
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
|
|
|
|
|
|
<strong>{loadDays ?? '—'}</strong> Tage
|
2026-04-19 21:27:12 +02:00
|
|
|
|
{viz.last_updated ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{' '}
|
|
|
|
|
|
· letzte Aktivität <strong>{viz.last_updated}</strong>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
|
|
|
|
|
|
|
2026-04-20 08:04:50 +02:00
|
|
|
|
{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}
|
|
|
|
|
|
|
2026-04-19 21:27:12 +02:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
2026-04-20 08:04:50 +02:00
|
|
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
2026-04-19 21:27:12 +02:00
|
|
|
|
gap: 16,
|
|
|
|
|
|
marginTop: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
|
|
|
|
|
Trainingsvolumen (Minuten / Woche)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{volRows.length >= 1 ? (
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
|
|
|
|
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
2026-04-20 08:04:50 +02:00
|
|
|
|
<XAxis
|
|
|
|
|
|
dataKey="name"
|
|
|
|
|
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
|
|
|
|
|
tickLine={false}
|
|
|
|
|
|
interval={0}
|
|
|
|
|
|
angle={-35}
|
|
|
|
|
|
textAnchor="end"
|
|
|
|
|
|
height={48}
|
|
|
|
|
|
/>
|
2026-04-19 21:27:12 +02:00
|
|
|
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
|
|
|
|
|
<Tooltip
|
|
|
|
|
|
contentStyle={{
|
|
|
|
|
|
background: 'var(--surface)',
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
}}
|
|
|
|
|
|
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
|
|
|
|
|
|
</BarChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
|
|
|
|
|
Training nach Kategorie
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{pieData.length >= 1 ? (
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
|
|
|
|
<PieChart>
|
|
|
|
|
|
<Pie
|
|
|
|
|
|
data={pieData}
|
|
|
|
|
|
dataKey="value"
|
|
|
|
|
|
nameKey="name"
|
|
|
|
|
|
cx="50%"
|
|
|
|
|
|
cy="50%"
|
|
|
|
|
|
outerRadius={72}
|
|
|
|
|
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Tooltip
|
|
|
|
|
|
contentStyle={{
|
|
|
|
|
|
background: 'var(--surface)',
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PieChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-04-20 08:04:50 +02:00
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
|
|
|
|
|
Qualitäts-Sessions (Schätzung)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{qualBar.length >= 1 ? (
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
|
|
|
|
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
|
|
|
|
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
|
|
|
|
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
|
|
|
|
|
|
<Tooltip
|
|
|
|
|
|
contentStyle={{
|
|
|
|
|
|
background: 'var(--surface)',
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
|
|
|
|
|
|
{qualBar.map((entry, i) => (
|
|
|
|
|
|
<Cell key={`q-${i}`} fill={entry.fill} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Bar>
|
|
|
|
|
|
</BarChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
|
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
|
|
|
|
|
Belastung (Proxy-Load · duration×RPE / Tag)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{loadRows.length >= 1 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
|
|
|
|
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
|
|
|
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
|
|
|
|
|
<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="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
|
|
|
|
|
|
</LineChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
|
|
|
|
|
|
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
|
|
|
|
|
|
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-04-19 21:27:12 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|