mitai-jinkendo/frontend/src/components/FitnessDashboardOverview.jsx
Lars bf84e3c2a5
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: enhance fitness dashboard with new metrics and insights
- Refactored the `calculate_proxy_internal_load_7d` function to `calculate_proxy_internal_load_window`, allowing for dynamic day range input.
- Introduced new functions for calculating training volume deltas and building fitness progress insights, enhancing user feedback on training metrics.
- Updated the fitness dashboard to include new charts for quality sessions and load monitoring, improving data visualization.
- Integrated these new metrics into the fitness dashboard overview, providing users with comprehensive insights into their training performance.
- Streamlined the router to utilize the new chart-building functions, ensuring consistency and maintainability across the application.
2026-04-20 08:04:50 +02:00

349 lines
12 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 {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
PieChart,
Pie,
LineChart,
Line,
Cell,
} from 'recharts'
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor } from '../utils/interpret'
import dayjs from 'dayjs'
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).
*/
export default function FitnessDashboardOverview({
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
.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>
<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.
</p>
<button type="button" className="btn btn-primary" onClick={() => nav('/activity')}>
Zur Erfassung
</button>
</div>
)
}
const vol = viz.charts?.training_volume
const typ = viz.charts?.training_type_distribution
const qual = viz.charts?.quality_sessions
const loadCh = viz.charts?.load_monitoring
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',
}))
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 || {}
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 wUsed = viz.training_volume_weeks_used
const dTyp = viz.training_type_dist_days_used
const loadDays = viz.load_chart_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled
return (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</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))}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
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
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</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={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
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" />
<XAxis
dataKey="name"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={0}
angle={-35}
textAnchor="end"
height={48}
/>
<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>
<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,81,3'} · Proxy)
</div>
</>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
)}
</div>
</div>
</div>
)
}