Files
InsanityLab-web/src/components/stress-index/SportView.tsx
T
Adriano 3cda76b4c4 Stress Index: Analytics e Sport rifatti sulle viste reali
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>
2026-07-31 16:20:40 +02:00

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>
);
}