Piattaforme riservate: ruolo dedicato e port della dashboard Stress Index

Il ruolo `campus` diventa `piattaforme` e non apre più solo la sezione
Biohacking: copre tutte le aree riservate, con migrazione guardata sugli
utenti esistenti. Nell'header la vecchia voce Biohacking lascia il posto a
un menu Piattaforme con i due link, visibile solo a chi ha il ruolo, e
/piattaforme diventa la pagina di atterraggio.

Stress Index è portata dentro il sito: le nove viste dell'area
professionisti diventano pagine Astro sotto /piattaforme/stress-index,
con lo stile del sito al posto del design system del prototipo. React
resta solo dove serve davvero — grafici recharts e scheda cliente — il
resto è Astro con un filo di JS. I dati sono ancora quelli dimostrativi,
raccolti in lib/stress-index/data.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:49:47 +02:00
parent e8e24e086b
commit c4eb598a3a
32 changed files with 2908 additions and 74 deletions
@@ -0,0 +1,121 @@
---
// 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.
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';
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' },
];
---
<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>
</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>
@@ -0,0 +1,43 @@
---
// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta
// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione.
import StressIndex from '../../../../layouts/StressIndex.astro';
export const prerender = false;
const PASSI = [
"L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.",
'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.',
"La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.",
'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.',
];
---
<StressIndex title="Team live" crumbs={['Sport', 'Team live']}>
<div class="si-head si-head--row">
<div>
<h1>Team live</h1>
<p>Nessun atleta in sessione</p>
</div>
<label class="si-field si-field--inline">
<span class="si-visually-hidden">Ordina per</span>
<select>
<option value="nome">Ordina per nome</option>
<option value="hr">Ordina per HR corrente</option>
<option value="carico">Ordina per carico sessione</option>
</select>
</label>
</div>
<div class="si-card si-live">
<p class="si-live__title">Nessun atleta in sessione</p>
<p class="si-muted si-small">Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.</p>
<div class="si-live__how">
<p class="si-card__title">Come funziona</p>
<ol class="si-steps">
{PASSI.map((p, i) => (
<li><span class="si-steps__num">{i + 1}</span><span>{p}</span></li>
))}
</ol>
</div>
</div>
</StressIndex>