3cda76b4c4
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>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
// 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),
|
|
}));
|
|
}
|