diff --git a/src/components/stress-index/AnalyticsView.tsx b/src/components/stress-index/AnalyticsView.tsx new file mode 100644 index 0000000..efa2b7d --- /dev/null +++ b/src/components/stress-index/AnalyticsView.tsx @@ -0,0 +1,140 @@ +// Analytics di studio. È un'isola React perché tutto dipende da due controlli — l'intervallo +// di date e la dimensione di confronto — e ogni numero della pagina si ricalcola con essi. +import { useMemo, useState } from 'react'; +import DataChart from './charts/DataChart'; +import type { Alert, Client, Measurement } from '../../lib/stress-index/data'; +import { + DIMENSIONI, METRICHE, classificaRecupero, confrontoSegmenti, distribuzione, kpis, + type Dimensione, +} from '../../lib/stress-index/analytics'; + +interface Props { + clients: Client[]; + measurementsByClient: Record; + alerts: Alert[]; + /** Intervallo iniziale, deciso dalla pagina. */ + from: string; + to: string; +} + +const dataItaliana = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }); + +export default function AnalyticsView({ clients, measurementsByClient, alerts, from, to }: Props) { + const [periodo, setPeriodo] = useState({ from, to }); + const [dimensione, setDimensione] = useState('sesso'); + + const indicatori = useMemo(() => kpis(clients, measurementsByClient, alerts, periodo), [clients, measurementsByClient, alerts, periodo]); + const fasce = useMemo(() => distribuzione(measurementsByClient, periodo), [measurementsByClient, periodo]); + const classifica = useMemo(() => classificaRecupero(clients, measurementsByClient, periodo), [clients, measurementsByClient, periodo]); + const segmenti = useMemo(() => confrontoSegmenti(clients, measurementsByClient, periodo, dimensione), [clients, measurementsByClient, periodo, dimensione]); + + const migliori = classifica.slice(0, 4); + const critici = [...classifica].reverse().slice(0, 4); + + return ( +
+
+
+

Analytics di studio

+

Confronta i tuoi clienti, individua gli andamenti, calibra il lavoro

+
+
+ + + +
+
+ +
+ {indicatori.map((k) => ( +
+

{k.label}

+

{k.value}

+

{k.note}

+
+ ))} +
+ +
+
+
+

Distribuzione score clienti

+

Quante misurazioni cadono in ogni fascia di valore

+
+
+ ({ ...m }))} height={300} /> +
+ +
+
+
+
+

Andamento migliore

+

Recupero medio più alto nel periodo

+
+
+
    + {migliori.map((r) => ( +
  • + {r.name} + + {r.recovery} + {r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'} + +
  • + ))} +
+
+ +
+
+
+

Clienti critici

+

Recupero medio più basso nel periodo

+
+
+
    + {critici.map((r) => ( +
  • + {r.name} + + {r.recovery} + {r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'} + +
  • + ))} +
+
+
+ +
+
+
+

Confronto segmenti

+

Valori medi per {dataItaliana(periodo.from)} – {dataItaliana(periodo.to)}

+
+ +
+ +
+
+ ); +} diff --git a/src/components/stress-index/SportView.tsx b/src/components/stress-index/SportView.tsx new file mode 100644 index 0000000..6bba711 --- /dev/null +++ b/src/components/stress-index/SportView.tsx @@ -0,0 +1,204 @@ +// Modulo Sport: tre viste (dashboard, elenco sessioni con filtri, schede atleta) in un'unica +// isola, perché condividono lo stesso insieme di sessioni e i filtri agiscono su quello. +import { useMemo, useState } from 'react'; +import DataChart from './charts/DataChart'; +import type { Athlete, Session } from '../../lib/stress-index/data'; +import { dataBreve, dataOra, durata, kpiSport, perSettimana, riepilogoAtleti } from '../../lib/stress-index/sport'; + +interface Props { + sessions: Session[]; + athletes: Athlete[]; + /** Ultimo giorno coperto dai dati: tutte le finestre temporali partono da qui. */ + riferimento: string; +} + +const TABS = [ + { key: 'dashboard', label: 'Dashboard' }, + { key: 'sessioni', label: 'Sessioni' }, + { key: 'atleti', label: 'Atleti' }, +]; + +const PERIODI = [ + { key: 'tutto', label: 'Tutto', giorni: 0 }, + { key: '7', label: 'Ultimi 7 giorni', giorni: 7 }, + { key: '30', label: 'Ultimi 30 giorni', giorni: 30 }, + { key: '90', label: 'Ultimi 90 giorni', giorni: 90 }, +]; + +export default function SportView({ sessions, athletes, riferimento }: Props) { + const [tab, setTab] = useState('dashboard'); + const [atleta, setAtleta] = useState('tutti'); + const [periodo, setPeriodo] = useState('tutto'); + const [sport, setSport] = useState('tutti'); + + const sports = useMemo( + () => [...new Set(sessions.map((s) => s.sport).filter((s): s is string => Boolean(s)))], + [sessions] + ); + + const filtrate = useMemo(() => { + const giorni = PERIODI.find((p) => p.key === periodo)?.giorni ?? 0; + const limite = new Date(`${riferimento}T00:00:00`); + limite.setDate(limite.getDate() - giorni + 1); + const dal = limite.toISOString().slice(0, 10); + return sessions.filter((s) => + (atleta === 'tutti' || s.athleteId === atleta) && + (sport === 'tutti' || s.sport === sport) && + (!giorni || s.startedAt.slice(0, 10) >= dal) + ); + }, [sessions, atleta, periodo, sport, riferimento]); + + const kpi = useMemo(() => kpiSport(sessions, riferimento), [sessions, riferimento]); + const settimane = useMemo(() => perSettimana(sessions, 12, riferimento), [sessions, riferimento]); + const atletiRiepilogo = useMemo(() => riepilogoAtleti(sessions, athletes, riferimento), [sessions, athletes, riferimento]); + const nome = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id; + + return ( +
+
+

Modulo Sport Pro

+

Sessioni di allenamento, zone DFA Alpha1, carico e recupero degli atleti

+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'dashboard' && ( +
+
+ {kpi.map((k) => ( +
+

{k.label}

+

{k.value}

+ {k.note &&

{k.note}

} +
+ ))} +
+ +
+
+
+

Sessioni per settimana

+

Ultime 12 settimane

+
+
+ +
+ +
+

Ultime sessioni

+
+ + + + + + {sessions.slice(0, 10).map((s) => ( + + + + + + + + + + ))} + +
AtletaDataSportDurataTRIMPHR medioDFA A1
{nome(s.athleteId)}{dataBreve(s.startedAt)}{s.sport ?? '—'}{durata(s.durationSec)}{s.trimp}{s.hrAvg} bpm{s.dfaA1 ?? '—'}
+
+
+
+ )} + + {tab === 'sessioni' && ( +
+
+
+ + + +

+ {filtrate.length} {filtrate.length === 1 ? 'sessione' : 'sessioni'} +

+
+
+ +
+
+ + + + + + {filtrate.map((s) => ( + + + + + + + + + + + + ))} + +
Data e oraAtletaSportDurataHR medioHR maxTRIMPDFA A1RPE
{dataOra(s.startedAt)}{nome(s.athleteId)}{s.sport ?? '—'}{durata(s.durationSec)}{s.hrAvg}{s.hrMax}{s.trimp}{s.dfaA1 ?? '—'}{s.rpe ? `${s.rpe}/10` : '—'}
+ {filtrate.length === 0 &&

Nessuna sessione con questi filtri.

} +
+
+
+ )} + + {tab === 'atleti' && ( +
+ {atletiRiepilogo.map((a) => ( +
+
+
+

{a.name}

+

{a.sport ?? 'Sport non specificato'}

+
+ + {a.trend} + +
+ +
+

{a.sessioni30}

Sessioni 30gg

+

{a.trimp7}

TRIMP 7gg

+

{a.ultimoTrimp}

Ultimo TRIMP

+
+ + + {a.ultimaSessione ? `Ultima sessione ${dataBreve(a.ultimaSessione)}` : 'Nessuna sessione registrata'} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/stress-index/charts/DataChart.tsx b/src/components/stress-index/charts/DataChart.tsx index ef88df0..c5f3726 100644 --- a/src/components/stress-index/charts/DataChart.tsx +++ b/src/components/stress-index/charts/DataChart.tsx @@ -35,6 +35,8 @@ export default function DataChart({ type = 'line', data, xKey, series, height = {series.length > 1 && } {series.map((s, i) => type === 'bar' ? ( + // Un dato che porta con sé un campo `fill` colora la propria barra: è così che + // l'istogramma delle fasce di score usa un colore per fascia. ) : ( @@ -38,7 +36,7 @@ const isAdmin = Astro.locals.user?.role === 'admin'; InsanityLab Biohacking - ← Torna alla home + Piattaforme - -
- {isAdmin && Gestione utenti} - Esci -
@@ -108,12 +101,6 @@ const isAdmin = Astro.locals.user?.role === 'admin'; .cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); } .cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); } - .cp-foot { display: flex; flex-direction: column; gap: 10px; margin-top: 30px; - padding-top: 18px; border-top: 1px solid rgba(255,255,255,.12); } - .cp-foot a { font-size: .78rem; letter-spacing: .1em; text-transform: uppercase; - color: #8d8379; text-decoration: none; } - .cp-foot a:hover { color: #fff; } - /* --- contenuto --- */ .cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; } .cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; } diff --git a/src/layouts/StressIndex.astro b/src/layouts/StressIndex.astro index 21f17ee..b8194cd 100644 --- a/src/layouts/StressIndex.astro +++ b/src/layouts/StressIndex.astro @@ -17,7 +17,6 @@ interface Props { } const { title, crumbs = [] } = Astro.props; const path = Astro.url.pathname; -const isAdmin = Astro.locals.user?.role === 'admin'; const BASE = '/piattaforme/stress-index'; const NAV = [ @@ -48,7 +47,7 @@ const NAV = [ InsanityLab Stress Index - ← Piattaforme + Piattaforme - -
- {professional.role} - {isAdmin && Gestione utenti} - Esci -
diff --git a/src/lib/db.ts b/src/lib/db.ts index ff45380..24f0a27 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 }[]; diff --git a/src/lib/stress-index/analytics.ts b/src/lib/stress-index/analytics.ts new file mode 100644 index 0000000..f555a0c --- /dev/null +++ b/src/lib/stress-index/analytics.ts @@ -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, + periodo: Periodo +): Record { + const out: Record = {}; + 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, + 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, + periodo: Periodo +): Record[] { + const righe = FASCE.map((f) => { + const riga: Record = { 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, + 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, + periodo: Periodo, + dimensione: Dimensione +): Record[] { + const nelPeriodo = misurazioniNelPeriodo(byClient, periodo); + const gruppi = new Map(); + 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), + })); +} diff --git a/src/lib/stress-index/data.ts b/src/lib/stress-index/data.ts index 4467103..cd14cdb 100644 --- a/src/lib/stress-index/data.ts +++ b/src/lib/stress-index/data.ts @@ -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. diff --git a/src/lib/stress-index/sport.ts b/src/lib/stress-index/sport.ts new file mode 100644 index 0000000..eb6caea --- /dev/null +++ b/src/lib/stress-index/sport.ts @@ -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[] { + // 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[] = []; + 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', + }; + }); +} diff --git a/src/pages/piattaforme/stress-index/analytics.astro b/src/pages/piattaforme/stress-index/analytics.astro index b8ce274..9c9be7b 100644 --- a/src/pages/piattaforme/stress-index/analytics.astro +++ b/src/pages/piattaforme/stress-index/analytics.astro @@ -1,62 +1,22 @@ --- -// Analytics di studio: numeri aggregati sul periodo. Il selettore di periodo è ancora -// dimostrativo (i dati sorgente sono un'istantanea sola), come nel prototipo. +// Analytics di studio. La pagina fornisce i dati e l'intervallo iniziale; il resto — filtri, +// aggregazioni e grafici — è nell'isola, perché ogni numero dipende dai controlli. import StressIndex from '../../../layouts/StressIndex.astro'; -import Card from '../../../components/stress-index/Card.astro'; -import Kpi from '../../../components/stress-index/Kpi.astro'; -import StatList from '../../../components/stress-index/StatList.astro'; -import DataChart from '../../../components/stress-index/charts/DataChart'; -import { studioKpis, scoreDistribution, segments, topPerformers, criticalClients } from '../../../lib/stress-index/data'; +import AnalyticsView from '../../../components/stress-index/AnalyticsView'; +import { clients, measurementsByClient, alerts } from '../../../lib/stress-index/data'; export const prerender = false; + +// Mese corrente dei dati dimostrativi. +const DA = '2026-07-01'; +const A = '2026-07-31'; --- -
-
-

Analytics

-

Andamento dello studio nel periodo selezionato

-
- -
- -
- - - - -
- -
- - - - - -
- {segments.map((s) => ( -
-

{s.label}

-

{s.avgStress}

-

stress medio · {s.count} {s.count === 1 ? 'cliente' : 'clienti'}

-
- ))} -
-
- -
- - ({ label: c.name, value: c.stress }))} /> - - - ({ label: c.name, value: c.stress }))} /> - -
-
+
diff --git a/src/pages/piattaforme/stress-index/sport/index.astro b/src/pages/piattaforme/stress-index/sport/index.astro index 7e389ee..74dbe94 100644 --- a/src/pages/piattaforme/stress-index/sport/index.astro +++ b/src/pages/piattaforme/stress-index/sport/index.astro @@ -1,121 +1,13 @@ --- -// Modulo Sport: carico di lavoro e sessioni degli atleti seguiti. Le tre viste sono già -// tutte renderizzate: il cambio scheda è solo un mostra/nascondi, non serve un'isola. +// Modulo Sport. La pagina passa i dati; filtri, schede e aggregazioni stanno nell'isola. import StressIndex from '../../../../layouts/StressIndex.astro'; -import Card from '../../../../components/stress-index/Card.astro'; -import Kpi from '../../../../components/stress-index/Kpi.astro'; -import DataChart from '../../../../components/stress-index/charts/DataChart'; -import { sessions, athletes, sessionsPerWeek } from '../../../../lib/stress-index/data'; +import SportView from '../../../../components/stress-index/SportView'; +import { sessions, athletes } from '../../../../lib/stress-index/data'; export const prerender = false; -const trimpMedio = Math.round(sessions.reduce((s, x) => s + x.trimp, 0) / sessions.length); -const sessioniSettimana = sessionsPerWeek[sessionsPerWeek.length - 1]?.count ?? 0; -const nomeAtleta = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id; -const TABS = [ - { key: 'dashboard', label: 'Dashboard' }, - { key: 'sessioni', label: 'Sessioni' }, - { key: 'atleti', label: 'Atleti' }, -]; +// Ultimo giorno coperto dai dati dimostrativi. +const RIFERIMENTO = '2026-07-12'; --- -
-

Sport

-

Sessioni, carico e monitoraggio degli atleti

-
- -
- {TABS.map((t, i) => ( - - ))} -
- -
-
- - - - -
- - - - - - -
- - - - {sessions.map((s) => ( - - - - - - - - - - ))} - -
AtletaDataSportDurataTRIMPHR medioDFA A1
{nomeAtleta(s.athleteId)}{s.date}{s.type}{s.durationMin} min{s.trimp}{s.hrAvg}{s.dfaA1}
-
-
-
- - - - +
- - diff --git a/src/pages/promo-eventi2.astro b/src/pages/promo-eventi2.astro deleted file mode 100644 index 05a0277..0000000 --- a/src/pages/promo-eventi2.astro +++ /dev/null @@ -1,181 +0,0 @@ ---- -import Base from '../layouts/Base.astro'; -import PageHero from '../components/PageHero.astro'; -import T from '../components/content/T.astro'; -import { t, resolveContent } from '../lib/content'; -// Variante di test di /promo-eventi ristrutturata sui pattern del sito: banner PageHero -// a tutta larghezza, sezioni .section/.container su sfondo bianco, bottoni .btn globali, -// font e palette dal design system. Contenuti identici (stessi tag `promo.*`). Gli stili -// custom (griglia card, accordion FAQ) sono namespaced sotto `.promo2`. -const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428'; -export const prerender = false; - -const cards = [ - { id: 1, features: 4, featured: false }, - { id: 2, features: 4, featured: true }, - { id: 3, features: 3, featured: false }, - { id: 4, features: 3, featured: false }, -]; -const faqs = [1, 2, 3, 4, 5, 6]; -const wa = t('global.whatsapp'); ---- - - - -
- - -
-
-

- - -

- - -
-
- - -
-
- -

-

- - -
-
- - -
-
-
- {cards.map((c) => ( -
- {c.featured && } - - -
- - -
- -
    - {Array.from({ length: c.features }, (_, i) => i + 1).map((m) => ( - - ))} -
- -
- ))} -
- -
-
- - -
-
- - {faqs.map((n) => { - const a = resolveContent(`promo.faq.${n}.a`); - return ( -
-
+
-

-

- ); - })} -
-
- -
- - - - - - diff --git a/src/styles/stress-index.css b/src/styles/stress-index.css index e58cbeb..4a8ec2a 100644 --- a/src/styles/stress-index.css +++ b/src/styles/stress-index.css @@ -45,14 +45,6 @@ padding: 1px 7px; font-size: .58rem; letter-spacing: .1em; } -.si-foot { - display: flex; flex-direction: column; gap: 9px; margin-top: auto; padding-top: 20px; - border-top: 1px solid rgba(255,255,255,.12); -} -.si-foot__role { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-accent); } -.si-foot a { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: #8d8379; text-decoration: none; } -.si-foot a:hover { color: #fff; } - /* --- shell contenuto --- */ .si-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; } .si-top { @@ -285,3 +277,34 @@ .si-org p { margin: 0; } .si-org .si-field { text-align: left; } .si-org__cta:disabled { opacity: .45; cursor: not-allowed; } + +/* --- analytics: intervallo e classifiche --- */ +.si-range { display: flex; align-items: center; gap: 8px; } +.si-range__sep { color: var(--c-text-light); } +.si-range input { padding: 7px 10px; } +.si-card__head p { margin: 4px 0 0; } +.si-rank { list-style: none; margin: 0; padding: 0; } +.si-rank li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--si-line); } +.si-rank li:last-child { border-bottom: 0; } +.si-rank a { text-decoration: none; color: var(--c-heading); font-weight: 600; font-size: .9rem; } +.si-rank a:hover { color: var(--c-accent-dark); } +.si-rank__value { display: block; text-align: right; font-family: var(--font-heading); font-size: 1.05rem; } +.si-rank__value--ok { color: var(--si-ok); } +.si-rank__value--bad { color: var(--si-bad); } +.si-rank__count { display: block; text-align: right; font-size: .72rem; color: var(--c-text-light); } + +/* --- modulo sport --- */ +.si-badge--pro { background: var(--c-accent); color: #fff; vertical-align: middle; margin-left: 8px; } +.si-filters { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; } +.si-filters__count { margin: 0 0 8px auto; } +.si-athlete__name { font-size: 1.05rem; text-transform: none; letter-spacing: .02em; margin: 0 0 2px; } +.si-athlete__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.si-athlete__stats > div { background: var(--c-bg-alt); padding: 14px 10px; text-align: center; } +.si-athlete__stats p { margin: 0; } +.si-athlete__num { font-family: var(--font-heading); font-size: 1.3rem; color: var(--c-heading); } +.si-athlete__foot { + display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; + padding-top: 14px; border-top: 1px solid var(--si-line); text-decoration: none; + font-size: .82rem; color: var(--c-text-light); +} +.si-athlete__foot:hover { color: var(--c-accent-dark); } diff --git a/tests/stress-index-analytics.test.ts b/tests/stress-index-analytics.test.ts new file mode 100644 index 0000000..678dbaa --- /dev/null +++ b/tests/stress-index-analytics.test.ts @@ -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 = { + // 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 }, + ]); + }); +}); diff --git a/tests/stress-index-sport.test.ts b/tests/stress-index-sport.test.ts new file mode 100644 index 0000000..c1a57b0 --- /dev/null +++ b/tests/stress-index-sport.test.ts @@ -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); + }); +});