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:
2026-07-31 16:20:40 +02:00
parent c4eb598a3a
commit 3cda76b4c4
17 changed files with 907 additions and 409 deletions
@@ -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>
);
}
+204
View File
@@ -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
-3
View File
@@ -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.'),
-2
View File
@@ -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" },
+1 -14
View File
@@ -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; }
+1 -8
View File
@@ -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">
+4
View File
@@ -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 }[];
+141
View File
@@ -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),
}));
}
+64 -23
View File
@@ -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.
+105
View File
@@ -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>
-181
View File
@@ -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>
+31 -8
View File
@@ -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); }