3cda76b4c4
Le due sezioni erano ferme al prototipo locale, più povero della piattaforma vera. Analytics ha ora l'intervallo di date, i KPI con la nota sotto il valore, la distribuzione dello score a quattro serie per fascia, le classifiche per recupero medio col numero di misurazioni e il confronto segmenti con selettore di dimensione. Sport diventa Dashboard, Sessioni con filtri atleta/periodo/sport e schede atleta con carico e tendenza. I numeri non sono più costanti scritte a mano: si calcolano dai dati nel periodo scelto. La logica sta in lib/stress-index/analytics.ts e sport.ts, coperta da test — che hanno subito trovato uno slittamento delle settimane dovuto alle date costruite in ora locale invece che in UTC. Le sessioni hanno ora ora di inizio, HR max, RPE e durata al secondo, che il prototipo non prevedeva. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
9.0 KiB
TypeScript
205 lines
9.0 KiB
TypeScript
// Modulo Sport: tre viste (dashboard, elenco sessioni con filtri, schede atleta) in un'unica
|
|
// isola, perché condividono lo stesso insieme di sessioni e i filtri agiscono su quello.
|
|
import { useMemo, useState } from 'react';
|
|
import DataChart from './charts/DataChart';
|
|
import type { Athlete, Session } from '../../lib/stress-index/data';
|
|
import { dataBreve, dataOra, durata, kpiSport, perSettimana, riepilogoAtleti } from '../../lib/stress-index/sport';
|
|
|
|
interface Props {
|
|
sessions: Session[];
|
|
athletes: Athlete[];
|
|
/** Ultimo giorno coperto dai dati: tutte le finestre temporali partono da qui. */
|
|
riferimento: string;
|
|
}
|
|
|
|
const TABS = [
|
|
{ key: 'dashboard', label: 'Dashboard' },
|
|
{ key: 'sessioni', label: 'Sessioni' },
|
|
{ key: 'atleti', label: 'Atleti' },
|
|
];
|
|
|
|
const PERIODI = [
|
|
{ key: 'tutto', label: 'Tutto', giorni: 0 },
|
|
{ key: '7', label: 'Ultimi 7 giorni', giorni: 7 },
|
|
{ key: '30', label: 'Ultimi 30 giorni', giorni: 30 },
|
|
{ key: '90', label: 'Ultimi 90 giorni', giorni: 90 },
|
|
];
|
|
|
|
export default function SportView({ sessions, athletes, riferimento }: Props) {
|
|
const [tab, setTab] = useState('dashboard');
|
|
const [atleta, setAtleta] = useState('tutti');
|
|
const [periodo, setPeriodo] = useState('tutto');
|
|
const [sport, setSport] = useState('tutti');
|
|
|
|
const sports = useMemo(
|
|
() => [...new Set(sessions.map((s) => s.sport).filter((s): s is string => Boolean(s)))],
|
|
[sessions]
|
|
);
|
|
|
|
const filtrate = useMemo(() => {
|
|
const giorni = PERIODI.find((p) => p.key === periodo)?.giorni ?? 0;
|
|
const limite = new Date(`${riferimento}T00:00:00`);
|
|
limite.setDate(limite.getDate() - giorni + 1);
|
|
const dal = limite.toISOString().slice(0, 10);
|
|
return sessions.filter((s) =>
|
|
(atleta === 'tutti' || s.athleteId === atleta) &&
|
|
(sport === 'tutti' || s.sport === sport) &&
|
|
(!giorni || s.startedAt.slice(0, 10) >= dal)
|
|
);
|
|
}, [sessions, atleta, periodo, sport, riferimento]);
|
|
|
|
const kpi = useMemo(() => kpiSport(sessions, riferimento), [sessions, riferimento]);
|
|
const settimane = useMemo(() => perSettimana(sessions, 12, riferimento), [sessions, riferimento]);
|
|
const atletiRiepilogo = useMemo(() => riepilogoAtleti(sessions, athletes, riferimento), [sessions, athletes, riferimento]);
|
|
const nome = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id;
|
|
|
|
return (
|
|
<div className="si-stack">
|
|
<div className="si-head">
|
|
<h1>Modulo Sport <span className="si-badge si-badge--pro">Pro</span></h1>
|
|
<p>Sessioni di allenamento, zone DFA Alpha1, carico e recupero degli atleti</p>
|
|
</div>
|
|
|
|
<div className="si-tabs" role="tablist">
|
|
{TABS.map((t) => (
|
|
<button key={t.key} type="button" role="tab" className="si-tabs__btn" aria-selected={t.key === tab} onClick={() => setTab(t.key)}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'dashboard' && (
|
|
<div className="si-stack">
|
|
<div className="si-grid si-grid--kpi">
|
|
{kpi.map((k) => (
|
|
<div className="si-card" key={k.label}>
|
|
<p className="si-kpi__label">{k.label}</p>
|
|
<p className="si-kpi__value">{k.value}</p>
|
|
{k.note && <p className="si-muted si-small">{k.note}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<section className="si-card">
|
|
<div className="si-card__head">
|
|
<div>
|
|
<h2 className="si-card__title">Sessioni per settimana</h2>
|
|
<p className="si-muted si-small">Ultime 12 settimane</p>
|
|
</div>
|
|
</div>
|
|
<DataChart type="bar" data={settimane} xKey="settimana" series={[{ key: 'sessioni', label: 'Sessioni', color: '#9c8b70' }]} />
|
|
</section>
|
|
|
|
<section className="si-card">
|
|
<div className="si-card__head"><h2 className="si-card__title">Ultime sessioni</h2></div>
|
|
<div className="si-scroll-x">
|
|
<table className="si-table">
|
|
<thead>
|
|
<tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{sessions.slice(0, 10).map((s) => (
|
|
<tr key={s.id}>
|
|
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
|
<td className="si-muted">{dataBreve(s.startedAt)}</td>
|
|
<td>{s.sport ?? '—'}</td>
|
|
<td className="si-table__num">{durata(s.durationSec)}</td>
|
|
<td className="si-table__num">{s.trimp}</td>
|
|
<td className="si-table__num">{s.hrAvg} bpm</td>
|
|
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'sessioni' && (
|
|
<div className="si-stack">
|
|
<section className="si-card">
|
|
<div className="si-filters">
|
|
<label className="si-field">Atleta
|
|
<select value={atleta} onChange={(e) => setAtleta(e.target.value)}>
|
|
<option value="tutti">Tutti</option>
|
|
{athletes.map((a) => <option key={a.clientId} value={a.clientId}>{a.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="si-field">Periodo
|
|
<select value={periodo} onChange={(e) => setPeriodo(e.target.value)}>
|
|
{PERIODI.map((p) => <option key={p.key} value={p.key}>{p.label}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="si-field">Sport
|
|
<select value={sport} onChange={(e) => setSport(e.target.value)}>
|
|
<option value="tutti">Tutti</option>
|
|
{sports.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</label>
|
|
<p className="si-muted si-small si-filters__count">
|
|
{filtrate.length} {filtrate.length === 1 ? 'sessione' : 'sessioni'}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="si-card">
|
|
<div className="si-scroll-x">
|
|
<table className="si-table">
|
|
<thead>
|
|
<tr><th>Data e ora</th><th>Atleta</th><th>Sport</th><th>Durata</th><th>HR medio</th><th>HR max</th><th>TRIMP</th><th>DFA A1</th><th>RPE</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtrate.map((s) => (
|
|
<tr key={s.id}>
|
|
<td className="si-muted">{dataOra(s.startedAt)}</td>
|
|
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
|
<td>{s.sport ?? '—'}</td>
|
|
<td className="si-table__num">{durata(s.durationSec)}</td>
|
|
<td className="si-table__num">{s.hrAvg}</td>
|
|
<td className="si-table__num">{s.hrMax}</td>
|
|
<td className="si-table__num">{s.trimp}</td>
|
|
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
|
<td className="si-table__num">{s.rpe ? `${s.rpe}/10` : '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{filtrate.length === 0 && <p className="si-muted si-small si-empty">Nessuna sessione con questi filtri.</p>}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'atleti' && (
|
|
<div className="si-grid si-grid--halves">
|
|
{atletiRiepilogo.map((a) => (
|
|
<section className="si-card si-athlete" key={a.clientId}>
|
|
<div className="si-card__head">
|
|
<div>
|
|
<h2 className="si-athlete__name">{a.name}</h2>
|
|
<p className="si-muted si-small">{a.sport ?? 'Sport non specificato'}</p>
|
|
</div>
|
|
<span className={`si-badge si-badge--${a.trend === 'in calo' ? 'bad' : a.trend === 'in crescita' ? 'ok' : 'warn'}`}>
|
|
{a.trend}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="si-athlete__stats">
|
|
<div><p className="si-athlete__num">{a.sessioni30}</p><p className="si-muted si-small">Sessioni 30gg</p></div>
|
|
<div><p className="si-athlete__num">{a.trimp7}</p><p className="si-muted si-small">TRIMP 7gg</p></div>
|
|
<div><p className="si-athlete__num">{a.ultimoTrimp}</p><p className="si-muted si-small">Ultimo TRIMP</p></div>
|
|
</div>
|
|
|
|
<a className="si-athlete__foot" href={`/piattaforme/stress-index/clienti/${a.clientId}`}>
|
|
<span>{a.ultimaSessione ? `Ultima sessione ${dataBreve(a.ultimaSessione)}` : 'Nessuna sessione registrata'}</span>
|
|
<span aria-hidden="true">→</span>
|
|
</a>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|