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
+141
View File
@@ -0,0 +1,141 @@
// Calcoli della vista Analytics. Stanno fuori dal componente perché sono la parte con una
// logica vera — e quindi l'unica che vale la pena testare — mentre la vista si limita a
// disegnare ciò che esce da qui.
import type { Alert, Client, Measurement } from './data';
export interface Periodo { from: string; to: string }
export const METRICHE = [
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
{ key: 'balance', label: 'Equilibrio', color: '#7d9b96' },
{ key: 'energy', label: 'Energia', color: '#c08a3e' },
] as const;
export const FASCE = ['0-20', '21-40', '41-60', '61-80', '81-100'];
/** Fascia di appartenenza di uno score 0-100. */
export function fascia(value: number): string {
if (value <= 20) return FASCE[0];
if (value <= 40) return FASCE[1];
if (value <= 60) return FASCE[2];
if (value <= 80) return FASCE[3];
return FASCE[4];
}
const dentro = (date: string, p: Periodo) => date >= p.from && date <= p.to;
/** Misurazioni del periodo, cliente per cliente. */
export function misurazioniNelPeriodo(
byClient: Record<string, Measurement[]>,
periodo: Periodo
): Record<string, Measurement[]> {
const out: Record<string, Measurement[]> = {};
for (const [id, serie] of Object.entries(byClient)) {
out[id] = serie.filter((m) => dentro(m.date, periodo));
}
return out;
}
export interface Kpi { label: string; value: string | number; note: string }
export function kpis(
clients: Client[],
byClient: Record<string, Measurement[]>,
alerts: Alert[],
periodo: Periodo
): Kpi[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
const attivi = clients.filter((c) => (nelPeriodo[c.id] ?? []).length > 0).length;
const misurazioni = Object.values(nelPeriodo).reduce((n, s) => n + s.length, 0);
const alertAttivi = alerts.filter((a) => dentro(a.when, periodo)).length;
const aderenza = clients.length ? Math.round((attivi / clients.length) * 100) : 0;
return [
{ label: 'Clienti attivi', value: attivi, note: `/ ${clients.length} totali` },
{ label: 'Misurazioni', value: misurazioni, note: 'nel periodo' },
{ label: 'Alert attivi', value: alertAttivi, note: 'nel periodo' },
{ label: 'Aderenza', value: `${aderenza}%`, note: 'hanno misurato' },
];
}
/** Quante misurazioni cadono in ogni fascia, separatamente per ciascuna metrica. */
export function distribuzione(
byClient: Record<string, Measurement[]>,
periodo: Periodo
): Record<string, number | string>[] {
const righe = FASCE.map((f) => {
const riga: Record<string, number | string> = { range: f };
for (const m of METRICHE) riga[m.key] = 0;
return riga;
});
for (const serie of Object.values(misurazioniNelPeriodo(byClient, periodo))) {
for (const misura of serie) {
for (const metrica of METRICHE) {
const riga = righe[FASCE.indexOf(fascia(misura[metrica.key]))];
riga[metrica.key] = (riga[metrica.key] as number) + 1;
}
}
}
return righe;
}
export interface RigaClassifica { clientId: string; name: string; recovery: number; count: number }
/** Clienti ordinati per recupero medio nel periodo: i migliori in testa. */
export function classificaRecupero(
clients: Client[],
byClient: Record<string, Measurement[]>,
periodo: Periodo
): RigaClassifica[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
return clients
.map((c) => {
const serie = nelPeriodo[c.id] ?? [];
const media = serie.length ? serie.reduce((n, m) => n + m.recovery, 0) / serie.length : 0;
return { clientId: c.id, name: c.name, recovery: Math.round(media), count: serie.length };
})
.filter((r) => r.count > 0)
.sort((a, b) => b.recovery - a.recovery);
}
export type Dimensione = 'sesso' | 'eta' | 'tag';
export const DIMENSIONI: { key: Dimensione; label: string }[] = [
{ key: 'sesso', label: 'Sesso' },
{ key: 'eta', label: 'Età' },
{ key: 'tag', label: 'Tag principale' },
];
function gruppoDi(client: Client, dimensione: Dimensione): string {
if (dimensione === 'sesso') return client.sex === 'F' ? 'Donne' : 'Uomini';
if (dimensione === 'eta') return client.age < 35 ? 'Fino a 34' : client.age < 50 ? '35-49' : '50 e oltre';
return client.tags[0] ?? 'Senza tag';
}
/** Stress e recupero medi per gruppo, secondo la dimensione scelta. */
export function confrontoSegmenti(
clients: Client[],
byClient: Record<string, Measurement[]>,
periodo: Periodo,
dimensione: Dimensione
): Record<string, number | string>[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
const gruppi = new Map<string, { stress: number; recovery: number; n: number }>();
for (const client of clients) {
const serie = nelPeriodo[client.id] ?? [];
if (!serie.length) continue;
const nome = gruppoDi(client, dimensione);
const acc = gruppi.get(nome) ?? { stress: 0, recovery: 0, n: 0 };
for (const m of serie) {
acc.stress += m.stress;
acc.recovery += m.recovery;
acc.n += 1;
}
gruppi.set(nome, acc);
}
return [...gruppi.entries()].map(([gruppo, a]) => ({
gruppo,
stress: Math.round(a.stress / a.n),
recovery: Math.round(a.recovery / a.n),
}));
}