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
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
fascia, misurazioniNelPeriodo, kpis, distribuzione, classificaRecupero, confrontoSegmenti,
} from '../src/lib/stress-index/analytics';
import type { Alert, Client, Measurement } from '../src/lib/stress-index/data';
const PERIODO = { from: '2026-07-01', to: '2026-07-31' };
const misura = (date: string, stress: number, recovery: number): Measurement =>
({ date, stress, recovery, balance: 50, energy: 50, type: 'Riposo', durationMin: 20 });
const cliente = (id: string, name: string, sex: 'M' | 'F', age: number): Client =>
({ id, name, initials: 'XX', age, sex, tags: ['test'], email: `${id}@esempio.it`,
since: '2024-01-01', lastMeasurement: '2026-07-10', stress: 50, alerts: 0 });
const clients = [cliente('a', 'A. Uno', 'M', 30), cliente('b', 'B. Due', 'F', 55), cliente('c', 'C. Tre', 'F', 40)];
const byClient: Record<string, Measurement[]> = {
// dentro il periodo
a: [misura('2026-07-05', 10, 90), misura('2026-07-12', 30, 70)],
// una dentro, una fuori
b: [misura('2026-06-20', 80, 20), misura('2026-07-20', 90, 30)],
// tutte fuori periodo
c: [misura('2026-05-01', 50, 50)],
};
const alerts: Alert[] = [
{ clientId: 'a', clientName: 'A. Uno', text: 'x', severity: 'warn', when: '2026-07-03' },
{ clientId: 'b', clientName: 'B. Due', text: 'y', severity: 'error', when: '2026-06-01' },
];
describe('fasce di score', () => {
it('assegna gli estremi alla fascia giusta', () => {
expect(fascia(0)).toBe('0-20');
expect(fascia(20)).toBe('0-20');
expect(fascia(21)).toBe('21-40');
expect(fascia(60)).toBe('41-60');
expect(fascia(81)).toBe('81-100');
expect(fascia(100)).toBe('81-100');
});
});
describe('filtro sul periodo', () => {
it('tiene solo le misurazioni comprese fra le due date, estremi inclusi', () => {
const dentro = misurazioniNelPeriodo(byClient, PERIODO);
expect(dentro.a.map((m) => m.date)).toEqual(['2026-07-05', '2026-07-12']);
expect(dentro.b.map((m) => m.date)).toEqual(['2026-07-20']);
expect(dentro.c).toEqual([]);
});
});
describe('kpi', () => {
it('conta clienti attivi, misurazioni, alert del periodo e aderenza', () => {
expect(kpis(clients, byClient, alerts, PERIODO)).toEqual([
{ label: 'Clienti attivi', value: 2, note: '/ 3 totali' },
{ label: 'Misurazioni', value: 3, note: 'nel periodo' },
{ label: 'Alert attivi', value: 1, note: 'nel periodo' },
{ label: 'Aderenza', value: '67%', note: 'hanno misurato' },
]);
});
});
describe('distribuzione per fascia', () => {
it('conta ogni metrica nella propria fascia', () => {
const righe = distribuzione(byClient, PERIODO);
const perFascia = Object.fromEntries(righe.map((r) => [r.range, r]));
// stress: 10 → 0-20, 30 → 21-40, 90 → 81-100
expect(perFascia['0-20'].stress).toBe(1);
expect(perFascia['21-40'].stress).toBe(1);
expect(perFascia['81-100'].stress).toBe(1);
// recovery: 90 → 81-100, 70 → 61-80, 30 → 21-40
expect(perFascia['81-100'].recovery).toBe(1);
expect(perFascia['61-80'].recovery).toBe(1);
expect(perFascia['21-40'].recovery).toBe(1);
// le tre misurazioni hanno tutte balance ed energy a 50
expect(perFascia['41-60'].balance).toBe(3);
expect(perFascia['41-60'].energy).toBe(3);
});
});
describe('classifica per recupero', () => {
it('ordina dal recupero medio più alto e ignora chi non ha misurato', () => {
expect(classificaRecupero(clients, byClient, PERIODO)).toEqual([
{ clientId: 'a', name: 'A. Uno', recovery: 80, count: 2 },
{ clientId: 'b', name: 'B. Due', recovery: 30, count: 1 },
]);
});
});
describe('confronto segmenti', () => {
it('media stress e recupero per sesso', () => {
expect(confrontoSegmenti(clients, byClient, PERIODO, 'sesso')).toEqual([
{ gruppo: 'Uomini', stress: 20, recovery: 80 },
{ gruppo: 'Donne', stress: 90, recovery: 30 },
]);
});
it('raggruppa per fascia di età', () => {
expect(confrontoSegmenti(clients, byClient, PERIODO, 'eta')).toEqual([
{ gruppo: 'Fino a 34', stress: 20, recovery: 80 },
{ gruppo: '50 e oltre', stress: 90, recovery: 30 },
]);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { durata, dataBreve, dataOra, ultimi, kpiSport, perSettimana, riepilogoAtleti } from '../src/lib/stress-index/sport';
import type { Athlete, Session } from '../src/lib/stress-index/data';
const RIF = '2026-07-12';
const sess = (id: string, athleteId: string, startedAt: string, trimp: number): Session =>
({ id, athleteId, startedAt, durationSec: 1800, sport: null, hrAvg: 120, hrMax: 150, trimp, dfaA1: 1.2, rpe: 5 });
const athletes: Athlete[] = [
{ clientId: 'a', name: 'A. Uno', sport: 'Corsa' },
{ clientId: 'b', name: 'B. Due', sport: null },
];
// Ordinate dalla più recente, come le espone data.ts
const sessions: Session[] = [
sess('s1', 'a', '2026-07-12T09:00', 40),
sess('s2', 'a', '2026-07-08T09:00', 30),
sess('s3', 'a', '2026-07-02T09:00', 60), // settimana precedente
sess('s4', 'b', '2026-06-20T09:00', 20), // oltre i 30 giorni? no: 22 giorni prima
sess('s5', 'b', '2026-04-01T09:00', 15), // fuori da tutte le finestre
];
describe('formattazioni', () => {
it('durata: ore, minuti e secondi', () => {
expect(durata(4860)).toBe('1h 21m');
expect(durata(3275)).toBe('54m 35s');
expect(durata(5)).toBe('5s');
});
it('date in forma breve ed estesa', () => {
expect(dataBreve('2026-07-30T11:27')).toBe('30 lug 2026');
expect(dataOra('2026-07-30T11:27')).toBe('30 lug 2026 alle 11:27');
});
});
describe('finestre temporali', () => {
it('gli ultimi 7 giorni includono il giorno di riferimento ed escludono il settimo indietro', () => {
expect(ultimi(sessions, 7, RIF).map((s) => s.id)).toEqual(['s1', 's2']);
});
it('gli ultimi 30 giorni allargano a chi ha misurato nel mese', () => {
expect(ultimi(sessions, 30, RIF).map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']);
});
});
describe('kpi', () => {
it('conta sessioni, carico settimanale e atleti attivi nel mese', () => {
expect(kpiSport(sessions, RIF)).toEqual([
{ label: 'Sessioni totali', value: 5 },
{ label: 'Questa settimana', value: 2, note: 'ultimi 7gg' },
{ label: 'TRIMP medio', value: 70, note: 'settimanale' },
{ label: 'Atleti attivi', value: 2, note: 'ultimi 30gg' },
]);
});
});
describe('sessioni per settimana', () => {
const righe = perSettimana(sessions, 4, RIF);
it('produce una riga per settimana, la più recente per ultima', () => {
expect(righe).toHaveLength(4);
// 2026-07-12 è una domenica: la sua settimana parte lunedì 06/07
expect(righe[righe.length - 1].settimana).toBe('06/07');
});
it('conta le sessioni nella settimana giusta', () => {
const perEtichetta = Object.fromEntries(righe.map((r) => [r.settimana, r.sessioni]));
expect(perEtichetta['06/07']).toBe(2); // s1 e s2
expect(perEtichetta['29/06']).toBe(1); // s3
});
});
describe('riepilogo atleti', () => {
const riepilogo = riepilogoAtleti(sessions, athletes, RIF);
it('somma il carico dei 7 giorni e riporta ultima sessione e conteggio mensile', () => {
expect(riepilogo[0]).toEqual({
clientId: 'a', name: 'A. Uno', sport: 'Corsa',
sessioni30: 3, trimp7: 70, ultimoTrimp: 40,
ultimaSessione: '2026-07-12T09:00', trend: 'stabile',
});
});
it('resta stabile quando lo scarto fra le due settimane è minimo', () => {
// b non ha nulla negli ultimi 7 giorni né nei 7 precedenti → nessuno scarto, resta stabile
expect(riepilogo[1].trend).toBe('stabile');
expect(riepilogo[1].sessioni30).toBe(1);
});
});