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:
@@ -226,6 +226,10 @@ export function createDb(path?: string): Database.Database {
|
||||
// righe esistenti; il vincolo sui valori ammessi vive in auth.ts (isRole), non nello schema.
|
||||
db.prepare("UPDATE users SET role = 'piattaforme' WHERE role = 'campus'").run();
|
||||
|
||||
// Rimossa la variante di test /promo-eventi2 e la sua voce di menu (era la settima,
|
||||
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
||||
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
|
||||
|
||||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const clients: Client[] = [
|
||||
{ id: 'c10', name: 'V. Costa', initials: 'VC', age: 36, sex: 'F', tags: ['ansia', 'lavoro'], email: 'v.costa@esempio.it', since: '2024-07-01', lastMeasurement: '2026-07-10', stress: 58, alerts: 1 },
|
||||
];
|
||||
|
||||
export interface Measurement {
|
||||
export type Measurement = {
|
||||
date: string;
|
||||
stress: number;
|
||||
recovery: number;
|
||||
@@ -120,12 +120,13 @@ export const studioKpis = {
|
||||
retentionRate: 92,
|
||||
};
|
||||
|
||||
// `fill` colora la singola barra: le fasce basse sono "buone", le alte critiche.
|
||||
export const scoreDistribution = [
|
||||
{ range: '0-20', count: 0 },
|
||||
{ range: '21-40', count: 2 },
|
||||
{ range: '41-60', count: 3 },
|
||||
{ range: '61-80', count: 4 },
|
||||
{ range: '81-100', count: 1 },
|
||||
{ range: '0-20', count: 0, fill: '#6f8f6a' },
|
||||
{ range: '21-40', count: 2, fill: '#6f8f6a' },
|
||||
{ range: '41-60', count: 3, fill: '#c08a3e' },
|
||||
{ range: '61-80', count: 4, fill: '#b05a4e' },
|
||||
{ range: '81-100', count: 1, fill: '#8d3f34' },
|
||||
];
|
||||
|
||||
export const segments = [
|
||||
@@ -146,26 +147,66 @@ export const criticalClients = [
|
||||
{ clientId: 'c6', name: 'E. Ricci', stress: 70 },
|
||||
];
|
||||
|
||||
export const sessions = [
|
||||
{ id: 's1', athleteId: 'c2', date: '2026-07-11', type: 'Corsa', durationMin: 45, load: 62, trimp: 58, hrAvg: 142, dfaA1: 0.72 },
|
||||
{ id: 's2', athleteId: 'c5', date: '2026-07-08', type: 'Ciclismo', durationMin: 90, load: 74, trimp: 96, hrAvg: 138, dfaA1: 0.61 },
|
||||
{ id: 's3', athleteId: 'c8', date: '2026-07-12', type: 'Nuoto', durationMin: 40, load: 50, trimp: 45, hrAvg: 128, dfaA1: 0.8 },
|
||||
{ id: 's4', athleteId: 'c2', date: '2026-07-04', type: 'Pesi', durationMin: 60, load: 68, trimp: 70, hrAvg: 145, dfaA1: 0.55 },
|
||||
{ id: 's5', athleteId: 'c5', date: '2026-07-01', type: 'Corsa', durationMin: 50, load: 58, trimp: 52, hrAvg: 135, dfaA1: 0.75 },
|
||||
// Ultimo giorno coperto dai dati dimostrativi dello Sport.
|
||||
const OGGI_SPORT = '2026-07-12';
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
athleteId: string;
|
||||
/** Inizio della sessione, data e ora locali. */
|
||||
startedAt: string;
|
||||
durationSec: number;
|
||||
/** Lo sport può non essere indicato: l'app lo ricava dal sensore solo se dichiarato. */
|
||||
sport: string | null;
|
||||
hrAvg: number;
|
||||
hrMax: number;
|
||||
trimp: number;
|
||||
/** DFA Alpha1 a fine sessione; assente sulle registrazioni troppo brevi. */
|
||||
dfaA1: number | null;
|
||||
/** Sforzo percepito 1-10, inserito a mano dall'atleta. */
|
||||
rpe: number | null;
|
||||
};
|
||||
|
||||
export type Athlete = { clientId: string; name: string; sport: string | null };
|
||||
|
||||
export const athletes: Athlete[] = [
|
||||
{ clientId: 'c2', name: 'A. Neri', sport: 'Corsa' },
|
||||
{ clientId: 'c5', name: 'P. Colombo', sport: 'Ciclismo' },
|
||||
{ clientId: 'c8', name: 'C. Greco', sport: null },
|
||||
];
|
||||
|
||||
export const athletes = [
|
||||
{ clientId: 'c2', name: 'A. Neri', sport: 'Corsa', weeklyLoad: 130 },
|
||||
{ clientId: 'c5', name: 'P. Colombo', sport: 'Ciclismo', weeklyLoad: 164 },
|
||||
{ clientId: 'c8', name: 'C. Greco', sport: 'Nuoto', weeklyLoad: 90 },
|
||||
];
|
||||
// Venti sessioni distribuite sugli ultimi due mesi, generate in modo deterministico dallo
|
||||
// stesso schema seno/coseno usato per le misurazioni: nessun valore casuale, così server e
|
||||
// client disegnano gli stessi grafici.
|
||||
function buildSessions(): Session[] {
|
||||
const out: Session[] = [];
|
||||
const fine = new Date(`${OGGI_SPORT}T00:00:00`);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const atleta = athletes[i % athletes.length];
|
||||
const giorno = new Date(fine);
|
||||
giorno.setDate(giorno.getDate() - Math.round(i * 2.6));
|
||||
const seed = i * 7 + atleta.clientId.charCodeAt(1);
|
||||
const ora = 7 + (i % 12);
|
||||
const minuti = (seed * 13) % 60;
|
||||
const durata = i % 9 === 0 ? 5 + (seed % 40) : 540 + Math.round(Math.abs(Math.sin(seed)) * 4400);
|
||||
const hrAvg = 95 + Math.round(Math.abs(Math.cos(seed)) * 55);
|
||||
out.push({
|
||||
id: `s${i + 1}`,
|
||||
athleteId: atleta.clientId,
|
||||
startedAt: `${giorno.toISOString().slice(0, 10)}T${String(ora).padStart(2, '0')}:${String(minuti).padStart(2, '0')}`,
|
||||
durationSec: durata,
|
||||
sport: atleta.sport,
|
||||
hrAvg,
|
||||
hrMax: hrAvg + 15 + (seed % 25),
|
||||
trimp: durata < 300 ? 0 : Math.max(4, Math.round((durata / 60) * (hrAvg / 110))),
|
||||
dfaA1: durata < 300 ? null : Math.round((0.85 + Math.abs(Math.sin(seed * 1.7)) * 0.85) * 100) / 100,
|
||||
rpe: i % 7 === 2 ? null : 3 + (seed % 6),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
||||
}
|
||||
|
||||
export const sessionsPerWeek = [
|
||||
{ week: 'S1', count: 4 }, { week: 'S2', count: 5 }, { week: 'S3', count: 3 },
|
||||
{ week: 'S4', count: 6 }, { week: 'S5', count: 4 }, { week: 'S6', count: 5 },
|
||||
{ week: 'S7', count: 2 }, { week: 'S8', count: 5 }, { week: 'S9', count: 6 },
|
||||
{ week: 'S10', count: 4 }, { week: 'S11', count: 5 }, { week: 'S12', count: 3 },
|
||||
];
|
||||
export const sessions: Session[] = buildSessions();
|
||||
|
||||
// "Oggi" della piattaforma: i dati dimostrativi sono ancorati a questa data, quindi i conteggi
|
||||
// "giorni fa" restano coerenti invece di crescere col passare del tempo reale.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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',
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user