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
+7
View File
@@ -1,9 +1,12 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({
site: 'https://insanitylab.it',
output: 'server',
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
@@ -15,7 +18,9 @@ export default defineConfig({
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
],
},
adapter: node({ mode: 'standalone' }),
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
redirects: {
'/programs': { status: 301, destination: '/services' },
@@ -26,4 +31,6 @@ export default defineConfig({
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
},
integrations: [react()],
});
+985 -3
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -14,16 +14,22 @@
},
"dependencies": {
"@astrojs/node": "^11.0.1",
"@astrojs/react": "^6.0.2",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.0.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
+35 -4
View File
@@ -1,5 +1,6 @@
---
import { site } from '../data/site';
import { piattaforme } from '../data/piattaforme';
import { t, contentImageUrl } from '../lib/content';
import { landingFor } from '../lib/auth';
interface Props {
@@ -11,8 +12,9 @@ const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname;
const loginHref = `/login?next=${encodeURIComponent(path)}`;
const areaHref = user ? landingFor(user.role) : '/admin';
// La sezione Biohacking compare nel menu solo a chi può davvero aprirla.
const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
// Le piattaforme riservate compaiono nel menu solo a chi può davvero aprirle.
const canPiattaforme = user?.role === 'piattaforme' || user?.role === 'admin';
const inPiattaforme = piattaforme.some((p) => path === p.href || path.startsWith(`${p.href}/`));
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
@@ -23,8 +25,15 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
)
))}
{canBiohacking && (
<a href="/campus" class:list={['hdr__link', 'hdr__link--bio', { 'is-active': path.startsWith('/campus') }]}>Biohacking</a>
{canPiattaforme && (
<details class="plat">
<summary class:list={['hdr__link', 'hdr__link--bio', 'plat__btn', { 'is-active': inPiattaforme }]}>Piattaforme ▾</summary>
<div class="plat__panel">
{piattaforme.map((p) => (
<a href={p.href} class:list={['plat__item', { 'is-active': path === p.href || path.startsWith(`${p.href}/`) }]}>{p.label}</a>
))}
</div>
</details>
)}
</nav>
<div class="hdr__user">
@@ -57,6 +66,17 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__link--bio { color: var(--c-accent-dark); }
/* Menu Piattaforme: su desktop è un pannello che scende dalla voce, in mobile —
dove la nav è già una colonna — le voci si aprono in linea sotto il titolo. */
.plat { position: relative; }
.plat__btn { cursor: pointer; list-style: none; }
.plat__btn::-webkit-details-marker { display: none; }
.plat__panel {
position: absolute; left: 0; top: calc(100% + 14px); background: #fff; min-width: 210px;
box-shadow: 0 8px 22px rgba(0,0,0,.12); display: flex; flex-direction: column; padding: 8px 0; z-index: 200;
}
.plat__item { padding: 10px 16px; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.plat__item:hover, .plat__item.is-active { background: #f4f2ef; color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
@@ -64,6 +84,8 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
.plat__panel { position: static; box-shadow: none; padding: 0 0 8px; min-width: 0; }
.plat__item { padding: 10px 0 10px 14px; }
}
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
@@ -86,6 +108,15 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
toggle.setAttribute('aria-expanded', String(open));
});
// Il pannello Piattaforme è un <details>: da solo resterebbe aperto anche cliccando altrove.
const plat = document.querySelector<HTMLDetailsElement>('.plat');
if (plat) {
document.addEventListener('click', (e) => {
if (plat.open && !plat.contains(e.target as Node)) plat.open = false;
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') plat.open = false; });
}
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
@@ -0,0 +1,12 @@
---
import type { Alert } from '../../lib/stress-index/data';
interface Props { alert: Alert }
const { alert } = Astro.props;
---
<div class:list={['si-alert', alert.severity === 'error' && 'si-alert--error']}>
<span class="si-alert__mark" aria-hidden="true"></span>
<div>
<p class="si-alert__text">{alert.text}</p>
<p class="si-alert__meta">{alert.clientName} · {alert.when}</p>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
---
// Riquadro base della dashboard: titolo opzionale e, a destra, un'azione (di solito un link).
interface Props { title?: string; actionLabel?: string; actionHref?: string; class?: string }
const { title, actionLabel, actionHref, class: cls } = Astro.props;
---
<section class:list={['si-card', cls]}>
{(title || actionLabel) && (
<div class="si-card__head">
{title && <h2 class="si-card__title">{title}</h2>}
{actionLabel && actionHref && <a class="si-card__action" href={actionHref}>{actionLabel}</a>}
</div>
)}
<slot />
</section>
@@ -0,0 +1,387 @@
// Scheda cliente: è l'unica vista davvero interattiva della piattaforma (tab, filtri sul
// grafico, note, messaggi, tag), quindi vive come singola isola React. Lo stile arriva dalle
// classi `.si-*` del foglio globale: qui non ci sono classi Tailwind del prototipo originale.
import { useMemo, useState } from 'react';
import DataChart from './charts/DataChart';
import type { Alert, Client, Measurement, Note } from '../../lib/stress-index/data';
interface Props {
client: Client;
measurements: Measurement[];
alerts: Alert[];
notes: Note[];
}
const TABS = [
{ key: 'panoramica', label: 'Panoramica' },
{ key: 'misurazioni', label: 'Misurazioni' },
{ key: 'analytics', label: 'Analytics' },
{ key: 'report', label: 'Report' },
{ key: 'note', label: 'Note' },
{ key: 'messaggi', label: 'Messaggi' },
{ key: 'impostazioni', label: 'Impostazioni' },
];
const METRICHE = [
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
{ key: 'balance', label: 'Equilibrio', color: '#c08a3e' },
{ key: 'energy', label: 'Energia', color: '#9c8b70' },
];
const PERIODI = [
{ key: '4', label: 'Ultime 4' },
{ key: '8', label: 'Ultime 8' },
{ key: 'all', label: 'Tutte' },
];
const REPORT = [
{ date: '2026-07-01', label: 'Report mensile — Giugno 2026' },
{ date: '2026-06-01', label: 'Report mensile — Maggio 2026' },
{ date: '2026-05-01', label: 'Report mensile — Aprile 2026' },
];
const MESSAGGI_INIZIALI = [
{ from: 'cliente', text: "Buongiorno dottore, le invio l'esito della misurazione di stamattina.", when: '09:12' },
{ from: 'professionista', text: 'Grazie, la vedo. I valori di recupero sono in miglioramento rispetto alla scorsa settimana.', when: '09:20' },
{ from: 'cliente', text: 'Sì, mi sento meglio, dormo di più.', when: '09:22' },
{ from: 'professionista', text: 'Continui così, ci sentiamo al prossimo controllo.', when: '09:25' },
];
const qualitativa = (v: number) => (v >= 70 ? 'Alto' : v >= 40 ? 'Moderato' : 'Basso');
const livello = (v: number) => (v >= 70 ? 'bad' : v >= 45 ? 'warn' : 'ok');
/** Indicatore ad arco: il valore riempie un semicerchio da sinistra a destra. */
function Gauge({ value, label }: { value: number; label: string }) {
const ratio = Math.max(0, Math.min(1, value / 100));
const R = 56;
const len = Math.PI * R;
return (
<div className="si-gauge">
<svg viewBox="0 0 140 78" className="si-gauge__svg" role="img" aria-label={`${label}: ${value} su 100`}>
<path d="M 14 70 A 56 56 0 0 1 126 70" fill="none" stroke="#e4dfd7" strokeWidth="10" strokeLinecap="round" />
<path
d="M 14 70 A 56 56 0 0 1 126 70" fill="none" strokeWidth="10" strokeLinecap="round"
stroke={value >= 70 ? '#b05a4e' : value >= 45 ? '#c08a3e' : '#6f8f6a'}
strokeDasharray={`${len * ratio} ${len}`}
/>
</svg>
<p className="si-gauge__value">{value}<span className="si-gauge__max">/100</span></p>
<p className="si-gauge__label">{label} · {qualitativa(value)}</p>
</div>
);
}
export default function ClientDetail({ client, measurements, alerts, notes }: Props) {
const [tab, setTab] = useState('panoramica');
const [periodo, setPeriodo] = useState('8');
const [metriche, setMetriche] = useState<string[]>(METRICHE.map((m) => m.key));
const [noteCliente, setNoteCliente] = useState(notes);
const [nuovaNota, setNuovaNota] = useState('');
const [messaggi, setMessaggi] = useState(MESSAGGI_INIZIALI);
const [bozza, setBozza] = useState('');
const [tag, setTag] = useState<string[]>(client.tags);
const [nuovoTag, setNuovoTag] = useState('');
const [promemoria, setPromemoria] = useState(true);
const [reportSemestrale, setReportSemestrale] = useState(false);
const ultima = measurements[measurements.length - 1];
const stress = ultima?.stress ?? client.stress;
const recovery = ultima?.recovery ?? 0;
const balance = ultima?.balance ?? 0;
const energy = ultima?.energy ?? 0;
const datiGrafico = useMemo(
() => (periodo === 'all' ? measurements : measurements.slice(-Number(periodo))),
[measurements, periodo]
);
const serieAttive = METRICHE.filter((m) => metriche.includes(m.key));
const ultimeTre = [...measurements].slice(-3).reverse();
const storico = [...measurements].reverse();
const toggleMetrica = (key: string) =>
setMetriche((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const aggiungiNota = () => {
const testo = nuovaNota.trim();
if (!testo) return;
setNoteCliente((prev) => [{ clientId: client.id, text: testo, when: client.lastMeasurement }, ...prev]);
setNuovaNota('');
};
const invia = () => {
const testo = bozza.trim();
if (!testo) return;
setMessaggi((prev) => [...prev, { from: 'professionista', text: testo, when: 'ora' }]);
setBozza('');
};
const aggiungiTag = () => {
const t = nuovoTag.trim();
if (!t || tag.includes(t)) return;
setTag((prev) => [...prev, t]);
setNuovoTag('');
};
return (
<div className="si-stack">
<div className="si-client-head">
<span className="si-avatar si-avatar--lg">{client.initials}</span>
<div>
<h1>{client.name}</h1>
<p className="si-muted si-small">
{client.age} anni · {client.sex} · in carico dal {client.since}
</p>
</div>
</div>
<div className="si-grid si-grid--kpi">
{[
{ label: 'Stress', value: stress, note: qualitativa(stress) },
{ label: 'Recupero', value: recovery, note: qualitativa(recovery) },
{ label: 'Misurazioni', value: measurements.length, note: 'totali' },
{ label: 'In carico da', value: client.since, note: 'data iscrizione' },
].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>
<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 === 'panoramica' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Stato attuale</h2></div>
<div className="si-gauges">
<Gauge value={stress} label="Stress" />
<Gauge value={recovery} label="Recupero" />
<Gauge value={balance} label="Equilibrio" />
<Gauge value={energy} label="Energia" />
</div>
</section>
<section className="si-card">
<div className="si-card__head">
<h2 className="si-card__title">Andamento clinico</h2>
</div>
<div className="si-chips">
{PERIODI.map((p) => (
<button key={p.key} type="button" className="si-chip" aria-pressed={periodo === p.key} onClick={() => setPeriodo(p.key)}>
{p.label}
</button>
))}
</div>
<div className="si-chips">
{METRICHE.map((m) => (
<button key={m.key} type="button" className="si-chip" aria-pressed={metriche.includes(m.key)} onClick={() => toggleMetrica(m.key)}>
{m.label}
</button>
))}
</div>
<DataChart data={datiGrafico} xKey="date" series={serieAttive} />
</section>
<div className="si-grid si-grid--halves">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Alert attivi</h2></div>
{alerts.length === 0 ? (
<p className="si-muted si-small">Nessun alert attivo per questo cliente.</p>
) : (
alerts.map((a, i) => (
<div className={`si-alert${a.severity === 'error' ? ' si-alert--error' : ''}`} key={i}>
<span className="si-alert__mark" aria-hidden="true" />
<div>
<p className="si-alert__text">{a.text}</p>
<p className="si-alert__meta">{a.when}</p>
</div>
</div>
))
)}
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Ultime 3 misurazioni</h2></div>
<ul className="si-list si-list--rows">
{ultimeTre.map((m, i) => (
<li key={i}>
<span>
<strong>{m.date}</strong>
<span className="si-muted"> · {m.type} · stress {m.stress}</span>
</span>
<span className={`si-badge si-badge--${livello(m.stress)}`}>{qualitativa(m.stress)}</span>
</li>
))}
</ul>
</section>
</div>
<section className="si-insight">
<p className="si-insight__title">Sintesi</p>
<p>
{stress >= 70
? `${client.name} mostra un livello di stress elevato nelle ultime misurazioni, con recupero notturno ridotto. Consigliata una revisione del carico settimanale e un lavoro sulla respirazione.`
: `${client.name} mantiene un equilibrio complessivamente stabile. Continuare il monitoraggio periodico e mantenere le abitudini di recupero attuali.`}
</p>
</section>
</div>
)}
{tab === 'misurazioni' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Storico misurazioni</h2></div>
<div className="si-scroll-x">
<table className="si-table">
<thead>
<tr><th>Data</th><th>Tipo</th><th>Stress</th><th>Recupero</th><th>Equilibrio</th><th>Energia</th><th>Durata</th></tr>
</thead>
<tbody>
{storico.map((m, i) => (
<tr key={i}>
<td className="si-table__num">{m.date}</td>
<td>{m.type}</td>
<td className="si-table__num">{m.stress}</td>
<td className="si-table__num">{m.recovery}</td>
<td className="si-table__num">{m.balance}</td>
<td className="si-table__num">{m.energy}</td>
<td className="si-table__num">{m.durationMin} min</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{tab === 'analytics' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Trend dei quattro parametri</h2></div>
<DataChart data={measurements} xKey="date" series={METRICHE} />
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Stress e recupero, ultime misurazioni</h2></div>
<DataChart type="bar" data={ultimeTre} xKey="date" series={[METRICHE[0], METRICHE[1]]} />
</section>
</div>
)}
{tab === 'report' && (
<section className="si-card">
<div className="si-card__head">
<h2 className="si-card__title">Report generati</h2>
<button type="button" className="si-btn">Genera report</button>
</div>
<ul className="si-list si-list--rows">
{REPORT.map((r) => (
<li key={r.date}>
<span><span className="si-muted si-table__num">{r.date}</span> · {r.label}</span>
<button type="button" className="si-btn">Scarica</button>
</li>
))}
</ul>
</section>
)}
{tab === 'note' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Note cliente</h2></div>
<div className="si-stack">
<label className="si-field">
Aggiungi nota
<textarea
rows={3} value={nuovaNota} onChange={(e) => setNuovaNota(e.target.value)}
placeholder="Scrivi una nota clinica su questo cliente…"
/>
</label>
<div><button type="button" className="si-btn si-btn--solid" onClick={aggiungiNota}>Aggiungi nota</button></div>
{noteCliente.length === 0 ? (
<p className="si-muted si-small">Nessuna nota registrata per questo cliente.</p>
) : (
<ul className="si-list">
{noteCliente.map((n, i) => (
<li className="si-note-card" key={i}>
<p className="si-note__meta">{n.when}</p>
<p>{n.text}</p>
</li>
))}
</ul>
)}
</div>
</section>
)}
{tab === 'messaggi' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Messaggi</h2></div>
<ul className="si-chat">
{messaggi.map((m, i) => (
<li key={i} className={`si-chat__row si-chat__row--${m.from}`}>
<div className="si-chat__bubble">
<p>{m.text}</p>
<p className="si-chat__when">{m.when}</p>
</div>
</li>
))}
</ul>
<div className="si-chat__compose">
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Messaggio</span>
<input type="text" value={bozza} onChange={(e) => setBozza(e.target.value)} placeholder="Scrivi un messaggio…" />
</label>
<button type="button" className="si-btn si-btn--solid" onClick={invia}>Invia</button>
</div>
</section>
)}
{tab === 'impostazioni' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Tag cliente</h2></div>
<div className="si-tag-list">
{tag.map((t) => (
<span className="si-tag si-tag--removable" key={t}>
{t}
<button type="button" aria-label={`Rimuovi tag ${t}`} onClick={() => setTag((prev) => prev.filter((x) => x !== t))}></button>
</span>
))}
</div>
<div className="si-chat__compose">
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Nuovo tag</span>
<input type="text" value={nuovoTag} onChange={(e) => setNuovoTag(e.target.value)} placeholder="Nuovo tag…" />
</label>
<button type="button" className="si-btn" onClick={aggiungiTag}>Aggiungi</button>
</div>
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Preferenze</h2></div>
<ul className="si-list si-list--rows">
<li>
<span>Promemoria automatici di misurazione</span>
<input type="checkbox" checked={promemoria} onChange={(e) => setPromemoria(e.target.checked)} />
</li>
<li>
<span>Invio automatico del report semestrale</span>
<input type="checkbox" checked={reportSemestrale} onChange={(e) => setReportSemestrale(e.target.checked)} />
</li>
</ul>
</section>
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
---
// Indicatore sintetico: valore grande, etichetta e variazione con verso.
interface Props { label: string; value: string | number; delta?: string; trend?: 'up' | 'down' }
const { label, value, delta, trend = 'up' } = Astro.props;
---
<div class="si-card">
<p class="si-kpi__label">{label}</p>
<p class="si-kpi__value">{value}</p>
{delta && (
<p class:list={['si-kpi__delta', trend === 'down' ? 'si-kpi__delta--down' : 'si-kpi__delta--up']}>
{trend === 'down' ? '▼' : '▲'} {delta}
</p>
)}
</div>
@@ -0,0 +1,13 @@
---
// Coppie etichetta/valore incolonnate, usate nei riquadri di riepilogo.
interface Props { items: { label: string; value: string | number }[] }
const { items } = Astro.props;
---
<dl class="si-list si-list--defs">
{items.map((i) => (
<div class="si-list__row">
<dt class="si-muted">{i.label}</dt>
<dd class="si-table__num">{i.value}</dd>
</div>
))}
</dl>
@@ -0,0 +1,56 @@
// Andamento medio dello studio, con selettore di periodo. L'aggregazione media stress e
// recupero di tutti i clienti sugli ultimi N rilievi settimanali.
import { useState } from 'react';
import DataChart from './DataChart';
import type { Measurement } from '../../../lib/stress-index/data';
const PERIODS = [
{ key: '30g', label: '30 giorni', points: 5 },
{ key: '90g', label: '90 giorni', points: 13 },
{ key: '6m', label: '6 mesi', points: 20 },
{ key: 'tutto', label: 'Tutto', points: 20 },
] as const;
interface Props { measurementsByClient: Record<string, Measurement[]> }
function aggregate(byClient: Record<string, Measurement[]>, points: number) {
const out: { date: string; stress: number; recovery: number }[] = [];
for (let fromEnd = points; fromEnd >= 1; fromEnd--) {
let stress = 0, recovery = 0, count = 0, date = '';
for (const series of Object.values(byClient)) {
const point = series[series.length - fromEnd];
if (!point) continue;
stress += point.stress;
recovery += point.recovery;
count += 1;
date = point.date;
}
if (count) out.push({ date, stress: Math.round(stress / count), recovery: Math.round(recovery / count) });
}
return out;
}
export default function AndamentoChart({ measurementsByClient }: Props) {
const [period, setPeriod] = useState<string>('90g');
const active = PERIODS.find((p) => p.key === period) ?? PERIODS[1];
return (
<div>
<div className="si-chips">
{PERIODS.map((p) => (
<button key={p.key} type="button" className="si-chip" aria-pressed={p.key === period} onClick={() => setPeriod(p.key)}>
{p.label}
</button>
))}
</div>
<DataChart
data={aggregate(measurementsByClient, active.points)}
xKey="date"
series={[
{ key: 'stress', label: 'Stress medio' },
{ key: 'recovery', label: 'Recupero medio' },
]}
/>
</div>
);
}
@@ -0,0 +1,50 @@
// Grafico generico della piattaforma (isola React: recharts ha bisogno del DOM).
// I colori sono quelli del sito, non quelli del prototipo originale.
import {
ResponsiveContainer, LineChart, BarChart, Line, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts';
export interface ChartSeries { key: string; label: string; color?: string }
interface Props {
type?: 'line' | 'bar';
data: Record<string, string | number>[];
xKey: string;
series: ChartSeries[];
height?: number;
}
const COLORS = ['#9c8b70', '#3a2929', '#6f8f6a', '#c08a3e', '#b05a4e'];
const AXIS = '#8a8a8a';
export default function DataChart({ type = 'line', data, xKey, series, height = 260 }: Props) {
if (!data.length) return <p className="si-chart__empty">Nessun dato nel periodo selezionato.</p>;
const Chart = type === 'bar' ? BarChart : LineChart;
return (
<div className="si-chart" style={{ height }}>
<ResponsiveContainer width="100%" height={height}>
<Chart data={data} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="#e4dfd7" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xKey} stroke={AXIS} fontSize={11} tickLine={false} />
<YAxis stroke={AXIS} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ border: '1px solid #e4dfd7', borderRadius: 0, fontSize: 12 }}
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
/>
{series.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />}
{series.map((s, i) =>
type === 'bar' ? (
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
) : (
<Line
key={s.key} type="monotone" dataKey={s.key} name={s.label} dot={false} strokeWidth={2.2}
stroke={s.color ?? COLORS[i % COLORS.length]}
/>
)
)}
</Chart>
</ResponsiveContainer>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Piattaforme riservate: sezioni applicative accessibili solo ai ruoli `piattaforme` e `admin`.
// Elenco unico usato dal menu dell'header e dalla pagina indice /piattaforme, così aggiungerne
// una in futuro è una riga sola.
export interface Piattaforma {
slug: string;
label: string;
href: string;
description: string;
}
export const piattaforme: Piattaforma[] = [
{
slug: 'campus',
label: 'Biohacking Campus',
href: '/campus',
description: 'Le guide di studio del corso: aree, capitoli e lezioni con le slide.',
},
{
slug: 'stress-index',
label: 'Stress Index',
href: '/piattaforme/stress-index',
description: 'Monitoraggio HRV di stress e recupero: clienti, andamenti e alert.',
},
];
+1 -1
View File
@@ -37,7 +37,7 @@ const user = Astro.locals.user;
<a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
{user.role === 'admin' && <a href="/campus">Biohacking</a>}
{user.role === 'admin' && <a href="/piattaforme">Piattaforme</a>}
<a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a>
+83
View File
@@ -0,0 +1,83 @@
---
// Layout della piattaforma Stress Index. Come il Campus: token, font e palette del sito,
// senza Header e Footer pubblici — la navigazione è tutta interna alla piattaforma.
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import '../styles/stress-index.css';
import { professional } from '../lib/stress-index/data';
interface Props {
title: string;
/** Voci di percorso mostrate nella topbar, dopo "Stress Index". */
crumbs?: string[];
}
const { title, crumbs = [] } = Astro.props;
const path = Astro.url.pathname;
const isAdmin = Astro.locals.user?.role === 'admin';
const BASE = '/piattaforme/stress-index';
const NAV = [
{ href: BASE, label: 'Oggi' },
{ href: `${BASE}/clienti`, label: 'Clienti' },
{ href: `${BASE}/analytics`, label: 'Analytics' },
{ href: `${BASE}/sport`, label: 'Sport', badge: 'Pro' },
{ href: `${BASE}/sport/team-live`, label: 'Team live' },
{ href: `${BASE}/impostazioni`, label: 'Impostazioni' },
{ href: `${BASE}/organizzazione`, label: 'Organizzazione' },
];
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Stress Index InsanityLab</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="si">
<input type="checkbox" id="si-menu" class="si-menu-toggle" hidden />
<label for="si-menu" class="si-menu-btn" aria-label="Apri il menu">☰ Menu</label>
<aside class="si-side">
<a class="si-brand" href={BASE}>
<span class="eyebrow">InsanityLab</span>
Stress Index
</a>
<a class="si-home" href="/piattaforme">← Piattaforme</a>
<nav class="si-nav">
{NAV.map((item) => (
<a
href={item.href}
class:list={['si-nav__link', { 'is-active': path === item.href }]}
>
{item.label}
{item.badge && <span class="si-nav__badge">{item.badge}</span>}
</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">
<header class="si-top">
<p class="si-top__crumbs">
<strong>Stress Index</strong>
{crumbs.map((c) => <span> / {c}</span>)}
</p>
<p class="si-top__studio">{professional.studio}</p>
</header>
<main class="si-main"><slot /></main>
</div>
</body>
</html>
+9 -6
View File
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
export type Role = 'admin' | 'superuser' | 'user' | 'campus';
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme';
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12);
@@ -58,7 +58,10 @@ const RULES: [RegExp, Role[]][] = [
[/^\/api\/admin\/users(\/|$)/, ['admin']],
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
[/^\/campus(\/|$)/, ['admin', 'campus']],
// Le piattaforme riservate: il Campus Biohacking (contenuti) e l'indice/dashboard
// sotto /piattaforme. Stesso ruolo per entrambe.
[/^\/campus(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
];
export function canAccessAdminPath(role: string, pathname: string): boolean {
@@ -66,8 +69,8 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
for (const [re, roles] of RULES) {
if (re.test(pathname)) return roles.includes(role as Role);
}
// campus vede solo la sua sezione (coperta da RULES) e il logout
if (role === 'campus') return pathname === '/admin/logout';
// piattaforme vede solo le sue sezioni (coperte da RULES) e il logout
if (role === 'piattaforme') return pathname === '/admin/logout';
return true; // blog, upload, logout, showtags: tutti i loggati
}
@@ -108,11 +111,11 @@ export function randomPassword(): string {
}
export function isRole(v: unknown): v is Role {
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'campus';
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme';
}
export function landingFor(role: string): string {
if (role === 'superuser') return '/admin/content';
if (role === 'campus') return '/campus';
if (role === 'piattaforme') return '/piattaforme';
return '/admin';
}
+5
View File
@@ -221,6 +221,11 @@ export function createDb(path?: string): Database.Database {
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
updAboutTx();
// Il ruolo `campus` diventa `piattaforme`: non dà più accesso alla sola sezione Biohacking
// ma a tutte le piattaforme riservate (Campus, Stress Index). Rinomina una tantum delle
// 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();
// 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 }[];
+187
View File
@@ -0,0 +1,187 @@
// Dati della piattaforma Stress Index. Sono ancora i dati dimostrativi del prototipo
// (nessun backend HRV collegato): stanno tutti qui, così quando arriverà la sorgente vera
// basterà sostituire queste costanti con le query, lasciando intatte pagine e componenti.
export interface Client {
id: string;
name: string;
initials: string;
age: number;
sex: 'M' | 'F';
tags: string[];
email: string;
since: string;
lastMeasurement: string;
stress: number;
alerts: number;
}
export const clients: Client[] = [
{ id: 'c1', name: 'L. Verdi', initials: 'LV', age: 34, sex: 'F', tags: ['ansia', 'sonno'], email: 'l.verdi@esempio.it', since: '2024-02-10', lastMeasurement: '2026-07-10', stress: 62, alerts: 1 },
{ id: 'c2', name: 'A. Neri', initials: 'AN', age: 45, sex: 'M', tags: ['sport', 'recupero'], email: 'a.neri@esempio.it', since: '2023-11-05', lastMeasurement: '2026-07-11', stress: 38, alerts: 0 },
{ id: 'c3', name: 'G. Rossi', initials: 'GR', age: 29, sex: 'M', tags: ['lavoro', 'stress'], email: 'g.rossi@esempio.it', since: '2024-05-20', lastMeasurement: '2026-07-09', stress: 78, alerts: 2 },
{ id: 'c4', name: 'S. Ferrari', initials: 'SF', age: 52, sex: 'F', tags: ['menopausa', 'sonno'], email: 's.ferrari@esempio.it', since: '2023-08-14', lastMeasurement: '2026-07-12', stress: 55, alerts: 0 },
{ id: 'c5', name: 'P. Colombo', initials: 'PC', age: 40, sex: 'M', tags: ['sport', 'agonismo'], email: 'p.colombo@esempio.it', since: '2024-01-30', lastMeasurement: '2026-07-08', stress: 44, alerts: 0 },
{ id: 'c6', name: 'E. Ricci', initials: 'ER', age: 61, sex: 'F', tags: ['cronico', 'riabilitazione'], email: 'e.ricci@esempio.it', since: '2022-09-01', lastMeasurement: '2026-07-11', stress: 70, alerts: 1 },
{ id: 'c7', name: 'D. Marino', initials: 'DM', age: 33, sex: 'M', tags: ['lavoro', 'burnout'], email: 'd.marino@esempio.it', since: '2024-03-18', lastMeasurement: '2026-07-07', stress: 82, alerts: 2 },
{ id: 'c8', name: 'C. Greco', initials: 'CG', age: 27, sex: 'F', tags: ['sport', 'studentessa'], email: 'c.greco@esempio.it', since: '2024-06-02', lastMeasurement: '2026-07-12', stress: 30, alerts: 0 },
{ id: 'c9', name: 'F. Bruno', initials: 'FB', age: 48, sex: 'M', tags: ['ipertensione', 'sonno'], email: 'f.bruno@esempio.it', since: '2023-04-25', lastMeasurement: '2026-07-06', stress: 66, alerts: 1 },
{ 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 {
date: string;
stress: number;
recovery: number;
balance: number;
energy: number;
type: string;
durationMin: number;
}
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
const TYPES = ['Riposo', 'Lavoro', 'Sport', 'Sonno'];
// Serie settimanale deterministica: l'oscillazione deriva da id + indice, così i grafici
// sono sempre uguali fra server e client (nessun Math.random che sfarfalla all'idratazione).
function buildSeries(clientId: string, baseStress: number, endDate: string, points: number): Measurement[] {
const end = new Date(endDate);
const series: Measurement[] = [];
for (let i = points - 1; i >= 0; i--) {
const d = new Date(end);
d.setDate(d.getDate() - i * 7);
const seed = clientId.charCodeAt(clientId.length - 1) + i * 13;
const wobble = Math.sin(seed) * 12;
const stress = clamp(baseStress + wobble);
series.push({
date: d.toISOString().slice(0, 10),
stress,
recovery: clamp(100 - stress + Math.cos(seed) * 8),
balance: clamp(70 - Math.abs(wobble) + Math.sin(seed * 2) * 6),
energy: clamp(65 - (stress - 50) * 0.4 + Math.cos(seed * 1.5) * 7),
type: TYPES[i % TYPES.length],
durationMin: 15 + ((i * 5) % 40),
});
}
return series;
}
export const measurementsByClient: Record<string, Measurement[]> = {
c1: buildSeries('c1', 62, '2026-07-10', 16),
c2: buildSeries('c2', 38, '2026-07-11', 14),
c3: buildSeries('c3', 78, '2026-07-09', 18),
c4: buildSeries('c4', 55, '2026-07-12', 12),
c5: buildSeries('c5', 44, '2026-07-08', 15),
c6: buildSeries('c6', 70, '2026-07-11', 20),
c7: buildSeries('c7', 82, '2026-07-07', 17),
c8: buildSeries('c8', 30, '2026-07-12', 10),
c9: buildSeries('c9', 66, '2026-07-06', 13),
c10: buildSeries('c10', 58, '2026-07-10', 11),
};
export interface Alert {
clientId: string;
clientName: string;
text: string;
severity: 'warn' | 'error';
when: string;
}
export const alerts: Alert[] = [
{ clientId: 'c7', clientName: 'D. Marino', text: 'Stress elevato per 3 misurazioni consecutive', severity: 'error', when: '2026-07-07' },
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Recupero notturno in calo', severity: 'warn', when: '2026-07-09' },
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Bilancio simpatico/parasimpatico sbilanciato', severity: 'warn', when: '2026-07-05' },
{ clientId: 'c1', clientName: 'L. Verdi', text: 'Qualità del sonno sotto la soglia attesa', severity: 'warn', when: '2026-07-10' },
{ clientId: 'c6', clientName: 'E. Ricci', text: 'Andamento stress in peggioramento nelle ultime 4 settimane', severity: 'warn', when: '2026-07-11' },
{ clientId: 'c9', clientName: 'F. Bruno', text: 'Nessuna misurazione da oltre 7 giorni', severity: 'warn', when: '2026-07-13' },
{ clientId: 'c10', clientName: 'V. Costa', text: 'Picco di stress rilevato dopo sessione di lavoro prolungata', severity: 'warn', when: '2026-07-10' },
{ clientId: 'c7', clientName: 'D. Marino', text: 'Energia media sotto il 30% da due settimane', severity: 'error', when: '2026-07-01' },
];
export interface Note {
clientId?: string;
text: string;
when: string;
}
export const notes: Note[] = [
{ clientId: 'c1', text: 'Consigliata routine di respirazione serale, rivalutare tra 2 settimane.', when: '2026-07-10' },
{ clientId: 'c3', text: 'Segnalato carico lavorativo elevato, proposto colloquio di follow-up.', when: '2026-07-09' },
{ clientId: 'c7', text: 'Situazione di burnout in peggioramento, valutare invio a specialista.', when: '2026-07-07' },
{ text: 'Aggiornamento protocollo misurazioni: aggiunta soglia energia minima.', when: '2026-07-05' },
{ clientId: 'c6', text: 'Programma di riabilitazione in corso, buoni progressi sul recupero.', when: '2026-07-11' },
];
export const studioKpis = {
totalClients: 10,
avgStress: 58,
activeAlerts: 8,
measurementsThisMonth: 46,
retentionRate: 92,
};
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 },
];
export const segments = [
{ label: 'Sportivi', count: 3, avgStress: 37 },
{ label: 'Stress lavorativo', count: 4, avgStress: 69 },
{ label: 'Sonno/recupero', count: 3, avgStress: 61 },
];
export const topPerformers = [
{ clientId: 'c8', name: 'C. Greco', stress: 30 },
{ clientId: 'c2', name: 'A. Neri', stress: 38 },
{ clientId: 'c5', name: 'P. Colombo', stress: 44 },
];
export const criticalClients = [
{ clientId: 'c7', name: 'D. Marino', stress: 82 },
{ clientId: 'c3', name: 'G. Rossi', stress: 78 },
{ 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 },
];
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 },
];
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 },
];
// "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.
export const OGGI = '2026-07-13';
export function daysSince(date: string, from: string = OGGI): number {
return Math.max(0, Math.round((Date.parse(from) - Date.parse(date)) / 86_400_000));
}
export function findClient(id: string): Client | null {
return clients.find((c) => c.id === id) ?? null;
}
export const professional = {
name: 'Dr. M. Bianchi',
role: 'Osteopata',
studio: 'Studio Bianchi — Osteopatia e Benessere',
email: 'm.bianchi@esempio.it',
};
+5 -4
View File
@@ -7,7 +7,8 @@ export const onRequest = defineMiddleware((context, next) => {
const isProtected =
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin') ||
pathname.startsWith('/campus');
pathname.startsWith('/campus') ||
pathname.startsWith('/piattaforme');
if (!isProtected) return next();
const token = context.cookies.get(SESSION_COOKIE)?.value;
@@ -18,9 +19,9 @@ export const onRequest = defineMiddleware((context, next) => {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
// La sezione Biohacking si raggiunge dal sito, quindi passa dal login
// pubblico e ci torna dopo l'accesso; il pannello ha il proprio.
if (pathname.startsWith('/campus'))
// Le piattaforme riservate si raggiungono dal sito, quindi passano dal login
// pubblico e ci tornano dopo l'accesso; il pannello ha il proprio.
if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme'))
return context.redirect(`/login?next=${encodeURIComponent(pathname)}`);
return context.redirect('/admin/login');
}
+2 -2
View File
@@ -16,7 +16,7 @@ const me = Astro.locals.user!;
<select class="afield" name="role">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="campus">campus</option>
<option value="piattaforme">piattaforme</option>
<option value="admin">admin</option>
</select>
<button class="abtn" type="submit">Crea</button>
@@ -32,7 +32,7 @@ const me = Astro.locals.user!;
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td>
<select class="role-sel" data-id={u.id}>
{['user', 'superuser', 'campus', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
{['user', 'superuser', 'piattaforme', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select>
</td>
<td>
+46
View File
@@ -0,0 +1,46 @@
---
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
import Base from '../../layouts/Base.astro';
import { piattaforme } from '../../data/piattaforme';
export const prerender = false;
---
<Base title="Piattaforme" description="Le piattaforme riservate InsanityLab.">
<section class="section plat-index">
<div class="container">
<p class="plat-index__eyebrow">Area riservata</p>
<h1>Piattaforme</h1>
<p class="plat-index__lead">Gli strumenti riservati agli utenti abilitati.</p>
<div class="plat-index__grid">
{piattaforme.map((p) => (
<a class="plat-card" href={p.href}>
<h2 class="plat-card__title">{p.label}</h2>
<p class="plat-card__desc">{p.description}</p>
<span class="plat-card__cta">Entra</span>
</a>
))}
</div>
</div>
</section>
</Base>
<style>
.plat-index__eyebrow {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--c-accent-dark); margin: 0 0 .6em;
}
.plat-index__lead { max-width: 46ch; color: var(--c-text-light); margin-bottom: 46px; }
.plat-index__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
.plat-card {
display: block; text-decoration: none; background: var(--c-bg-alt); padding: 34px 30px 30px;
border-top: 3px solid var(--c-accent); transition: transform .3s ease, box-shadow .3s ease;
}
.plat-card:hover { transform: translateY(-4px); box-shadow: 0 14px 30px rgba(0,0,0,.08); }
.plat-card__title { font-size: 1.25rem; margin-bottom: .4em; }
.plat-card__desc { color: var(--c-text-light); font-size: .92rem; margin-bottom: 1.4em; }
.plat-card__cta {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--c-accent-dark);
}
.plat-card__cta::after { content: ' →'; }
</style>
@@ -0,0 +1,62 @@
---
// Analytics di studio: numeri aggregati sul periodo. Il selettore di periodo è ancora
// dimostrativo (i dati sorgente sono un'istantanea sola), come nel prototipo.
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';
export const prerender = false;
---
<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>
</StressIndex>
@@ -0,0 +1,21 @@
---
// Scheda del singolo cliente. La pagina raccoglie i dati e li passa all'isola React che
// gestisce tab e interazioni.
import StressIndex from '../../../../layouts/StressIndex.astro';
import ClientDetail from '../../../../components/stress-index/ClientDetail';
import { findClient, measurementsByClient, alerts, notes } from '../../../../lib/stress-index/data';
export const prerender = false;
const { id } = Astro.params;
const client = findClient(id ?? '');
if (!client) return Astro.redirect('/piattaforme/stress-index/clienti');
---
<StressIndex title={client.name} crumbs={['Clienti', client.name]}>
<ClientDetail
client:load
client={client}
measurements={measurementsByClient[client.id] ?? []}
alerts={alerts.filter((a) => a.clientId === client.id)}
notes={notes.filter((n) => n.clientId === client.id)}
/>
</StressIndex>
@@ -0,0 +1,89 @@
---
// Elenco clienti (CRM). Il filtro per nome/email è puro JS sulle righe già renderizzate:
// i clienti sono una decina, non vale un'isola React per cercarli.
import StressIndex from '../../../../layouts/StressIndex.astro';
import Card from '../../../../components/stress-index/Card.astro';
import { clients } from '../../../../lib/stress-index/data';
export const prerender = false;
const livello = (stress: number) => (stress >= 70 ? 'bad' : stress >= 45 ? 'warn' : 'ok');
---
<StressIndex title="Clienti" crumbs={['Clienti']}>
<div class="si-head">
<h1>Clienti</h1>
<p>{clients.length} in carico</p>
</div>
<Card>
<div class="si-toolbar">
<label class="si-field si-field--inline">
<span class="si-visually-hidden">Cerca cliente</span>
<input type="search" id="si-cerca" placeholder="Cerca per nome o email" autocomplete="off" />
</label>
<p class="si-muted si-small" id="si-conteggio">{clients.length} risultati</p>
</div>
<table class="si-table" id="si-tabella-clienti">
<thead>
<tr>
<th>Cliente</th><th>Età</th><th>Tag</th><th>Ultima misurazione</th><th>Stress</th><th>Alert</th>
</tr>
</thead>
<tbody>
{clients.map((c) => (
<tr data-cerca={`${c.name} ${c.email}`.toLowerCase()}>
<td>
<a class="si-client" href={`/piattaforme/stress-index/clienti/${c.id}`}>
<span class="si-avatar">{c.initials}</span>
<span>
<span class="si-client__name">{c.name}</span>
<span class="si-client__mail">{c.email}</span>
</span>
</a>
</td>
<td>{c.age} anni</td>
<td>{c.tags[0] && <span class="si-tag">{c.tags[0]}</span>}</td>
<td>{c.lastMeasurement}</td>
<td>
<div class="si-meter">
<span class="si-bar">
<span class:list={['si-bar__fill', `si-bar__fill--${livello(c.stress)}`]} style={`width:${c.stress}%`}></span>
</span>
<span class="si-table__num">{c.stress}</span>
</div>
</td>
<td>
{c.alerts > 0
? <span class:list={['si-badge', c.alerts >= 2 ? 'si-badge--bad' : 'si-badge--warn']}>{c.alerts}</span>
: <span class="si-muted">—</span>}
</td>
</tr>
))}
</tbody>
</table>
<p class="si-muted si-small si-empty" id="si-vuoto" hidden>Nessun cliente corrisponde alla ricerca.</p>
</Card>
</StressIndex>
<script>
// Filtro dell'elenco: nasconde le righe che non contengono il testo cercato e tiene
// aggiornato il conteggio dei risultati.
const input = document.getElementById('si-cerca') as HTMLInputElement | null;
const table = document.getElementById('si-tabella-clienti');
const counter = document.getElementById('si-conteggio');
const empty = document.getElementById('si-vuoto');
if (input && table && counter && empty) {
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
let visible = 0;
for (const row of rows) {
const match = !q || (row.dataset.cerca ?? '').includes(q);
row.hidden = !match;
if (match) visible += 1;
}
counter.textContent = `${visible} ${visible === 1 ? 'risultato' : 'risultati'}`;
empty.hidden = visible > 0;
});
}
</script>
@@ -0,0 +1,121 @@
---
// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo —
// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva.
import StressIndex from '../../../layouts/StressIndex.astro';
import Card from '../../../components/stress-index/Card.astro';
import { professional } from '../../../lib/stress-index/data';
export const prerender = false;
const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' ');
const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'];
const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00'];
const TABS = [
{ key: 'profilo', label: 'Profilo' },
{ key: 'notifiche', label: 'Notifiche' },
{ key: 'account', label: 'Account' },
];
---
<StressIndex title="Impostazioni" crumbs={['Impostazioni']}>
<div class="si-head">
<h1>Impostazioni</h1>
<p>Profilo, notifiche e account</p>
</div>
<div class="si-tabs" role="tablist" id="si-set-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="profilo" class="si-stack">
<Card title="Dati professionista">
<div class="si-form-grid">
<label class="si-field">Titolo<input type="text" value="Dr." /></label>
<label class="si-field">Professione<input type="text" value={professional.role} /></label>
<label class="si-field">Nome<input type="text" value={nome} /></label>
<label class="si-field">Cognome<input type="text" value={cognome} /></label>
<label class="si-field">Specializzazione<input type="text" value={professional.role} /></label>
</div>
</Card>
<Card title="Studio">
<div class="si-form-grid">
<label class="si-field">Nome studio<input type="text" value={professional.studio} /></label>
<label class="si-field">Indirizzo<input type="text" placeholder="Via Roma 1, Bologna" /></label>
<label class="si-field">Telefono<input type="tel" placeholder="+39 051 123456" /></label>
<label class="si-field">Sito web<input type="url" placeholder="https://studio.it" /></label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva</button></p>
</Card>
</section>
<section data-panel="notifiche" hidden>
<Card title="Preferenze di notifica">
<ul class="si-list si-list--rows si-prefs">
<li>
<span>
<strong>Riassunto settimanale via email</strong>
<span class="si-muted si-small">Un riepilogo periodico dell'andamento dei clienti</span>
</span>
<input type="checkbox" checked />
</li>
<li>
<span>
<strong>Alert immediati</strong>
<span class="si-muted si-small">Email non appena viene generato un alert cliente</span>
</span>
<input type="checkbox" checked />
</li>
<li>
<span>
<strong>Novità di prodotto</strong>
<span class="si-muted si-small">Aggiornamenti sulle nuove funzionalità</span>
</span>
<input type="checkbox" />
</li>
</ul>
<div class="si-form-grid si-mt">
<label class="si-field">Giorno dell'invio
<select>{GIORNI.map((g) => <option>{g}</option>)}</select>
</label>
<label class="si-field">Ora dell'invio
<select>{ORE.map((o) => <option>{o}</option>)}</select>
</label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva preferenze</button></p>
</Card>
</section>
<section data-panel="account" hidden class="si-stack">
<Card title="Accesso">
<label class="si-field">Email<input type="email" value={professional.email} readonly /></label>
<p class="si-muted si-small si-mt">Per cambiare l'indirizzo email serve una richiesta al supporto.</p>
<div class="si-form-grid si-mt">
<label class="si-field">Password attuale<input type="password" autocomplete="current-password" /></label>
<label class="si-field">Nuova password<input type="password" autocomplete="new-password" /></label>
<label class="si-field">Conferma nuova password<input type="password" autocomplete="new-password" /></label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Aggiorna password</button></p>
</Card>
<Card title="Abbonamento">
<p class="si-muted si-small">Per il piano e la fatturazione fa riferimento al supporto Stress Index.</p>
</Card>
<p><a class="si-btn" href="/admin/logout">Esci dall'account</a></p>
</section>
</StressIndex>
<script>
const tabs = document.getElementById('si-set-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,88 @@
---
// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da
// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin).
import StressIndex from '../../../layouts/StressIndex.astro';
import Card from '../../../components/stress-index/Card.astro';
import Kpi from '../../../components/stress-index/Kpi.astro';
import AlertRow from '../../../components/stress-index/AlertRow.astro';
import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart';
import {
alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince,
} from '../../../lib/stress-index/data';
export const prerender = false;
const prioritari = alerts.slice(0, 4);
const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length;
const daContattare = [...clients]
.sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement))
.slice(0, 5);
const ultimeNote = [...notes]
.sort((a, b) => (a.when < b.when ? 1 : -1))
.slice(0, 4)
.map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name }));
---
<StressIndex title="Oggi">
<div class="si-head">
<h1>Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}</h1>
<p>Oggi, {OGGI}</p>
</div>
<div class="si-grid si-grid--kpi">
<Kpi label="Clienti attivi" value={studioKpis.totalClients} delta="+2 questo mese" trend="up" />
<Kpi
label="Misurazioni oggi"
value={misurazioniOggi}
delta={misurazioniOggi === 0 ? 'Nessuna ancora' : 'In aggiornamento'}
trend={misurazioniOggi === 0 ? 'down' : 'up'}
/>
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} delta="+3 questa settimana" trend="down" />
<Kpi label="Stress medio" value={studioKpis.avgStress} delta="-4% vs mese scorso" trend="up" />
</div>
<div class="si-grid si-grid--2 si-mt">
<div class="si-stack">
<Card title="Andamento medio aggregato">
<AndamentoChart client:load measurementsByClient={measurementsByClient} />
</Card>
<Card title="Alert prioritari" actionLabel="Vedi tutti" actionHref="/piattaforme/stress-index/clienti">
{prioritari.map((a) => <AlertRow alert={a} />)}
</Card>
<Card title="Da contattare">
<ul class="si-list si-list--rows">
{daContattare.map((c) => (
<li>
<span>
<strong>{c.name}</strong>
<span class="si-muted"> · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa</span>
</span>
<a class="si-btn" href={`/piattaforme/stress-index/clienti/${c.id}`}>Scheda</a>
</li>
))}
</ul>
</Card>
</div>
<div class="si-stack">
<Card title="Misurazioni di oggi">
{misurazioniOggi === 0 ? (
<p class="si-muted si-small">Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.</p>
) : (
<p class="si-kpi__value">{misurazioniOggi}</p>
)}
</Card>
<Card title="Ultime note">
<ul class="si-list">
{ultimeNote.map((n) => (
<li class="si-note">
<p>{n.text}</p>
<p class="si-note__meta">{n.clientName ?? 'Nota generale'} · {n.when}</p>
</li>
))}
</ul>
</Card>
</div>
</div>
</StressIndex>
@@ -0,0 +1,33 @@
---
// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è
// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto.
import StressIndex from '../../../layouts/StressIndex.astro';
export const prerender = false;
---
<StressIndex title="Organizzazione" crumbs={['Organizzazione']}>
<div class="si-center">
<div class="si-card si-org">
<h1>Crea la tua organizzazione</h1>
<p class="si-muted si-small">
Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti,
dati e impostazioni dello studio.
</p>
<label class="si-field si-mt">
Nome organizzazione
<input type="text" id="si-org-nome" placeholder="Studio Bianchi Associati" />
</label>
<p class="si-mt">
<button type="button" class="si-btn si-btn--solid si-org__cta" id="si-org-crea" disabled>Crea organizzazione</button>
</p>
</div>
</div>
</StressIndex>
<script>
// Il pulsante si sblocca solo con un nome valorizzato.
const input = document.getElementById('si-org-nome') as HTMLInputElement | null;
const btn = document.getElementById('si-org-crea') as HTMLButtonElement | null;
if (input && btn) {
input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; });
}
</script>
@@ -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>
+287
View File
@@ -0,0 +1,287 @@
/* Stile della piattaforma Stress Index.
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
ai componenti Astro sia alle isole React (grafici e viste interattive), che lo scope
di Astro non raggiungerebbe. Tutto è prefissato `.si` e usa i token del sito. */
.si {
--si-line: #e4dfd7;
--si-surface: #ffffff;
--si-ok: #6f8f6a;
--si-warn: #c08a3e;
--si-bad: #b05a4e;
display: flex;
align-items: flex-start;
background: var(--c-bg-alt);
}
/* --- sidebar --- */
.si-side {
width: 250px; flex: 0 0 250px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
background: var(--c-dark); color: #cfc6bd; padding: 28px 20px 40px;
display: flex; flex-direction: column;
}
.si-brand {
display: block; font-family: var(--font-heading); font-size: 1.35rem; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1;
}
.si-brand .eyebrow { display: block; font-size: .7rem; letter-spacing: .18em; color: var(--c-accent); margin-bottom: 5px; }
.si-home {
display: inline-block; margin: 16px 0 22px; font-family: var(--font-heading); font-size: .68rem;
font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--c-accent);
text-decoration: none; border-bottom: 1px solid transparent;
}
.si-home:hover { border-bottom-color: var(--c-accent); }
.si-nav { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid rgba(255,255,255,.12); padding-top: 16px; }
.si-nav__link {
display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-decoration: none;
font-family: var(--font-heading); font-size: .74rem; font-weight: 600; letter-spacing: .12em;
text-transform: uppercase; color: #b6ada3; border-left: 3px solid transparent;
}
.si-nav__link:hover { color: #fff; background: rgba(255,255,255,.06); }
.si-nav__link.is-active { color: #fff; background: rgba(184,169,140,.22); border-left-color: var(--c-accent); }
.si-nav__badge {
margin-left: auto; background: var(--c-accent); color: var(--c-dark); border-radius: 999px;
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 {
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
background: var(--si-surface); border-bottom: 1px solid var(--si-line); padding: 16px 32px;
position: sticky; top: 0; z-index: 10;
}
.si-top p { margin: 0; }
.si-top__crumbs { font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light); }
.si-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
.si-top__studio { font-size: .8rem; color: var(--c-text-light); }
.si-main { padding: 34px 32px 70px; }
/* --- intestazione di pagina --- */
.si-head { margin-bottom: 28px; }
.si-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
.si-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
/* --- griglie --- */
.si-grid { display: grid; gap: 20px; }
.si-grid--kpi { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.si-grid--2 { grid-template-columns: 2fr 1fr; align-items: start; }
.si-grid--halves { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.si-stack { display: flex; flex-direction: column; gap: 20px; }
.si-mt { margin-top: 20px; }
/* --- card --- */
.si-card { background: var(--si-surface); border: 1px solid var(--si-line); padding: 20px 22px; }
.si-card__head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
.si-card__title {
margin: 0; font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
}
.si-card__action { font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); text-decoration: none; }
.si-card__action:hover { text-decoration: underline; }
/* --- kpi --- */
.si-kpi__label { font-size: .78rem; color: var(--c-text-light); letter-spacing: .04em; }
.si-kpi__value { font-family: var(--font-heading); font-size: 2rem; font-weight: 500; color: var(--c-heading); margin: 6px 0 4px; }
.si-kpi__delta { font-size: .78rem; font-weight: 600; }
.si-kpi__delta--up { color: var(--si-ok); }
.si-kpi__delta--down { color: var(--si-bad); }
/* --- liste e tabelle --- */
.si-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.si-list--rows > li { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: .88rem; }
.si-list--defs .si-list__row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; font-size: .88rem; }
.si-list--defs dd { margin: 0; color: var(--c-heading); }
.si-muted { color: var(--c-text-light); }
.si-small { font-size: .78rem; }
.si-note p { margin: 0; font-size: .84rem; }
.si-note__meta { margin-top: 2px; font-size: .76rem; color: var(--c-text-light); }
.si-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.si-table th, .si-table td { border-bottom: 1px solid var(--si-line); padding: 11px 10px; text-align: left; vertical-align: middle; }
.si-table th {
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-text-light);
}
.si-table tbody tr:hover { background: var(--c-bg-alt); }
.si-table a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
.si-table a:hover { text-decoration: underline; }
.si-table__num { font-family: var(--font-heading); }
/* --- elementi --- */
.si-avatar {
display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
border-radius: 50%; background: var(--c-accent); color: #fff; font-family: var(--font-heading);
font-size: .74rem; font-weight: 600; letter-spacing: .04em; flex: 0 0 34px;
}
.si-tag {
display: inline-block; border: 1px solid var(--si-line); padding: 2px 9px; margin-right: 5px;
font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--c-text-light);
}
.si-badge { display: inline-block; padding: 2px 9px; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.si-badge--ok { background: rgba(111,143,106,.16); color: var(--si-ok); }
.si-badge--warn { background: rgba(192,138,62,.16); color: var(--si-warn); }
.si-badge--bad { background: rgba(176,90,78,.16); color: var(--si-bad); }
.si-alert { display: flex; gap: 12px; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid var(--si-line); }
.si-alert:last-child { border-bottom: 0; }
.si-alert__mark { width: 7px; height: 7px; border-radius: 50%; margin-top: 7px; flex: 0 0 7px; background: var(--si-warn); }
.si-alert--error .si-alert__mark { background: var(--si-bad); }
.si-alert__text { font-size: .88rem; font-weight: 600; color: var(--c-heading); }
.si-alert__meta { font-size: .76rem; color: var(--c-text-light); }
.si-btn {
display: inline-block; border: 1px solid var(--si-line); background: none; cursor: pointer;
padding: 6px 14px; font-family: var(--font-heading); font-size: .66rem; font-weight: 600;
letter-spacing: .14em; text-transform: uppercase; color: var(--c-heading); text-decoration: none;
}
.si-btn:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
.si-btn--solid { background: var(--c-dark); border-color: var(--c-dark); color: #fff; }
.si-btn--solid:hover { background: var(--c-dark-2); color: #fff; }
.si-field { display: flex; flex-direction: column; gap: 6px; font-size: .82rem; color: var(--c-text-light); }
.si-field input, .si-field select, .si-field textarea {
border: 1px solid var(--si-line); background: #fff; padding: 9px 11px; font: inherit;
font-size: .88rem; color: var(--c-text); border-radius: 0;
}
.si-field input:focus, .si-field select:focus, .si-field textarea:focus { outline: 2px solid var(--c-accent); outline-offset: -2px; }
/* --- misura, barre --- */
.si-bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
.si-bar__fill { height: 100%; background: var(--c-accent); }
.si-bar__fill--ok { background: var(--si-ok); }
.si-bar__fill--warn { background: var(--si-warn); }
.si-bar__fill--bad { background: var(--si-bad); }
.si-score { display: flex; align-items: baseline; gap: 8px; }
.si-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
.si-score__max { font-size: .82rem; color: var(--c-text-light); }
/* --- tab (isole React) --- */
.si-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--si-line); margin-bottom: 22px; }
.si-tabs__btn {
background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; padding: 10px 2px;
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-text-light);
}
.si-tabs__btn[aria-selected='true'] { color: var(--c-heading); border-bottom-color: var(--c-accent); }
.si-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.si-chip {
background: none; border: 1px solid var(--si-line); cursor: pointer; padding: 5px 12px;
font-family: var(--font-heading); font-size: .66rem; font-weight: 600; letter-spacing: .12em;
text-transform: uppercase; color: var(--c-text-light);
}
.si-chip[aria-pressed='true'] { border-color: var(--c-accent); background: var(--c-bg-alt); color: var(--c-accent-dark); }
.si-chart { width: 100%; }
.si-chart__empty { font-size: .85rem; color: var(--c-text-light); }
/* --- mobile --- */
.si-menu-btn { display: none; }
@media (max-width: 900px) {
.si { display: block; }
.si-menu-btn {
display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; padding: 14px 20px; cursor: pointer;
}
.si-side { display: none; width: auto; flex: none; height: auto; position: static; }
.si-menu-toggle:checked ~ .si-side { display: flex; }
.si-top { padding: 14px 20px; flex-direction: column; gap: 4px; }
.si-main { padding: 24px 20px 60px; }
.si-grid--2 { grid-template-columns: 1fr; }
}
/* --- elenco clienti --- */
.si-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.si-toolbar p { margin: 0; }
.si-field--inline { flex-direction: row; align-items: center; }
.si-field--inline input { min-width: 260px; }
.si-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.si-client { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.si-client__name { display: block; font-weight: 600; color: var(--c-heading); }
.si-client__mail { display: block; font-size: .76rem; color: var(--c-text-light); font-weight: 400; }
.si-client:hover .si-client__name { color: var(--c-accent-dark); }
.si-meter { display: flex; align-items: center; gap: 10px; }
.si-meter .si-bar { width: 74px; flex: 0 0 74px; }
.si-empty { margin: 16px 0 0; }
/* --- scheda cliente --- */
.si-client-head { display: flex; align-items: center; gap: 16px; }
.si-client-head h1 { margin: 0 0 2px; font-size: clamp(1.5rem, 2.6vw, 1.9rem); text-transform: none; letter-spacing: .02em; }
.si-client-head p { margin: 0; }
.si-avatar--lg { width: 52px; height: 52px; flex: 0 0 52px; font-size: .95rem; }
.si-gauges { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-around; }
.si-gauge { width: 150px; text-align: center; }
.si-gauge__svg { width: 100%; height: auto; }
.si-gauge__value { margin: 2px 0 0; font-family: var(--font-heading); font-size: 1.6rem; font-weight: 500; color: var(--c-heading); }
.si-gauge__max { font-size: .78rem; color: var(--c-text-light); }
.si-gauge__label { margin: 0; font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
.si-insight { background: var(--c-dark); color: #cfc6bd; padding: 22px 24px; }
.si-insight p { margin: 0; font-size: .9rem; }
.si-insight__title {
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--c-accent); margin-bottom: 8px !important;
}
.si-scroll-x { overflow-x: auto; }
.si-note-card { border: 1px solid var(--si-line); padding: 12px 14px; }
.si-note-card p { margin: 0; font-size: .86rem; }
.si-chat { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.si-chat__row { display: flex; }
.si-chat__row--professionista { justify-content: flex-end; }
.si-chat__bubble { max-width: 70%; padding: 10px 14px; background: var(--c-bg-alt); border: 1px solid var(--si-line); }
.si-chat__row--professionista .si-chat__bubble { background: var(--c-dark); border-color: var(--c-dark); color: #efe9e1; }
.si-chat__bubble p { margin: 0; font-size: .86rem; }
.si-chat__when { margin-top: 4px !important; font-size: .7rem; opacity: .7; }
.si-chat__compose { display: flex; align-items: center; gap: 10px; }
.si-chat__compose .si-field { flex: 1; }
.si-chat__compose input { width: 100%; }
.si-tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.si-tag--removable { display: inline-flex; align-items: center; gap: 7px; margin-right: 0; }
.si-tag--removable button { background: none; border: 0; cursor: pointer; color: inherit; font-size: .7rem; padding: 0; }
/* --- analytics, sport, impostazioni --- */
.si-head--row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.si-segment { border: 1px solid var(--si-line); padding: 16px 18px; }
.si-segment__label { margin: 0; font-weight: 600; color: var(--c-heading); font-size: .9rem; }
.si-segment p { margin: 0; }
.si-segment .si-kpi__value { margin: 6px 0 2px; }
.si-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; }
.si-prefs > li { align-items: flex-start; }
.si-prefs strong { display: block; font-size: .88rem; color: var(--c-heading); }
.si-prefs .si-small { display: block; }
/* --- team live --- */
.si-live { text-align: center; padding: 54px 26px; }
.si-live__title { font-family: var(--font-heading); font-size: 1.05rem; color: var(--c-heading); margin: 0 0 6px; }
.si-live__how { max-width: 560px; margin: 32px auto 0; border: 1px solid var(--si-line); padding: 20px 22px; text-align: left; background: var(--c-bg-alt); }
.si-steps { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.si-steps li { display: flex; gap: 12px; align-items: flex-start; font-size: .86rem; color: var(--c-text); }
.si-steps__num {
flex: 0 0 24px; width: 24px; height: 24px; border-radius: 50%; background: var(--c-accent); color: #fff;
display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-heading);
font-size: .72rem; font-weight: 600;
}
/* --- organizzazione --- */
.si-center { display: flex; justify-content: center; padding: 40px 0; }
.si-org { width: 100%; max-width: 460px; text-align: center; padding: 34px 32px; }
.si-org h1 { font-size: 1.4rem; text-transform: none; letter-spacing: .02em; margin-bottom: .4em; }
.si-org p { margin: 0; }
.si-org .si-field { text-align: left; }
.si-org__cta:disabled { opacity: .45; cursor: not-allowed; }
-50
View File
@@ -1,50 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor, isRole } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruolo campus', () => {
it('createUser accetta campus e getSessionUser lo ritorna', () => {
createUser(db, 'c', 'segreta123', 'campus');
expect(getSessionUser(db, login(db, 'c', 'segreta123')!)).toMatchObject({ role: 'campus' });
});
it('isRole valida campus', () => {
expect(isRole('campus')).toBe(true);
});
it('landingFor porta campus alla sezione campus', () => {
expect(landingFor('campus')).toBe('/campus');
});
});
describe('canAccessAdminPath con campus', () => {
it('campus: sezione campus e logout sì, resto del pannello no', () => {
expect(canAccessAdminPath('campus', '/campus')).toBe(true);
expect(canAccessAdminPath('campus', '/campus/')).toBe(true);
expect(canAccessAdminPath('campus', '/campus/corso-base/lezione-01')).toBe(true);
expect(canAccessAdminPath('campus', '/campus/assets/slide/x.png')).toBe(true);
expect(canAccessAdminPath('campus', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('campus', '/admin')).toBe(false);
expect(canAccessAdminPath('campus', '/admin/new')).toBe(false);
expect(canAccessAdminPath('campus', '/admin/content')).toBe(false);
expect(canAccessAdminPath('campus', '/admin/users')).toBe(false);
expect(canAccessAdminPath('campus', '/api/admin/posts')).toBe(false);
expect(canAccessAdminPath('campus', '/api/admin/upload')).toBe(false);
});
it('admin accede alla sezione campus', () => {
expect(canAccessAdminPath('admin', '/campus')).toBe(true);
expect(canAccessAdminPath('admin', '/campus/corso-base')).toBe(true);
});
it('superuser e user non accedono alla sezione campus', () => {
expect(canAccessAdminPath('superuser', '/campus')).toBe(false);
expect(canAccessAdminPath('superuser', '/campus/corso-base')).toBe(false);
expect(canAccessAdminPath('user', '/campus')).toBe(false);
expect(canAccessAdminPath('user', '/campus/assets/slide/x.png')).toBe(false);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor, isRole } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruolo piattaforme', () => {
it('createUser accetta piattaforme e getSessionUser lo ritorna', () => {
createUser(db, 'c', 'segreta123', 'piattaforme');
expect(getSessionUser(db, login(db, 'c', 'segreta123')!)).toMatchObject({ role: 'piattaforme' });
});
it('isRole valida piattaforme e non più il vecchio campus', () => {
expect(isRole('piattaforme')).toBe(true);
expect(isRole('campus')).toBe(false);
});
it("landingFor porta piattaforme all'indice delle piattaforme", () => {
expect(landingFor('piattaforme')).toBe('/piattaforme');
});
});
describe('canAccessAdminPath con piattaforme', () => {
it('piattaforme: sezioni riservate e logout sì, resto del pannello no', () => {
expect(canAccessAdminPath('piattaforme', '/campus')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/corso-base/lezione-01')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/assets/slide/x.png')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index/clienti')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/new')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/content')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/users')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/api/admin/posts')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/api/admin/upload')).toBe(false);
});
it('admin accede a tutte le piattaforme', () => {
expect(canAccessAdminPath('admin', '/campus')).toBe(true);
expect(canAccessAdminPath('admin', '/campus/corso-base')).toBe(true);
expect(canAccessAdminPath('admin', '/piattaforme')).toBe(true);
expect(canAccessAdminPath('admin', '/piattaforme/stress-index')).toBe(true);
});
it('superuser e user non accedono alle piattaforme', () => {
expect(canAccessAdminPath('superuser', '/campus')).toBe(false);
expect(canAccessAdminPath('superuser', '/campus/corso-base')).toBe(false);
expect(canAccessAdminPath('superuser', '/piattaforme')).toBe(false);
expect(canAccessAdminPath('superuser', '/piattaforme/stress-index')).toBe(false);
expect(canAccessAdminPath('user', '/campus')).toBe(false);
expect(canAccessAdminPath('user', '/campus/assets/slide/x.png')).toBe(false);
expect(canAccessAdminPath('user', '/piattaforme')).toBe(false);
});
});
// La rinomina del ruolo va verificata su un DB su file: le migrazioni girano all'apertura,
// quindi serve chiudere e riaprire lo stesso database con dentro una riga al vecchio valore.
describe('migrazione del ruolo campus → piattaforme', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'ilab-db-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('rinomina gli utenti rimasti al vecchio ruolo, lasciando stare gli altri', () => {
const file = join(dir, 'test.db');
const first = createDb(file);
createUser(first, 'vecchio', 'segreta123', 'campus' as never);
createUser(first, 'redattore', 'segreta123', 'superuser');
first.close();
const second = createDb(file);
const roles = second.prepare('SELECT username, role FROM users ORDER BY username').all() as
{ username: string; role: string }[];
second.close();
expect(roles).toEqual([
{ username: 'redattore', role: 'superuser' },
{ username: 'vecchio', role: 'piattaforme' },
]);
});
});
+12 -2
View File
@@ -1,5 +1,15 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
"exclude": ["dist"]
"include": [
".astro/types.d.ts",
"src/**/*",
"tests/**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}