Files
InsanityLab-web/src/lib/stress-index/sport.ts
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

106 lines
4.4 KiB
TypeScript

// Calcoli e formattazioni del modulo Sport, tenuti fuori dalla vista perché sono la parte
// con una logica propria (finestre temporali, aggregazioni per settimana, riepiloghi atleta).
import type { Athlete, Session } from './data';
/** "1h 21m" oltre l'ora, "54m 35s" sopra il minuto, "5s" sotto. */
export function durata(sec: number): string {
if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`;
if (sec >= 60) return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`;
return `${sec}s`;
}
const MESI = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
/** "30 lug 2026" */
export function dataBreve(iso: string): string {
const [y, m, d] = iso.slice(0, 10).split('-');
return `${Number(d)} ${MESI[Number(m) - 1]} ${y}`;
}
/** "30 lug 2026 alle 11:27" */
export function dataOra(iso: string): string {
return `${dataBreve(iso)} alle ${iso.slice(11, 16)}`;
}
const giorniFra = (a: string, b: string) =>
Math.round((Date.parse(b.slice(0, 10)) - Date.parse(a.slice(0, 10))) / 86_400_000);
/** Sessioni degli ultimi `giorni` rispetto alla data di riferimento. */
export function ultimi(sessions: Session[], giorni: number, riferimento: string): Session[] {
return sessions.filter((s) => {
const d = giorniFra(s.startedAt, riferimento);
return d >= 0 && d < giorni;
});
}
export interface KpiSport { label: string; value: number; note?: string }
export function kpiSport(sessions: Session[], riferimento: string): KpiSport[] {
const settimana = ultimi(sessions, 7, riferimento);
const mese = ultimi(sessions, 30, riferimento);
const trimpSettimanale = settimana.reduce((n, s) => n + s.trimp, 0);
const atletiAttivi = new Set(mese.map((s) => s.athleteId)).size;
return [
{ label: 'Sessioni totali', value: sessions.length },
{ label: 'Questa settimana', value: settimana.length, note: 'ultimi 7gg' },
{ label: 'TRIMP medio', value: trimpSettimanale, note: 'settimanale' },
{ label: 'Atleti attivi', value: atletiAttivi, note: 'ultimi 30gg' },
];
}
/** Conteggio sessioni per settimana, etichettato col lunedì di ciascuna (dd/mm). */
export function perSettimana(sessions: Session[], settimane: number, riferimento: string): Record<string, string | number>[] {
// Tutta l'aritmetica sta in UTC: con date costruite in ora locale, `toISOString` riporta
// indietro di un giorno ovunque il fuso sia positivo, e le settimane slittano al giorno prima.
const GIORNO = 86_400_000;
const rif = Date.parse(riferimento.slice(0, 10));
// getUTCDay(): domenica = 0, quindi la domenica torna indietro di 6 giorni, non di -1.
const lunedi = rif - ((new Date(rif).getUTCDay() + 6) % 7) * GIORNO;
const righe: Record<string, string | number>[] = [];
for (let i = settimane - 1; i >= 0; i--) {
const inizio = lunedi - i * 7 * GIORNO;
const da = new Date(inizio).toISOString().slice(0, 10);
const a = new Date(inizio + 7 * GIORNO).toISOString().slice(0, 10);
righe.push({
settimana: `${da.slice(8, 10)}/${da.slice(5, 7)}`,
sessioni: sessions.filter((s) => s.startedAt.slice(0, 10) >= da && s.startedAt.slice(0, 10) < a).length,
});
}
return righe;
}
export interface RiepilogoAtleta {
clientId: string;
name: string;
sport: string | null;
sessioni30: number;
trimp7: number;
ultimoTrimp: number;
ultimaSessione: string | null;
/** Confronto fra il carico dell'ultima settimana e quello della precedente. */
trend: 'in crescita' | 'stabile' | 'in calo';
}
export function riepilogoAtleti(sessions: Session[], athletes: Athlete[], riferimento: string): RiepilogoAtleta[] {
return athletes.map((a) => {
const sue = sessions.filter((s) => s.athleteId === a.clientId);
const trimp7 = ultimi(sue, 7, riferimento).reduce((n, s) => n + s.trimp, 0);
const settimanaPrima = sue.filter((s) => {
const d = giorniFra(s.startedAt, riferimento);
return d >= 7 && d < 14;
}).reduce((n, s) => n + s.trimp, 0);
const ultima = sue[0] ?? null;
const scarto = trimp7 - settimanaPrima;
return {
clientId: a.clientId,
name: a.name,
sport: a.sport,
sessioni30: ultimi(sue, 30, riferimento).length,
trimp7,
ultimoTrimp: ultima?.trimp ?? 0,
ultimaSessione: ultima?.startedAt ?? null,
trend: Math.abs(scarto) <= 10 ? 'stabile' : scarto > 0 ? 'in crescita' : 'in calo',
};
});
}