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>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// Analytics di studio. È un'isola React perché tutto dipende da due controlli — l'intervallo
|
||||
// di date e la dimensione di confronto — e ogni numero della pagina si ricalcola con essi.
|
||||
import { useMemo, useState } from 'react';
|
||||
import DataChart from './charts/DataChart';
|
||||
import type { Alert, Client, Measurement } from '../../lib/stress-index/data';
|
||||
import {
|
||||
DIMENSIONI, METRICHE, classificaRecupero, confrontoSegmenti, distribuzione, kpis,
|
||||
type Dimensione,
|
||||
} from '../../lib/stress-index/analytics';
|
||||
|
||||
interface Props {
|
||||
clients: Client[];
|
||||
measurementsByClient: Record<string, Measurement[]>;
|
||||
alerts: Alert[];
|
||||
/** Intervallo iniziale, deciso dalla pagina. */
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const dataItaliana = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
|
||||
export default function AnalyticsView({ clients, measurementsByClient, alerts, from, to }: Props) {
|
||||
const [periodo, setPeriodo] = useState({ from, to });
|
||||
const [dimensione, setDimensione] = useState<Dimensione>('sesso');
|
||||
|
||||
const indicatori = useMemo(() => kpis(clients, measurementsByClient, alerts, periodo), [clients, measurementsByClient, alerts, periodo]);
|
||||
const fasce = useMemo(() => distribuzione(measurementsByClient, periodo), [measurementsByClient, periodo]);
|
||||
const classifica = useMemo(() => classificaRecupero(clients, measurementsByClient, periodo), [clients, measurementsByClient, periodo]);
|
||||
const segmenti = useMemo(() => confrontoSegmenti(clients, measurementsByClient, periodo, dimensione), [clients, measurementsByClient, periodo, dimensione]);
|
||||
|
||||
const migliori = classifica.slice(0, 4);
|
||||
const critici = [...classifica].reverse().slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="si-stack">
|
||||
<div className="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Analytics di studio</h1>
|
||||
<p>Confronta i tuoi clienti, individua gli andamenti, calibra il lavoro</p>
|
||||
</div>
|
||||
<div className="si-range">
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dal</span>
|
||||
<input type="date" value={periodo.from} max={periodo.to} onChange={(e) => setPeriodo((p) => ({ ...p, from: e.target.value }))} />
|
||||
</label>
|
||||
<span className="si-range__sep" aria-hidden="true">–</span>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Al</span>
|
||||
<input type="date" value={periodo.to} min={periodo.from} onChange={(e) => setPeriodo((p) => ({ ...p, to: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{indicatori.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>
|
||||
<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">Distribuzione score clienti</h2>
|
||||
<p className="si-muted si-small">Quante misurazioni cadono in ogni fascia di valore</p>
|
||||
</div>
|
||||
</div>
|
||||
<DataChart type="bar" data={fasce} xKey="range" series={METRICHE.map((m) => ({ ...m }))} height={300} />
|
||||
</section>
|
||||
|
||||
<div className="si-grid si-grid--halves">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Andamento migliore</h2>
|
||||
<p className="si-muted si-small">Recupero medio più alto nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{migliori.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--ok">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Clienti critici</h2>
|
||||
<p className="si-muted si-small">Recupero medio più basso nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{critici.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--bad">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Confronto segmenti</h2>
|
||||
<p className="si-muted si-small">Valori medi per {dataItaliana(periodo.from)} – {dataItaliana(periodo.to)}</p>
|
||||
</div>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dimensione di confronto</span>
|
||||
<select value={dimensione} onChange={(e) => setDimensione(e.target.value as Dimensione)}>
|
||||
{DIMENSIONI.map((d) => <option key={d.key} value={d.key}>{d.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<DataChart
|
||||
type="bar" data={segmenti} xKey="gruppo" height={300}
|
||||
series={[
|
||||
{ key: 'stress', label: 'Stress medio', color: '#b05a4e' },
|
||||
{ key: 'recovery', label: 'Recupero medio', color: '#6f8f6a' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export default function DataChart({ type = 'line', data, xKey, series, height =
|
||||
{series.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) =>
|
||||
type === 'bar' ? (
|
||||
// Un dato che porta con sé un campo `fill` colora la propria barra: è così che
|
||||
// l'istogramma delle fasce di score usa un colore per fascia.
|
||||
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
|
||||
) : (
|
||||
<Line
|
||||
|
||||
Reference in New Issue
Block a user