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:
@@ -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<string, Measurement[]>;
|
||||
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<Dimensione>('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 (
|
||||
<div className="si-stack">
|
||||
<div className="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Analytics di studio</h1>
|
||||
<p>Confronta i tuoi clienti, individua gli andamenti, calibra il lavoro</p>
|
||||
</div>
|
||||
<div className="si-range">
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dal</span>
|
||||
<input type="date" value={periodo.from} max={periodo.to} onChange={(e) => setPeriodo((p) => ({ ...p, from: e.target.value }))} />
|
||||
</label>
|
||||
<span className="si-range__sep" aria-hidden="true">–</span>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Al</span>
|
||||
<input type="date" value={periodo.to} min={periodo.from} onChange={(e) => setPeriodo((p) => ({ ...p, to: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{indicatori.map((k) => (
|
||||
<div className="si-card" key={k.label}>
|
||||
<p className="si-kpi__label">{k.label}</p>
|
||||
<p className="si-kpi__value">{k.value}</p>
|
||||
<p className="si-muted si-small">{k.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Distribuzione score clienti</h2>
|
||||
<p className="si-muted si-small">Quante misurazioni cadono in ogni fascia di valore</p>
|
||||
</div>
|
||||
</div>
|
||||
<DataChart type="bar" data={fasce} xKey="range" series={METRICHE.map((m) => ({ ...m }))} height={300} />
|
||||
</section>
|
||||
|
||||
<div className="si-grid si-grid--halves">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Andamento migliore</h2>
|
||||
<p className="si-muted si-small">Recupero medio più alto nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{migliori.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--ok">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Clienti critici</h2>
|
||||
<p className="si-muted si-small">Recupero medio più basso nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{critici.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--bad">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Confronto segmenti</h2>
|
||||
<p className="si-muted si-small">Valori medi per {dataItaliana(periodo.from)} – {dataItaliana(periodo.to)}</p>
|
||||
</div>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dimensione di confronto</span>
|
||||
<select value={dimensione} onChange={(e) => setDimensione(e.target.value as Dimensione)}>
|
||||
{DIMENSIONI.map((d) => <option key={d.key} value={d.key}>{d.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<DataChart
|
||||
type="bar" data={segmenti} xKey="gruppo" height={300}
|
||||
series={[
|
||||
{ key: 'stress', label: 'Stress medio', color: '#b05a4e' },
|
||||
{ key: 'recovery', label: 'Recupero medio', color: '#6f8f6a' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="si-stack">
|
||||
<div className="si-head">
|
||||
<h1>Modulo Sport <span className="si-badge si-badge--pro">Pro</span></h1>
|
||||
<p>Sessioni di allenamento, zone DFA Alpha1, carico e recupero degli atleti</p>
|
||||
</div>
|
||||
|
||||
<div className="si-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} type="button" role="tab" className="si-tabs__btn" aria-selected={t.key === tab} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'dashboard' && (
|
||||
<div className="si-stack">
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{kpi.map((k) => (
|
||||
<div className="si-card" key={k.label}>
|
||||
<p className="si-kpi__label">{k.label}</p>
|
||||
<p className="si-kpi__value">{k.value}</p>
|
||||
{k.note && <p className="si-muted si-small">{k.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Sessioni per settimana</h2>
|
||||
<p className="si-muted si-small">Ultime 12 settimane</p>
|
||||
</div>
|
||||
</div>
|
||||
<DataChart type="bar" data={settimane} xKey="settimana" series={[{ key: 'sessioni', label: 'Sessioni', color: '#9c8b70' }]} />
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Ultime sessioni</h2></div>
|
||||
<div className="si-scroll-x">
|
||||
<table className="si-table">
|
||||
<thead>
|
||||
<tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.slice(0, 10).map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
||||
<td className="si-muted">{dataBreve(s.startedAt)}</td>
|
||||
<td>{s.sport ?? '—'}</td>
|
||||
<td className="si-table__num">{durata(s.durationSec)}</td>
|
||||
<td className="si-table__num">{s.trimp}</td>
|
||||
<td className="si-table__num">{s.hrAvg} bpm</td>
|
||||
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'sessioni' && (
|
||||
<div className="si-stack">
|
||||
<section className="si-card">
|
||||
<div className="si-filters">
|
||||
<label className="si-field">Atleta
|
||||
<select value={atleta} onChange={(e) => setAtleta(e.target.value)}>
|
||||
<option value="tutti">Tutti</option>
|
||||
{athletes.map((a) => <option key={a.clientId} value={a.clientId}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="si-field">Periodo
|
||||
<select value={periodo} onChange={(e) => setPeriodo(e.target.value)}>
|
||||
{PERIODI.map((p) => <option key={p.key} value={p.key}>{p.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="si-field">Sport
|
||||
<select value={sport} onChange={(e) => setSport(e.target.value)}>
|
||||
<option value="tutti">Tutti</option>
|
||||
{sports.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="si-muted si-small si-filters__count">
|
||||
{filtrate.length} {filtrate.length === 1 ? 'sessione' : 'sessioni'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-scroll-x">
|
||||
<table className="si-table">
|
||||
<thead>
|
||||
<tr><th>Data e ora</th><th>Atleta</th><th>Sport</th><th>Durata</th><th>HR medio</th><th>HR max</th><th>TRIMP</th><th>DFA A1</th><th>RPE</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtrate.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="si-muted">{dataOra(s.startedAt)}</td>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
||||
<td>{s.sport ?? '—'}</td>
|
||||
<td className="si-table__num">{durata(s.durationSec)}</td>
|
||||
<td className="si-table__num">{s.hrAvg}</td>
|
||||
<td className="si-table__num">{s.hrMax}</td>
|
||||
<td className="si-table__num">{s.trimp}</td>
|
||||
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
||||
<td className="si-table__num">{s.rpe ? `${s.rpe}/10` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtrate.length === 0 && <p className="si-muted si-small si-empty">Nessuna sessione con questi filtri.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'atleti' && (
|
||||
<div className="si-grid si-grid--halves">
|
||||
{atletiRiepilogo.map((a) => (
|
||||
<section className="si-card si-athlete" key={a.clientId}>
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-athlete__name">{a.name}</h2>
|
||||
<p className="si-muted si-small">{a.sport ?? 'Sport non specificato'}</p>
|
||||
</div>
|
||||
<span className={`si-badge si-badge--${a.trend === 'in calo' ? 'bad' : a.trend === 'in crescita' ? 'ok' : 'warn'}`}>
|
||||
{a.trend}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="si-athlete__stats">
|
||||
<div><p className="si-athlete__num">{a.sessioni30}</p><p className="si-muted si-small">Sessioni 30gg</p></div>
|
||||
<div><p className="si-athlete__num">{a.trimp7}</p><p className="si-muted si-small">TRIMP 7gg</p></div>
|
||||
<div><p className="si-athlete__num">{a.ultimoTrimp}</p><p className="si-muted si-small">Ultimo TRIMP</p></div>
|
||||
</div>
|
||||
|
||||
<a className="si-athlete__foot" href={`/piattaforme/stress-index/clienti/${a.clientId}`}>
|
||||
<span>{a.ultimaSessione ? `Ultima sessione ${dataBreve(a.ultimaSessione)}` : 'Nessuna sessione registrata'}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export default function DataChart({ type = 'line', data, xKey, series, height =
|
||||
{series.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />}
|
||||
{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.
|
||||
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
|
||||
) : (
|
||||
<Line
|
||||
|
||||
@@ -33,7 +33,6 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('global.nav.4.label', site.navigation[3].label, []),
|
||||
T('global.nav.5.label', site.navigation[4].label, []),
|
||||
T('global.nav.6.label', site.navigation[5].label, []),
|
||||
T('global.nav.7.label', site.navigation[6].label, []),
|
||||
|
||||
// --- Header ---
|
||||
T('header.logo.alt', 'InsanityLab', []),
|
||||
@@ -302,8 +301,6 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('notfound.cta', 'Torna alla home', []),
|
||||
|
||||
// --- Promo ed eventi (promo-eventi.astro) ---
|
||||
// Titolo del banner PageHero (usato dalla variante promo-eventi2)
|
||||
T('promo.pagehero.title', 'Promo ed eventi'),
|
||||
// Hero
|
||||
T('promo.hero.badge', 'insanity.lab · bologna savena'),
|
||||
T('promo.hero.title1', 'Allenati con metodo.'),
|
||||
|
||||
@@ -18,8 +18,6 @@ export const site = {
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
// Pagina di test: visibile solo agli admin loggati, nascosta al pubblico.
|
||||
{ label: "Promo 2 (test)", href: "/promo-eventi2", adminOnly: true },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
|
||||
@@ -17,8 +17,6 @@ interface Props {
|
||||
currentLesson?: string;
|
||||
}
|
||||
const { title, nav, currentArea, currentLesson } = Astro.props;
|
||||
// Solo l'admin gestisce gli utenti: dalla sezione ci arriva senza passare dal pannello.
|
||||
const isAdmin = Astro.locals.user?.role === 'admin';
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
@@ -38,7 +36,7 @@ const isAdmin = Astro.locals.user?.role === 'admin';
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Biohacking
|
||||
</a>
|
||||
<a class="cp-home" href="/">← Torna alla home</a>
|
||||
<a class="cp-home" href="/piattaforme">Piattaforme</a>
|
||||
|
||||
<nav class="cp-nav">
|
||||
{nav.areas.map((area) => (
|
||||
@@ -65,11 +63,6 @@ const isAdmin = Astro.locals.user?.role === 'admin';
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div class="cp-foot">
|
||||
{isAdmin && <a href="/admin/users">Gestione utenti</a>}
|
||||
<a href="/admin/logout">Esci</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="cp-main"><slot /></main>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 = [
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Stress Index
|
||||
</a>
|
||||
<a class="si-home" href="/piattaforme">← Piattaforme</a>
|
||||
<a class="si-home" href="/piattaforme">Piattaforme</a>
|
||||
|
||||
<nav class="si-nav">
|
||||
{NAV.map((item) => (
|
||||
@@ -61,12 +60,6 @@ const NAV = [
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div class="si-foot">
|
||||
<span class="si-foot__role">{professional.role}</span>
|
||||
{isAdmin && <a href="/admin/users">Gestione utenti</a>}
|
||||
<a href="/admin/logout">Esci</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="si-body">
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
---
|
||||
<StressIndex title="Analytics" crumbs={['Analytics']}>
|
||||
<div class="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Andamento dello studio nel periodo selezionato</p>
|
||||
</div>
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Intervallo</span>
|
||||
<select>
|
||||
<option value="7">Ultimi 7 giorni</option>
|
||||
<option value="30" selected>Ultimi 30 giorni</option>
|
||||
<option value="90">Ultimi 90 giorni</option>
|
||||
<option value="365">Ultimo anno</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Clienti attivi" value={studioKpis.totalClients} />
|
||||
<Kpi label="Misurazioni nel periodo" value={studioKpis.measurementsThisMonth} />
|
||||
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} />
|
||||
<Kpi label="Aderenza" value={`${studioKpis.retentionRate}%`} />
|
||||
</div>
|
||||
|
||||
<div class="si-stack si-mt">
|
||||
<Card title="Distribuzione dello score dei clienti">
|
||||
<DataChart client:visible type="bar" data={scoreDistribution} xKey="range" series={[{ key: 'count', label: 'Clienti' }]} />
|
||||
</Card>
|
||||
|
||||
<Card title="Confronto fra segmenti">
|
||||
<div class="si-grid si-grid--halves">
|
||||
{segments.map((s) => (
|
||||
<div class="si-segment">
|
||||
<p class="si-segment__label">{s.label}</p>
|
||||
<p class="si-kpi__value">{s.avgStress}</p>
|
||||
<p class="si-muted si-small">stress medio · {s.count} {s.count === 1 ? 'cliente' : 'clienti'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="si-grid si-grid--halves">
|
||||
<Card title="Andamento migliore">
|
||||
<StatList items={topPerformers.map((c) => ({ label: c.name, value: c.stress }))} />
|
||||
</Card>
|
||||
<Card title="Clienti critici">
|
||||
<StatList items={criticalClients.map((c) => ({ label: c.name, value: c.stress }))} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<AnalyticsView
|
||||
client:load
|
||||
clients={clients}
|
||||
measurementsByClient={measurementsByClient}
|
||||
alerts={alerts}
|
||||
from={DA}
|
||||
to={A}
|
||||
/>
|
||||
</StressIndex>
|
||||
|
||||
@@ -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';
|
||||
---
|
||||
<StressIndex title="Sport" crumbs={['Sport']}>
|
||||
<div class="si-head">
|
||||
<h1>Sport</h1>
|
||||
<p>Sessioni, carico e monitoraggio degli atleti</p>
|
||||
</div>
|
||||
|
||||
<div class="si-tabs" role="tablist" id="si-sport-tabs">
|
||||
{TABS.map((t, i) => (
|
||||
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section data-panel="dashboard" class="si-stack">
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Sessioni totali" value={sessions.length} />
|
||||
<Kpi label="Sessioni questa settimana" value={sessioniSettimana} />
|
||||
<Kpi label="TRIMP medio" value={trimpMedio} />
|
||||
<Kpi label="Atleti attivi" value={athletes.length} />
|
||||
</div>
|
||||
|
||||
<Card title="Sessioni per settimana">
|
||||
<DataChart client:visible type="bar" data={sessionsPerWeek} xKey="week" series={[{ key: 'count', label: 'Sessioni' }]} />
|
||||
</Card>
|
||||
|
||||
<Card title="Ultime sessioni">
|
||||
<div class="si-scroll-x">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr>
|
||||
<td><strong>{nomeAtleta(s.athleteId)}</strong></td>
|
||||
<td class="si-table__num">{s.date}</td>
|
||||
<td>{s.type}</td>
|
||||
<td class="si-table__num">{s.durationMin} min</td>
|
||||
<td class="si-table__num">{s.trimp}</td>
|
||||
<td class="si-table__num">{s.hrAvg}</td>
|
||||
<td class="si-table__num">{s.dfaA1}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="sessioni" hidden>
|
||||
<Card title="Tutte le sessioni">
|
||||
<div class="si-scroll-x">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>Carico</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr>
|
||||
<td><strong>{nomeAtleta(s.athleteId)}</strong></td>
|
||||
<td class="si-table__num">{s.date}</td>
|
||||
<td>{s.type}</td>
|
||||
<td class="si-table__num">{s.durationMin} min</td>
|
||||
<td class="si-table__num">{s.load}</td>
|
||||
<td class="si-table__num">{s.trimp}</td>
|
||||
<td class="si-table__num">{s.hrAvg}</td>
|
||||
<td class="si-table__num">{s.dfaA1}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="atleti" hidden>
|
||||
<Card title="Atleti monitorati">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Sport</th><th>Carico settimanale</th></tr></thead>
|
||||
<tbody>
|
||||
{athletes.map((a) => (
|
||||
<tr>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${a.clientId}`}>{a.name}</a></td>
|
||||
<td>{a.sport}</td>
|
||||
<td class="si-table__num">{a.weeklyLoad}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</section>
|
||||
<SportView client:load sessions={sessions} athletes={athletes} riferimento={RIFERIMENTO} />
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Schede: un solo pannello visibile alla volta, selezione riflessa su aria-selected.
|
||||
const tabs = document.getElementById('si-sport-tabs');
|
||||
if (tabs) {
|
||||
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
|
||||
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
|
||||
for (const btn of buttons) {
|
||||
btn.addEventListener('click', () => {
|
||||
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
|
||||
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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');
|
||||
---
|
||||
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
|
||||
<PageHero title={t('promo.pagehero.title')} eyebrow={t('promo.hero.badge')} />
|
||||
|
||||
<div class="promo2">
|
||||
|
||||
<!-- Claim principale -->
|
||||
<section class="section claim-section">
|
||||
<div class="claim-wrap">
|
||||
<h2 class="claim">
|
||||
<span class="l1"><T tag="promo.hero.title1" as="span" /></span>
|
||||
<span class="l2"><em><T tag="promo.hero.accent" as="span" /></em> <T tag="promo.hero.title2" as="span" /></span>
|
||||
</h2>
|
||||
<T tag="promo.hero.desc" as="p" class="claim-desc" />
|
||||
<a class="btn" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lancio + perché 5 -->
|
||||
<section class="section section--alt">
|
||||
<div class="container launch">
|
||||
<T tag="promo.launch.badge" as="p" class="eyebrow" />
|
||||
<h2><T tag="promo.launch.title" as="span" /></h2>
|
||||
<p class="launch-accent"><T tag="promo.launch.accent" as="span" /></p>
|
||||
<T tag="promo.launch.desc" as="p" />
|
||||
<T tag="promo.why5.text" as="p" class="why5" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pacchetti -->
|
||||
<section class="section" id="proposte">
|
||||
<div class="container">
|
||||
<div class="cards">
|
||||
{cards.map((c) => (
|
||||
<div class:list={['card', c.featured && 'featured']}>
|
||||
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
|
||||
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
|
||||
<T tag={`promo.card.${c.id}.title`} as="div" class="ptitle" />
|
||||
<div class="price-row">
|
||||
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
|
||||
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
|
||||
</div>
|
||||
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
|
||||
<ul>
|
||||
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
||||
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
|
||||
))}
|
||||
</ul>
|
||||
<a class:list={['btn', c.featured ? 'btn--dark' : 'btn--light', 'buy']} href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<T tag="promo.validity-note" as="p" class="validity-note" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section class="section section--alt">
|
||||
<div class="container faq-wrap">
|
||||
<T tag="promo.faq.title" as="h2" class="faq-title" />
|
||||
{faqs.map((n) => {
|
||||
const a = resolveContent(`promo.faq.${n}.a`);
|
||||
return (
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
|
||||
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
|
||||
document.querySelectorAll('.promo2 .faq-block .faq-q').forEach((q) => {
|
||||
const block = q.closest('.faq-block');
|
||||
const toggle = q.querySelector('.faq-toggle');
|
||||
q.setAttribute('role', 'button');
|
||||
q.setAttribute('tabindex', '0');
|
||||
q.setAttribute('aria-expanded', 'false');
|
||||
const set = (open: boolean) => {
|
||||
block?.classList.toggle('is-open', open);
|
||||
q.setAttribute('aria-expanded', String(open));
|
||||
if (toggle) toggle.textContent = open ? '−' : '+';
|
||||
};
|
||||
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
|
||||
q.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
|
||||
scope della pagina). Tutto è prefissato `.promo2`; la struttura usa le classi globali
|
||||
del sito (.section, .container, .btn, .eyebrow, .section-heading). -->
|
||||
<style is:global>
|
||||
/* Claim principale */
|
||||
.promo2 .claim-section { padding-block: 72px; text-align: center; }
|
||||
.promo2 .claim-wrap { max-width: 820px; margin: 0 auto; padding-inline: 20px; }
|
||||
.promo2 .claim { font-family: var(--font-heading); font-weight: 500; letter-spacing: .02em;
|
||||
color: var(--c-heading); line-height: 1.18; font-size: clamp(2.1rem, 4.5vw, 3.1rem); margin: 0 0 22px; }
|
||||
.promo2 .claim .l1, .promo2 .claim .l2 { display: block; }
|
||||
.promo2 .claim em { font-style: italic; color: var(--c-accent-dark); }
|
||||
.promo2 .claim-desc { max-width: 500px; margin: 0 auto 30px; font-size: 1.05rem; color: var(--c-text); }
|
||||
|
||||
/* Lancio — banda evidenziata */
|
||||
.promo2 .launch { max-width: 760px; text-align: center; }
|
||||
.promo2 .launch .eyebrow { display: block; margin: 0 0 14px; }
|
||||
.promo2 .launch h2 { margin: 0 0 6px; }
|
||||
.promo2 .launch-accent { font-family: var(--font-heading); font-style: italic; font-size: 1.2rem; color: var(--c-accent-dark); margin: 0 0 22px; }
|
||||
.promo2 .launch > p { color: var(--c-text); margin-inline: auto; max-width: 620px; }
|
||||
.promo2 .why5 { font-family: var(--font-heading); font-style: italic; font-size: 1.25rem; line-height: 1.65;
|
||||
color: var(--c-heading); margin: 32px auto 0; max-width: 600px; padding-top: 28px; border-top: 2px solid var(--c-accent); }
|
||||
|
||||
/* Pacchetti */
|
||||
.promo2 .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; align-items: stretch; }
|
||||
.promo2 .card { background: var(--c-bg); border: 1px solid #e7e0d6; border-radius: 6px; padding: 34px 30px; position: relative; display: flex; flex-direction: column; transition: box-shadow .25s, transform .25s; }
|
||||
.promo2 .card:hover { box-shadow: 0 14px 40px rgba(58,41,41,.10); transform: translateY(-3px); }
|
||||
.promo2 .card.featured { border: 1px solid var(--c-accent); box-shadow: 0 10px 34px rgba(58,41,41,.08); }
|
||||
.promo2 .featured-badge {
|
||||
position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--c-accent);
|
||||
color: #fff; font-family: var(--font-heading); font-size: 10.5px; font-weight: 600; letter-spacing: .12em;
|
||||
text-transform: uppercase; padding: 6px 16px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.promo2 .card .eyebrow { margin: 0 0 8px; font-size: .68rem; }
|
||||
.promo2 .card .ptitle { font-family: var(--font-heading); font-size: 1.4rem; font-weight: 500; color: var(--c-heading); letter-spacing: .03em; margin: 0 0 18px; }
|
||||
.promo2 .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 3px; }
|
||||
.promo2 .card .price { font-family: var(--font-heading); font-size: 2.4rem; font-weight: 600; color: var(--c-heading); line-height: 1; }
|
||||
.promo2 .card .price-old { font-size: 1.05rem; color: var(--c-text-light); text-decoration: line-through; }
|
||||
.promo2 .card .unit { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--c-text-light); margin: 0 0 22px; }
|
||||
.promo2 .card ul { list-style: none; padding: 22px 0 0; margin: 0 0 26px; border-top: 1px solid #f0eae1; display: flex; flex-direction: column; gap: 11px; }
|
||||
.promo2 .card li { font-size: .9rem; line-height: 1.5; color: var(--c-text); display: flex; gap: 10px; }
|
||||
.promo2 .card li::before { content: "—"; color: var(--c-accent); font-weight: 700; flex-shrink: 0; }
|
||||
.promo2 .card .buy { margin-top: auto; justify-content: center; }
|
||||
.promo2 .validity-note { text-align: center; font-size: .82rem; color: var(--c-text-light); margin: 34px auto 0; max-width: 560px; }
|
||||
|
||||
/* FAQ */
|
||||
.promo2 .faq-wrap { max-width: 760px; }
|
||||
.promo2 .faq-title { text-align: center; text-transform: uppercase; letter-spacing: .1em; font-size: clamp(1.5rem, 3vw, 2rem); margin: 0 0 44px; }
|
||||
.promo2 .faq-block { border-top: 1px solid #e4ded6; padding: 20px 4px; transition: padding .2s; }
|
||||
.promo2 .faq-block:last-child { border-bottom: 1px solid #e4ded6; }
|
||||
.promo2 .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
|
||||
.promo2 .faq-q p { margin: 0; font-family: var(--font-heading); font-size: 1rem; font-weight: 500; color: var(--c-heading); letter-spacing: .02em; }
|
||||
.promo2 .faq-q:focus-visible { outline: 2px solid var(--c-accent-dark); outline-offset: 4px; }
|
||||
.promo2 .faq-toggle {
|
||||
width: 32px; height: 32px; border-radius: 50%; border: 1px solid #d8cfc2; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0; font-size: 17px; color: var(--c-heading);
|
||||
transition: background .2s ease, color .2s ease, border-color .2s ease;
|
||||
}
|
||||
.promo2 .faq-block.is-open .faq-toggle { background: var(--c-accent); color: #fff; border-color: var(--c-accent); }
|
||||
.promo2 .faq-a { display: none; margin: 14px 0 0; font-size: .94rem; line-height: 1.75; color: var(--c-text); max-width: 640px; }
|
||||
.promo2 .faq-block.is-open .faq-a { display: block; }
|
||||
.promo2 .faq-a .hl { color: var(--c-accent-dark); font-weight: 600; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.promo2 .cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user