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:
2026-07-31 16:20:40 +02:00
parent c4eb598a3a
commit 3cda76b4c4
17 changed files with 907 additions and 409 deletions
@@ -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>
);
}