mitai-jinkendo/frontend/src/components/FitnessDashboardOverview.jsx

229 lines
7.5 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
PieChart,
Pie,
} from 'recharts'
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
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).
*
* @param {number} [period] – gesteuert von außen (z. B. Verlauf `PeriodSelector`); mit `onPeriodChange` koppeln.
* @param {(n: number) => void} [onPeriodChange]
* @param {boolean} [hidePeriodSelector] – eigenes Zeitraum-Dropdown ausblenden (wenn die Seite oben schon einen Zeitraum wählt).
*/
export default function FitnessDashboardOverview({
period: periodProp,
onPeriodChange,
hidePeriodSelector = false,
}) {
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 }}>
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Kennzahlen und
Diagramme hier.
</p>
</div>
)
}
const vol = viz.charts?.training_volume
const typ = viz.charts?.training_type_distribution
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 kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...t,
sublabel:
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
}))
const eff = viz.effective_window_days
const wUsed = viz.training_volume_weeks_used
const dTyp = viz.training_type_dist_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 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Aktivitäts-Data-Layer). Zusammenfassung
ca. <strong>{eff}</strong> Tage · Volumen-Chart <strong>{wUsed}</strong> Wochen · Typ-Verteilung{' '}
<strong>{dTyp}</strong> Tage
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 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>
)
}