Separazione: le aree riservate diventano un'app propria (apps/platforms)

Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

Misura che ha guidato il taglio: le tre aree condividevano col sito
SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts.
Layout, librerie e dati erano gia' disgiunti.

Lato piattaforme:
- ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali
- canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non
  elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno)
- tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti
- /api risponde 401/403, non un redirect verso una pagina di login
- tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi

Lato sito:
- ROLES tornano i tre redazionali; via le regole e il login pubblico
- il login del pannello RESPINGE un account emigrato invece di aprirgli una
  sessione: senza, landingFor lo mandava su /admin, il middleware glielo
  negava, e si formava un ciclo di redirect che sembra un guasto del sito
- l'header non mostra piu' un menu delle piattaforme: un link che esce

Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi
i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra
in Campus e viene rimbalzato da Longevity, il cliente il contrario.

Non ancora in produzione: manca il record DNS di apps.insanitylab.it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:21:12 +00:00
parent 510d7ca4ec
commit 6301f521fd
383 changed files with 8477 additions and 350 deletions
@@ -0,0 +1,37 @@
---
// La carta di un macro-score (Performance/Energy/Recovery). Stesso principio del radar:
// il numero se il dato basta, un trattino se no — MAI uno zero, che si leggerebbe come
// "hai zero" invece di "non lo sappiamo ancora". Il bordo tratteggiato (.lg-card--incompleto,
// dal Task 1) è lo stesso linguaggio visivo del radar: non una convenzione nuova per le carte.
// L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un
// elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery
// con una trasformazione, non con un dizionario da tenere allineato a mano.
import { formattaNumero } from '../../lib/longevity/formato';
interface Props {
nome: string;
valore: number | null;
copertura: number;
}
const { nome, valore, copertura } = Astro.props;
const etichetta = nome.charAt(0) + nome.slice(1).toLowerCase();
// L'intensità dell'accento (mai un colore diverso, Task 1): minimo 0.15 così una carta
// nota ma bassa resta comunque leggibile, non sparisce nello sfondo.
const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0;
---
<div class:list={['lg-card', 'lg-macro__card', { 'lg-card--incompleto': valore === null }]}>
<p class="lg-score__label">{etichetta}</p>
<div class="lg-score">
<span class="lg-score__value lg-num">{valore !== null ? formattaNumero(valore) : '—'}</span>
<span class="lg-score__max">/ 100</span>
</div>
<div class="lg-score__bar">
<span
class="lg-score__bar-fill"
style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`}
></span>
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(copertura * 100)}%{valore === null && ' — non ancora sufficiente per un numero.'}
</p>
</div>
@@ -0,0 +1,192 @@
// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
//
// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
// proteggere e non protegge). La verifica che conta è quella del server
// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
import { useMemo, useState } from 'react';
import type { CampoQuestionario } from '../../lib/longevity/vista';
interface Props {
campi: CampoQuestionario[];
}
const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
{ id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
{ id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
{ id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
{ id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
];
const BLOCCHI = [
{ titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
{ titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
{ titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
] as const;
type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
export default function Questionario({ campi }: Props) {
const [passo, setPasso] = useState(0);
const [risposte, setRisposte] = useState<Record<string, string>>({});
const [liberi, setLiberi] = useState<Record<string, string>>({});
const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
const [eta, setEta] = useState('');
const [inviando, setInviando] = useState(false);
const [esito, setEsito] = useState<Esito | null>(null);
// Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
// ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
const gruppi = useMemo(
() => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
[campi]
);
// Il tipo dichiarato nel registro per ciascun campo: serve sia per scegliere il tipo di
// input (numero o testo) sia, all'invio, per non trattare una risposta testuale come un
// numero da scartare in silenzio.
const tipoDiCampo = useMemo(() => new Map(campi.map((c) => [c.id, c.tipo])), [campi]);
const totalePassi = gruppi.length + 1; // + il blocco note/consenso
const ultimo = passo === totalePassi - 1;
async function invia() {
setInviando(true);
setEsito(null);
const risposteNumeriche: Record<string, number> = {};
for (const [id, v] of Object.entries(risposte)) {
// Le domande testuali (tipo_valore='txt' nel registro) non sono ancora agganciate
// al salvataggio numerico: oggi non esistono nel questionario (i 20 campi attivi
// sono tutti 'num'), ma se un domani ne comparisse una non va convertita in NaN e
// scartata in silenzio — resta fuori esplicitamente.
if (tipoDiCampo.get(id) === 'txt') continue;
const n = Number(v);
if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
}
try {
const res = await fetch('/api/longevity/questionario', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risposte: risposteNumeriche,
liberi,
consensoSanitario: consenso,
eta: eta === '' ? undefined : Number(eta),
}),
});
const corpo = await res.json();
if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
} catch {
setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
} finally {
setInviando(false);
}
}
if (esito?.tipo === 'ok') {
return (
<div className="lg-card">
<p>Questionario salvato, grazie.</p>
</div>
);
}
return (
<div className="lg-form">
<p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
{passo < gruppi.length && (
<section className="lg-form__blocco">
<h2>{gruppi[passo].titolo}</h2>
{gruppi[passo].campi.map((c) => (
<label className="lg-field" key={c.id}>
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
{c.tipo === 'txt' ? (
<input
type="text"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
) : (
<input
type="number"
min={c.min}
max={c.max}
step="any"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
)}
</label>
))}
</section>
)}
{ultimo && (
<section className="lg-form__blocco">
<h2>Note e consenso</h2>
{CAMPI_LIBERI.map((c) => (
<label className="lg-field" key={c.id}>
<span>{c.etichetta}</span>
<textarea
value={liberi[c.id] ?? ''}
onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
/>
</label>
))}
<label className="lg-field">
<span>La tua età</span>
<input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
</label>
<fieldset className="lg-field lg-field--consenso">
<legend>
Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
composizione corporea) per il monitoraggio del percorso?
</legend>
<label>
<input
type="radio" name="consenso" checked={consenso === 'Si'}
onChange={() => setConsenso('Si')}
/>
, acconsento
</label>
<label>
<input
type="radio" name="consenso" checked={consenso === 'No'}
onChange={() => setConsenso('No')}
/>
No, non acconsento
</label>
</fieldset>
{esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
</section>
)}
<div className="lg-form__nav">
<button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
Indietro
</button>
{!ultimo && (
<button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
Avanti
</button>
)}
{ultimo && (
<button type="button" disabled={inviando || consenso === ''} onClick={invia}>
{inviando ? 'Invio…' : 'Invia'}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,95 @@
// Il radar a sette assi del referto: isola React perché recharts ha bisogno del DOM.
// Due serie sovrapposte sugli stessi sette assi (recharts vuole un solo `data` condiviso,
// non può disegnare un poligono che copre solo alcuni assi):
// - "noto": l'area piena, coi valori congelati che la sessione ha davvero. Dove il dato
// manca vale 0 — non si inventa un centro pieno per una misura che non c'è, si lascia
// che l'area si ritiri, onestamente, verso il centro;
// - "potenziale": il perimetro tratteggiato. Dove il dato c'è coincide col bordo di
// "noto" (stesso valore, la linea tratteggiata sparisce dietro quella piena); dove
// manca si allarga fino al massimo (100) — è la parte che CHIUDE il perimetro, il gesto
// su cui si regge l'interfaccia: non un errore, un invito a completare quell'asse.
// Sotto il grafico, l'elenco leggibile di cosa completerebbe ogni asse ancora tratteggiato,
// letto da `mancano` (calcolato in vista.ts, mai qui: questo componente non tocca il db).
import {
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
} from 'recharts';
import type { AsseVista } from '../../lib/longevity/vista';
interface Props {
assi: AsseVista[];
}
// Stessi colori del sito (come in DataChart.tsx, che per lo stesso motivo — attributi SVG,
// non proprietà CSS — non passa var(--...) a recharts): l'accento e il tratteggio di
// longevity.css, non un colore nuovo.
const ACCENTO = '#9c8b70';
const TRATTEGGIO = '#d8d2c8';
const LINEA = '#e4dfd7';
const TESTO_CHIARO = '#8a8a8a';
export default function RadarAssi({ assi }: Props) {
const dati = assi.map((a) => ({
nome: a.nome,
noto: a.valore ?? 0,
potenziale: a.valore ?? 100,
}));
const incompleti = assi.filter((a) => a.mancano.length > 0);
return (
<div className="lg-radar">
{/* La spiegazione del linguaggio visivo viene PRIMA del grafico che spiega, non dopo:
chi legge deve sapere cosa sta guardando prima di guardarlo. */}
<p className="lg-radar__intro">
L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto
dell'asse, dove il dato non c'è ancora.
</p>
<div className="lg-radar__grafico">
<ResponsiveContainer width="100%" height={360}>
<RadarChart data={dati} outerRadius="72%">
<PolarGrid stroke={LINEA} />
<PolarAngleAxis dataKey="nome" tick={{ fill: TESTO_CHIARO, fontSize: 11 }} />
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
{/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */}
<Radar
name="Perimetro completo"
dataKey="potenziale"
stroke={TRATTEGGIO}
strokeDasharray="4 3"
strokeWidth={1.5}
fill="none"
isAnimationActive={false}
/>
<Radar
name="Quello che sappiamo"
dataKey="noto"
stroke={ACCENTO}
fill={ACCENTO}
fillOpacity={0.32}
strokeWidth={2}
isAnimationActive={false}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{incompleti.length > 0 && (
<div className="lg-radar__mancano">
{/* L'invito concreto: l'elenco sotto è nomi di test, da solo non dice cosa fare.
Questi test si fanno in palestra con lo staff, non a casa da soli. */}
<p className="lg-muted">
Questi test si fanno in sala, con il trainer: prenota la prossima sessione per
completare il quadro.
</p>
{incompleti.map((a) => (
<div className="lg-radar__voce" key={a.nome}>
<p className="lg-radar__voce-nome">{a.nome}</p>
<ul className="lg-radar__lista">
{a.mancano.map((m) => <li key={m}>{m}</li>)}
</ul>
</div>
))}
</div>
)}
</div>
);
}
@@ -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>
@@ -0,0 +1,140 @@
// Analytics di studio. È un'isola React perché tutto dipende da due controlli — l'intervallo
// di date e la dimensione di confronto — e ogni numero della pagina si ricalcola con essi.
import { useMemo, useState } from 'react';
import DataChart from './charts/DataChart';
import type { Alert, Client, Measurement } from '../../lib/stress-index/data';
import {
DIMENSIONI, METRICHE, classificaRecupero, confrontoSegmenti, distribuzione, kpis,
type Dimensione,
} from '../../lib/stress-index/analytics';
interface Props {
clients: Client[];
measurementsByClient: Record<string, Measurement[]>;
alerts: Alert[];
/** Intervallo iniziale, deciso dalla pagina. */
from: string;
to: string;
}
const dataItaliana = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
export default function AnalyticsView({ clients, measurementsByClient, alerts, from, to }: Props) {
const [periodo, setPeriodo] = useState({ from, to });
const [dimensione, setDimensione] = useState<Dimensione>('sesso');
const indicatori = useMemo(() => kpis(clients, measurementsByClient, alerts, periodo), [clients, measurementsByClient, alerts, periodo]);
const fasce = useMemo(() => distribuzione(measurementsByClient, periodo), [measurementsByClient, periodo]);
const classifica = useMemo(() => classificaRecupero(clients, measurementsByClient, periodo), [clients, measurementsByClient, periodo]);
const segmenti = useMemo(() => confrontoSegmenti(clients, measurementsByClient, periodo, dimensione), [clients, measurementsByClient, periodo, dimensione]);
const migliori = classifica.slice(0, 4);
const critici = [...classifica].reverse().slice(0, 4);
return (
<div className="si-stack">
<div className="si-head si-head--row">
<div>
<h1>Analytics di studio</h1>
<p>Confronta i tuoi clienti, individua gli andamenti, calibra il lavoro</p>
</div>
<div className="si-range">
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Dal</span>
<input type="date" value={periodo.from} max={periodo.to} onChange={(e) => setPeriodo((p) => ({ ...p, from: e.target.value }))} />
</label>
<span className="si-range__sep" aria-hidden="true"></span>
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Al</span>
<input type="date" value={periodo.to} min={periodo.from} onChange={(e) => setPeriodo((p) => ({ ...p, to: e.target.value }))} />
</label>
</div>
</div>
<div className="si-grid si-grid--kpi">
{indicatori.map((k) => (
<div className="si-card" key={k.label}>
<p className="si-kpi__label">{k.label}</p>
<p className="si-kpi__value">{k.value}</p>
<p className="si-muted si-small">{k.note}</p>
</div>
))}
</div>
<section className="si-card">
<div className="si-card__head">
<div>
<h2 className="si-card__title">Distribuzione score clienti</h2>
<p className="si-muted si-small">Quante misurazioni cadono in ogni fascia di valore</p>
</div>
</div>
<DataChart type="bar" data={fasce} xKey="range" series={METRICHE.map((m) => ({ ...m }))} height={300} />
</section>
<div className="si-grid si-grid--halves">
<section className="si-card">
<div className="si-card__head">
<div>
<h2 className="si-card__title">Andamento migliore</h2>
<p className="si-muted si-small">Recupero medio più alto nel periodo</p>
</div>
</div>
<ul className="si-rank">
{migliori.map((r) => (
<li key={r.clientId}>
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
<span>
<span className="si-rank__value si-rank__value--ok">{r.recovery}</span>
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
</span>
</li>
))}
</ul>
</section>
<section className="si-card">
<div className="si-card__head">
<div>
<h2 className="si-card__title">Clienti critici</h2>
<p className="si-muted si-small">Recupero medio più basso nel periodo</p>
</div>
</div>
<ul className="si-rank">
{critici.map((r) => (
<li key={r.clientId}>
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
<span>
<span className="si-rank__value si-rank__value--bad">{r.recovery}</span>
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
</span>
</li>
))}
</ul>
</section>
</div>
<section className="si-card">
<div className="si-card__head">
<div>
<h2 className="si-card__title">Confronto segmenti</h2>
<p className="si-muted si-small">Valori medi per {dataItaliana(periodo.from)} {dataItaliana(periodo.to)}</p>
</div>
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Dimensione di confronto</span>
<select value={dimensione} onChange={(e) => setDimensione(e.target.value as Dimensione)}>
{DIMENSIONI.map((d) => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
</label>
</div>
<DataChart
type="bar" data={segmenti} xKey="gruppo" height={300}
series={[
{ key: 'stress', label: 'Stress medio', color: '#b05a4e' },
{ key: 'recovery', label: 'Recupero medio', color: '#6f8f6a' },
]}
/>
</section>
</div>
);
}
@@ -0,0 +1,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>
);
}
@@ -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,204 @@
// Modulo Sport: tre viste (dashboard, elenco sessioni con filtri, schede atleta) in un'unica
// isola, perché condividono lo stesso insieme di sessioni e i filtri agiscono su quello.
import { useMemo, useState } from 'react';
import DataChart from './charts/DataChart';
import type { Athlete, Session } from '../../lib/stress-index/data';
import { dataBreve, dataOra, durata, kpiSport, perSettimana, riepilogoAtleti } from '../../lib/stress-index/sport';
interface Props {
sessions: Session[];
athletes: Athlete[];
/** Ultimo giorno coperto dai dati: tutte le finestre temporali partono da qui. */
riferimento: string;
}
const TABS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'sessioni', label: 'Sessioni' },
{ key: 'atleti', label: 'Atleti' },
];
const PERIODI = [
{ key: 'tutto', label: 'Tutto', giorni: 0 },
{ key: '7', label: 'Ultimi 7 giorni', giorni: 7 },
{ key: '30', label: 'Ultimi 30 giorni', giorni: 30 },
{ key: '90', label: 'Ultimi 90 giorni', giorni: 90 },
];
export default function SportView({ sessions, athletes, riferimento }: Props) {
const [tab, setTab] = useState('dashboard');
const [atleta, setAtleta] = useState('tutti');
const [periodo, setPeriodo] = useState('tutto');
const [sport, setSport] = useState('tutti');
const sports = useMemo(
() => [...new Set(sessions.map((s) => s.sport).filter((s): s is string => Boolean(s)))],
[sessions]
);
const filtrate = useMemo(() => {
const giorni = PERIODI.find((p) => p.key === periodo)?.giorni ?? 0;
const limite = new Date(`${riferimento}T00:00:00`);
limite.setDate(limite.getDate() - giorni + 1);
const dal = limite.toISOString().slice(0, 10);
return sessions.filter((s) =>
(atleta === 'tutti' || s.athleteId === atleta) &&
(sport === 'tutti' || s.sport === sport) &&
(!giorni || s.startedAt.slice(0, 10) >= dal)
);
}, [sessions, atleta, periodo, sport, riferimento]);
const kpi = useMemo(() => kpiSport(sessions, riferimento), [sessions, riferimento]);
const settimane = useMemo(() => perSettimana(sessions, 12, riferimento), [sessions, riferimento]);
const atletiRiepilogo = useMemo(() => riepilogoAtleti(sessions, athletes, riferimento), [sessions, athletes, riferimento]);
const nome = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id;
return (
<div className="si-stack">
<div className="si-head">
<h1>Modulo Sport <span className="si-badge si-badge--pro">Pro</span></h1>
<p>Sessioni di allenamento, zone DFA Alpha1, carico e recupero degli atleti</p>
</div>
<div className="si-tabs" role="tablist">
{TABS.map((t) => (
<button key={t.key} type="button" role="tab" className="si-tabs__btn" aria-selected={t.key === tab} onClick={() => setTab(t.key)}>
{t.label}
</button>
))}
</div>
{tab === 'dashboard' && (
<div className="si-stack">
<div className="si-grid si-grid--kpi">
{kpi.map((k) => (
<div className="si-card" key={k.label}>
<p className="si-kpi__label">{k.label}</p>
<p className="si-kpi__value">{k.value}</p>
{k.note && <p className="si-muted si-small">{k.note}</p>}
</div>
))}
</div>
<section className="si-card">
<div className="si-card__head">
<div>
<h2 className="si-card__title">Sessioni per settimana</h2>
<p className="si-muted si-small">Ultime 12 settimane</p>
</div>
</div>
<DataChart type="bar" data={settimane} xKey="settimana" series={[{ key: 'sessioni', label: 'Sessioni', color: '#9c8b70' }]} />
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Ultime sessioni</h2></div>
<div className="si-scroll-x">
<table className="si-table">
<thead>
<tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr>
</thead>
<tbody>
{sessions.slice(0, 10).map((s) => (
<tr key={s.id}>
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
<td className="si-muted">{dataBreve(s.startedAt)}</td>
<td>{s.sport ?? '—'}</td>
<td className="si-table__num">{durata(s.durationSec)}</td>
<td className="si-table__num">{s.trimp}</td>
<td className="si-table__num">{s.hrAvg} bpm</td>
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)}
{tab === 'sessioni' && (
<div className="si-stack">
<section className="si-card">
<div className="si-filters">
<label className="si-field">Atleta
<select value={atleta} onChange={(e) => setAtleta(e.target.value)}>
<option value="tutti">Tutti</option>
{athletes.map((a) => <option key={a.clientId} value={a.clientId}>{a.name}</option>)}
</select>
</label>
<label className="si-field">Periodo
<select value={periodo} onChange={(e) => setPeriodo(e.target.value)}>
{PERIODI.map((p) => <option key={p.key} value={p.key}>{p.label}</option>)}
</select>
</label>
<label className="si-field">Sport
<select value={sport} onChange={(e) => setSport(e.target.value)}>
<option value="tutti">Tutti</option>
{sports.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<p className="si-muted si-small si-filters__count">
{filtrate.length} {filtrate.length === 1 ? 'sessione' : 'sessioni'}
</p>
</div>
</section>
<section className="si-card">
<div className="si-scroll-x">
<table className="si-table">
<thead>
<tr><th>Data e ora</th><th>Atleta</th><th>Sport</th><th>Durata</th><th>HR medio</th><th>HR max</th><th>TRIMP</th><th>DFA A1</th><th>RPE</th></tr>
</thead>
<tbody>
{filtrate.map((s) => (
<tr key={s.id}>
<td className="si-muted">{dataOra(s.startedAt)}</td>
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
<td>{s.sport ?? '—'}</td>
<td className="si-table__num">{durata(s.durationSec)}</td>
<td className="si-table__num">{s.hrAvg}</td>
<td className="si-table__num">{s.hrMax}</td>
<td className="si-table__num">{s.trimp}</td>
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
<td className="si-table__num">{s.rpe ? `${s.rpe}/10` : '—'}</td>
</tr>
))}
</tbody>
</table>
{filtrate.length === 0 && <p className="si-muted si-small si-empty">Nessuna sessione con questi filtri.</p>}
</div>
</section>
</div>
)}
{tab === 'atleti' && (
<div className="si-grid si-grid--halves">
{atletiRiepilogo.map((a) => (
<section className="si-card si-athlete" key={a.clientId}>
<div className="si-card__head">
<div>
<h2 className="si-athlete__name">{a.name}</h2>
<p className="si-muted si-small">{a.sport ?? 'Sport non specificato'}</p>
</div>
<span className={`si-badge si-badge--${a.trend === 'in calo' ? 'bad' : a.trend === 'in crescita' ? 'ok' : 'warn'}`}>
{a.trend}
</span>
</div>
<div className="si-athlete__stats">
<div><p className="si-athlete__num">{a.sessioni30}</p><p className="si-muted si-small">Sessioni 30gg</p></div>
<div><p className="si-athlete__num">{a.trimp7}</p><p className="si-muted si-small">TRIMP 7gg</p></div>
<div><p className="si-athlete__num">{a.ultimoTrimp}</p><p className="si-muted si-small">Ultimo TRIMP</p></div>
</div>
<a className="si-athlete__foot" href={`/piattaforme/stress-index/clienti/${a.clientId}`}>
<span>{a.ultimaSessione ? `Ultima sessione ${dataBreve(a.ultimaSessione)}` : 'Nessuna sessione registrata'}</span>
<span aria-hidden="true"></span>
</a>
</section>
))}
</div>
)}
</div>
);
}
@@ -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,52 @@
// 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' ? (
// Un dato che porta con sé un campo `fill` colora la propria barra: è così che
// l'istogramma delle fasce di score usa un colore per fascia.
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
) : (
<Line
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>
);
}
+39
View File
@@ -0,0 +1,39 @@
// Le sezioni di quest'app, elencate una volta sola: le usa l'indice /piattaforme.
// ⚠️ Ogni voce dichiara i ruoli che possono aprirla, e l'indice filtra su quelli: le regole
// vere restano in lib/auth (RULES), qui c'è solo cosa mostrare a chi. Se le due liste
// divergono si vede subito — un link che porta a un redirect invece che a una pagina.
export interface Piattaforma {
slug: string;
label: string;
href: string;
description: string;
ruoli: 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.',
ruoli: ['admin', 'piattaforme'],
},
{
slug: 'stress-index',
label: 'Stress Index',
href: '/piattaforme/stress-index',
description: 'Monitoraggio HRV di stress e recupero: clienti, andamenti e alert.',
ruoli: ['admin', 'piattaforme'],
},
{
slug: 'longevity',
label: 'Longevity',
href: '/longevity',
description: 'Il fascicolo del cliente: questionario, referto e progressione dei test.',
ruoli: ['admin', 'trainer', 'cliente'],
},
];
export function piattaformePer(ruolo: string): Piattaforma[] {
return piattaforme.filter((p) => ruolo === 'admin' || p.ruoli.includes(ruolo));
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user?: { id: number; username: string; role: string };
}
}
+50
View File
@@ -0,0 +1,50 @@
---
// Guscio minimo delle pagine che non hanno un layout proprio: il login e l'indice delle
// piattaforme. Font, token e palette del sito, senza Header e Footer pubblici — qui dentro
// non c'è niente da indicizzare e non c'è un menu del sito da riprodurre.
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';
interface Props { title: string; user?: { username: string } | null }
const { title, user = null } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>{title} — InsanityLab</title>
<link rel="icon" href="/favicon.svg" />
</head>
<body class="app-body">
<header class="app-bar">
<a class="app-bar__brand" href="/piattaforme">INSANITYLAB</a>
{user && (
<span class="app-bar__right">
<span class="app-bar__user">{user.username}</span>
<a class="app-bar__exit" href="/logout">Esci</a>
</span>
)}
</header>
<main class="app-main">
<slot />
</main>
<style is:global>
.app-body { margin: 0; background: var(--c-bg, #fff); color: var(--c-text, #222); font-family: var(--font-body, system-ui); }
.app-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: .9rem 1.4rem; border-bottom: 1px solid rgba(0,0,0,.08); }
.app-bar__brand { font-family: var(--font-heading, inherit); font-weight: 700; letter-spacing: .18em;
font-size: .82rem; text-decoration: none; color: var(--c-heading, #111); }
.app-bar__right { display: flex; align-items: center; gap: .9rem; font-size: .78rem; }
.app-bar__user { color: var(--c-muted, #777); }
.app-bar__exit { text-decoration: none; color: var(--c-heading, #111); text-transform: uppercase;
letter-spacing: .12em; font-size: .7rem; font-weight: 600; }
.app-main { max-width: 62rem; margin: 0 auto; padding: 2.2rem 1.4rem 4rem; }
</style>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
---
// Layout della sezione riservata Campus. Usa i token e i font del sito, ma
// non ne monta Header e Footer: la navigazione è tutta interna, con un solo
// richiamo alla home.
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 type { CampusNav } from '../lib/campus';
interface Props {
title: string;
nav: CampusNav;
currentArea?: string;
currentLesson?: string;
}
const { title, nav, currentArea, currentLesson } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Biohacking InsanityLab</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="cp">
<input type="checkbox" id="cp-menu" class="cp-menu-toggle" hidden />
<label for="cp-menu" class="cp-menu-btn" aria-label="Apri il menu">☰ Indice</label>
<aside class="cp-side">
<a class="cp-brand" href="/campus">
<span class="eyebrow">InsanityLab</span>
Biohacking
</a>
<a class="cp-home" href="/piattaforme">Piattaforme</a>
<nav class="cp-nav">
{nav.areas.map((area) => (
<div class="cp-area" data-on={area.slug === currentArea ? '' : null}>
<a class="cp-area__link" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>{area.title}</a>
{area.slug === currentArea && (
<ul class="cp-chapters">
{area.chapters.map((ch) => (
<li>
<span class="cp-ch" style={`--c:${ch.color}`}>{ch.num}. {ch.title}</span>
<ul>
{ch.lessons.map((l) => (
<li class={l.slug === currentLesson ? 'on' : undefined}>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-label">{l.label}</span> {l.titleShort}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
))}
</nav>
</aside>
<main class="cp-main"><slot /></main>
<style is:global>
body.cp { display: flex; align-items: flex-start; background: var(--c-bg); }
/* --- sidebar --- */
.cp-side {
width: 300px; flex: 0 0 300px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
background: var(--c-dark); color: #cfc6bd; padding: 28px 22px 50px;
}
.cp-brand { display: block; font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1; }
.cp-brand .eyebrow { display: block; color: var(--c-accent); margin-bottom: 4px; }
.cp-home { display: inline-block; margin: 18px 0 24px; font-family: var(--font-heading); font-size: .7rem;
font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent);
text-decoration: none; border-bottom: 1px solid transparent; }
.cp-home:hover { border-bottom-color: var(--c-accent); }
.cp-nav { border-top: 1px solid rgba(255,255,255,.12); padding-top: 18px; }
.cp-area + .cp-area { margin-top: 14px; }
.cp-area__link { display: block; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none;
padding: 8px 10px; border-left: 3px solid var(--c); }
.cp-area__link:hover, .cp-area[data-on] .cp-area__link { background: rgba(255,255,255,.07); }
.cp-chapters { list-style: none; margin: 8px 0 0; padding: 0; }
.cp-chapters > li + li { margin-top: 12px; }
.cp-ch { display: block; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--c); padding-left: 12px; }
.cp-chapters ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; }
.cp-chapters ul a { display: block; text-decoration: none; color: #b6ada3; font-size: .85rem;
line-height: 1.35; padding: 4px 8px; }
.cp-chapters ul a:hover { color: #fff; background: rgba(255,255,255,.06); }
.cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); }
.cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); }
/* --- contenuto --- */
.cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; }
.cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; }
.cp-main h2 { font-size: clamp(1.3rem, 2.2vw, 1.7rem); margin-top: 2em; padding-bottom: .25em;
border-bottom: 1px solid #e8e4de; text-transform: none; letter-spacing: .02em; }
.cp-main h3 { font-size: 1.12rem; margin-top: 1.7em; color: var(--c-accent-dark); }
.cp-main img { margin: 22px 0; border: 1px solid #e8e4de; }
.cp-main table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: .92rem; }
.cp-main th, .cp-main td { border: 1px solid #e8e4de; padding: 8px 12px; text-align: left; vertical-align: top; }
.cp-main th { background: var(--c-bg-alt); font-family: var(--font-heading); font-weight: 600; }
.cp-main blockquote { margin: 20px 0; padding: 4px 20px; border-left: 3px solid var(--c-accent); color: #6a6a6a; }
.cp-main code { background: var(--c-bg-alt); padding: 1px 5px; font-size: .9em; }
.cp-main li { margin-bottom: .35em; }
/* --- menu mobile (solo CSS) --- */
.cp-menu-btn { display: none; }
@media (max-width: 900px) {
body.cp { display: block; }
.cp-menu-btn { display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
font-family: var(--font-heading); font-size: .75rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; padding: 14px 20px; cursor: pointer; }
.cp-side { display: none; width: auto; flex: none; height: auto; position: static; }
.cp-menu-toggle:checked ~ .cp-side { display: block; }
.cp-main { padding: 32px 20px 70px; }
}
</style>
</body>
</html>
@@ -0,0 +1,47 @@
---
// Layout della piattaforma Longevity. Come Stress Index: token, font e palette del sito,
// senza Header e Footer pubblici — l'area è riservata e non va mai indicizzata.
// A differenza di Stress Index (barra laterale con sette voci, giusta per chi ci lavora
// dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro
// volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno
// strumento di lavoro. Il layout prevede due forme:
// - "referto": una colonna che scorre, senza navigazione (questo task)
// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale)
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/longevity.css';
interface Props {
title: string;
/** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */
crumbs?: string[];
/** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, non ancora implementata). */
variante?: 'referto' | 'gestionale';
}
const { title, crumbs = [], variante = 'referto' } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Longevity InsanityLab</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="lg" data-variante={variante}>
<div class="lg-shell">
<header class="lg-top">
<p class="lg-top__crumbs">
<strong>Longevity</strong>
{crumbs.map((c) => <span> / {c}</span>)}
</p>
</header>
<main class="lg-main"><slot /></main>
</div>
</body>
</html>
@@ -0,0 +1,76 @@
---
// 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 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>
</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>
+151
View File
@@ -0,0 +1,151 @@
import type Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole.
// ⚠️ Sono i ruoli di QUEST'app, non quelli del sito: `user` e `superuser` (blog e contenuti)
// restano di là e qui non significano niente. Un utente del sito non è un utente di qui.
// ⚠️ create-user.mjs duplica questa lista: è JavaScript e non può importare da qui. Se
// cambia questa riga va cambiata anche lì — è già andata fuori sincrono due volte.
export const ROLES = ['piattaforme', 'admin', 'cliente', 'trainer'] as const;
export type Role = typeof ROLES[number];
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12);
}
export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash);
}
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), role);
return Number(res.lastInsertRowid);
}
export function login(db: Database.Database, username: string, password: string): string | null {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| { id: number; password_hash: string } | undefined;
if (!user || !verifyPassword(password, user.password_hash)) return null;
const token = randomBytes(32).toString('hex');
db.prepare(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
).run(token, user.id);
return token;
}
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null {
const row = db.prepare(
`SELECT s.token, s.expires_at, u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined;
if (!row) return null;
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null;
}
return { id: row.id, username: row.username, role: row.role };
}
export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
// qui arrivano solo utenti già autenticati su una di quelle rotte.
const RULES: [RegExp, Role[]][] = [
// Le due piattaforme storiche: il Biohacking Campus (contenuti) e Stress Index.
[/^\/campus(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
// Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer.
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
[/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
];
/**
* Chi può aprire un percorso protetto. L'admin passa sempre; per gli altri decide la prima
* regola che combacia.
*
* ⚠️ Differenza sostanziale dal sito, da cui questa funzione viene: là il default finale era
* `return true` (le rotte non elencate — blog, upload, logout — erano di chiunque fosse
* loggato). Qui **non esiste una rotta di tutti**: ogni sezione è di qualcuno, quindi il
* default si chiude. Una rotta nuova senza la sua regola risulta vietata invece che aperta,
* e lo si scopre provandola, non leggendo un log di accessi.
*/
export function canAccessPath(role: string, pathname: string): boolean {
if (role === 'admin') return true;
for (const [re, roles] of RULES) {
if (re.test(pathname)) return roles.includes(role as Role);
}
return false;
}
export function getUsernameById(db: Database.Database, id: number): string | null {
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
return row?.username ?? null;
}
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
{ id: number; username: string; role: string }[];
}
export function countAdmins(db: Database.Database): number {
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
}
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
{ id: number; username: string; role: string } | undefined;
return row ?? null;
}
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
}
export function deleteUser(db: Database.Database, id: number): void {
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function randomPassword(): string {
return randomBytes(9).toString('base64url'); // ~12 caratteri
}
export function isRole(v: unknown): v is Role {
return (ROLES as readonly unknown[]).includes(v);
}
/**
* Dove atterra chi ha appena fatto il login. ⚠️ `/admin` non esiste in quest'app: il
* fondo della scala è l'indice delle piattaforme, non un pannello.
*/
export function landingFor(role: string): string {
if (role === 'cliente') return '/longevity/io';
if (role === 'trainer') return '/longevity/gestionale';
return '/piattaforme';
}
/**
* Cosa richiede una sessione. Nel sito era un elenco di prefissi protetti dentro un'app
* pubblica; qui il rapporto è rovesciato — **tutta** l'applicazione è riservata, e si
* elencano le poche eccezioni. Una pagina nuova nasce protetta senza che nessuno se ne
* ricordi, che è il verso giusto in cui sbagliare.
*/
export function isProtectedPath(pathname: string): boolean {
const aperte = ['/login', '/logout', '/404', '/favicon.svg'];
return !aperte.includes(pathname);
}
+94
View File
@@ -0,0 +1,94 @@
// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus`
// in campus-content/. I file sono letti a runtime (non a build time) e tenuti
// in cache in memoria: cambiano solo con un nuovo deploy.
import { readFileSync, existsSync } from 'node:fs';
import { join, normalize, resolve } from 'node:path';
export interface CampusLink { slug: string; area: string; title: string }
export interface CampusLesson {
slug: string;
kind: string;
label: string;
titleFull: string;
titleShort: string;
relatore: string;
concetti: string[];
chapter: number;
area: string;
prev: CampusLink | null;
next: CampusLink | null;
}
export interface CampusChapter {
num: number;
title: string;
sub: string;
color: string;
lessons: CampusLesson[];
}
export interface CampusArea {
slug: string;
title: string;
sub: string;
color: string;
chapters: CampusChapter[];
}
export interface CampusNav { areas: CampusArea[]; count: number }
const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content');
let navCache: CampusNav | null = null;
/** Struttura della sezione, o null se i contenuti non sono stati generati. */
export function getNav(): CampusNav | null {
if (navCache) return navCache;
const file = join(CONTENT_DIR, 'nav.json');
if (!existsSync(file)) {
console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`);
return null;
}
navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav;
return navCache;
}
export function findArea(nav: CampusNav, slug: string): CampusArea | null {
return nav.areas.find((a) => a.slug === slug) ?? null;
}
export function findLesson(nav: CampusNav, slug: string): CampusLesson | null {
for (const area of nav.areas)
for (const ch of area.chapters) {
const l = ch.lessons.find((x) => x.slug === slug);
if (l) return l;
}
return null;
}
export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null {
for (const area of nav.areas) {
const ch = area.chapters.find((c) => c.num === lesson.chapter);
if (ch) return ch;
}
return null;
}
/** Corpo HTML di una guida; null se il frammento non esiste. */
export function readLessonHtml(slug: string): string | null {
const file = join(CONTENT_DIR, 'pages', `${slug}.html`);
if (!existsSync(file)) return null;
return readFileSync(file, 'utf8');
}
/**
* Percorso assoluto di un asset dentro campus-content/assets, oppure null se
* il path esce dalla cartella (traversal) o il file non esiste.
*/
export function resolveAsset(relPath: string): string | null {
const base = join(CONTENT_DIR, 'assets');
const full = resolve(base, normalize(relPath));
if (full !== base && !full.startsWith(base + '/')) return null;
return existsSync(full) ? full : null;
}
+47
View File
@@ -0,0 +1,47 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* Il database di quest'app: SOLO utenti e sessioni.
*
* ⚠️ Non è il database del sito. Fino al 03/09/2026 `users` e `sessions` vivevano in
* `insanitylab.db` insieme a contenuti e articoli, ed erano l'unica cosa che sito e aree
* riservate condividevano davvero. Separandoli, le due anagrafiche diventano quello che
* sono sempre state: tre utenti redazionali di là, un utente per ogni cliente del centro
* di qua. Una sessione aperta sul sito qui non vale, ed è voluto.
*
* ⚠️ Qui NON entrano dati clinici: quelli stanno in longevity.db (senza nomi) e in
* identity.db (l'unico file con dei nomi), aperti da src/lib/longevity/db.ts.
*/
const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'cliente'
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
`;
export function createDb(path?: string): Database.Database {
const p = path ?? process.env.DB_PATH ?? 'data/piattaforme.db';
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
let singleton: Database.Database | null = null;
export function getDb(): Database.Database {
if (!singleton) singleton = createDb();
return singleton;
}
@@ -0,0 +1,96 @@
//
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
// La separazione dei due database è la garanzia che chi legge i dati clinici non
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3';
import { createIdentityDb } from './db';
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
// (un controllo testuale file-per-file non lo vedrebbe, ma qui il problema non si pone:
// non esiste un nome da importare). QUESTO è il modo in cui il vincolo si rende vero,
// non solo sorvegliato.
let identitySingleton: Database.Database | null = null;
function getIdentityDb(): Database.Database {
if (!identitySingleton) identitySingleton = createIdentityDb();
return identitySingleton;
}
/**
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
* a una persona nuova: qui non torna mai disponibile.
*/
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
const numero = (code: string) => Number(code.slice(4));
const ultimoIdentity = identity.prepare(
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
).get() as { client_code: string } | undefined;
const ultimoLongevity = longevity.prepare(
`SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1`
).get() as { client_code: string } | undefined;
const max = Math.max(
ultimoIdentity ? numero(ultimoIdentity.client_code) : 0,
ultimoLongevity ? numero(ultimoLongevity.client_code) : 0,
);
return `ISL-${String(max + 1).padStart(4, '0')}`;
}
export function creaCliente(
identity: Database.Database,
longevity: Database.Database,
dati: {
nome: string; cognome: string; sesso: 'M' | 'F';
data_nascita?: string; email?: string; telefono?: string; user_id?: number;
}
): string {
const code = prossimoCodice(identity, longevity);
identity.prepare(
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null);
try {
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
} catch (err) {
// Compensazione: due database distinti, quindi nessuna transazione unica possibile.
// Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza
// corrispettivo nell'altro database.
identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code);
throw err;
}
return code;
}
export function codicePerUtente(identity: Database.Database, userId: number): string | null {
const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as
{ client_code: string } | undefined;
return r?.client_code ?? null;
}
/**
* Il codice cliente a partire dall'utente di sessione del sito: apre lei stessa la
* connessione a identity (getIdentityDb), perché QUESTO è il modulo autorizzato a farlo.
* Un endpoint che deve solo sapere "chi è" chiama questa funzione e non ha mai bisogno
* di aprire identity da sé — riceve una stringa, mai una connessione che potrebbe
* incrociarsi con quella di longevity nello stesso file.
*/
export function codicePerUtenteLoggato(userId: number): string | null {
return codicePerUtente(getIdentityDb(), userId);
}
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita);
const d = new Date(alla);
let eta = d.getFullYear() - n.getFullYear();
const m = d.getMonth() - n.getMonth();
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
return eta;
}
+150
View File
@@ -0,0 +1,150 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore.
* Vale a ogni livello E dentro il questionario: soglia unica, confermata da Nicola il
* 22/08 — due criteri diversi sarebbero due cose da spiegare e da ricordare.
*/
export const COPERTURA_MINIMA = 0.4;
const SCHEMA_LONGEVITY = `
CREATE TABLE IF NOT EXISTS soggetti (
client_code TEXT PRIMARY KEY,
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS registro_test (
test_id TEXT PRIMARY KEY,
etichetta TEXT NOT NULL,
unita TEXT,
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
curva TEXT,
params TEXT,
asse TEXT,
sotto_dominio TEXT,
range_min REAL,
range_max REAL,
attivo_da TEXT NOT NULL,
attivo_a TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS sessioni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
eta_alla_data INTEGER,
operatore TEXT,
note TEXT,
quest_version TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC);
CREATE TABLE IF NOT EXISTS misure (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
valore_num REAL,
valore_txt TEXT,
unita TEXT,
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id);
CREATE TABLE IF NOT EXISTS pesi (
model_version TEXT NOT NULL,
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
contenitore TEXT NOT NULL,
elemento TEXT NOT NULL,
peso REAL NOT NULL,
PRIMARY KEY (model_version, livello, contenitore, elemento)
);
CREATE TABLE IF NOT EXISTS profilo_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER REFERENCES sessioni(id),
campo_id TEXT NOT NULL,
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS score (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
elemento TEXT NOT NULL,
valore REAL,
copertura REAL NOT NULL,
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
quest_version TEXT,
model_version TEXT NOT NULL,
calcolato_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Il tipo Punteggio protegge il codice ma non la tabella: l'interfaccia leggera'
-- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive
-- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e
-- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega.
-- ATTENZIONE al limite, che non e' quello che sembra: lo schema qui sopra gira con
-- CREATE TABLE IF NOT EXISTS, che su un file gia' esistente e' un'operazione a vuoto.
-- Quindi un longevity.db creato prima di questa modifica NON ha questo vincolo, e
-- continuera' a non averlo finche' qualcuno non lo migra a mano.
-- (Non e' un limite di SQLite: la versione imbarcata da better-sqlite3 e' la 3.53,
-- che ALTER TABLE ... ADD CONSTRAINT lo supporta. E' che qui una migrazione non c'e'.
-- Verificato il 22/08: la CLI di sistema e' la 3.37 e non lo supporta, quindi provarlo
-- da riga di comando da' un errore fuorviante.)
-- Oggi nessun longevity.db esiste ancora, quindi il caso e' teorico: lo diventa il
-- giorno del primo deploy, ed e' quello il momento di ricordarselo.
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
);
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
`;
const SCHEMA_IDENTITY = `
CREATE TABLE IF NOT EXISTS clienti (
client_code TEXT PRIMARY KEY,
user_id INTEGER,
nome TEXT NOT NULL,
cognome TEXT NOT NULL,
data_nascita TEXT,
email TEXT,
telefono TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id);
`;
function apri(p: string, schema: string): Database.Database {
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schema);
return db;
}
export function createLongevityDb(path?: string): Database.Database {
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
}
export function createIdentityDb(path?: string): Database.Database {
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
}
// Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
// ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton
// analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico
// modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione
// che passa da un re-export su piu' file, quindi la connessione condivisa a identity non
// deve essere raggiungibile da fuori quel modulo, punto.
let longevitySingleton: Database.Database | null = null;
export function getLongevityDb(): Database.Database {
if (!longevitySingleton) longevitySingleton = createLongevityDb();
return longevitySingleton;
}
@@ -0,0 +1,36 @@
//
// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene
// nomi: non serve nessuna funzione di anonimizzazione, e non c'è niente da ricordarsi.
// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi.
import type Database from 'better-sqlite3';
const INTESTAZIONI = [
'client_code', 'sesso', 'eta', 'data', 'tipo_sessione',
'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range',
];
export function esportaMisure(db: Database.Database): {
intestazioni: string[];
righe: (string | number | null)[][];
} {
const righe = db.prepare(
`SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version,
m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range
FROM misure m
JOIN sessioni s ON s.id = m.sessione_id
JOIN soggetti sg ON sg.client_code = m.client_code
ORDER BY s.data, m.id`
).raw().all() as (string | number | null)[][];
return { intestazioni: INTESTAZIONI, righe };
}
export function esportaCsv(db: Database.Database): string {
const { intestazioni, righe } = esportaMisure(db);
const cella = (v: string | number | null) => {
if (v === null) return '';
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n';
}
@@ -0,0 +1,11 @@
// Formattazione dei numeri del referto. Un solo posto, perché prima ognuno stampava il
// numero grezzo di JavaScript (punto, non virgola) direttamente nel template — e un
// referto italiano con "72.5" al posto di "72,5" si legge come un difetto, non come un
// dettaglio.
/** Un punteggio o una Fitness Age, formattati con la virgola decimale italiana.
* Le ETICHETTE dei punteggi (PERFORMANCE/ENERGY/RECOVERY, nomi di prodotto) non si
* toccano: solo i NUMERI si leggono all'italiana in un referto italiano. */
export function formattaNumero(n: number): string {
return n.toLocaleString('it-IT');
}
@@ -0,0 +1,74 @@
import type Database from 'better-sqlite3';
import { esisteTest } from './registro';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
export type MisuraIn = {
test_id: string;
valore_num?: number;
valore_txt?: string;
unita?: string;
};
export function apriSessione(
db: Database.Database,
s: {
client_code: string; data: string; tipo: 'checkup' | 'questionario';
eta_alla_data?: number; operatore?: string; quest_version?: string;
}
): number {
const r = db.prepare(
`INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version)
VALUES (?, ?, ?, ?, ?, ?)`
).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null);
return Number(r.lastInsertRowid);
}
/**
* L'unico punto in cui si scrive nella tabella `misure`.
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
* sessione scritta a metà. Un valore fuori dal range atteso invece si scrive e si
* marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe.
*/
export function registraMisure(
db: Database.Database,
sessioneId: number,
fonte: Fonte,
misure: MisuraIn[]
): { scritte: number; fuoriRange: string[] } {
const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sess) throw new Error(`sessione ${sessioneId} inesistente`);
for (const m of misure) {
if (!esisteTest(db, m.test_id)) {
throw new Error(`test_id non nel registro: ${m.test_id}`);
}
}
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
const ins = db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
const fuoriRange: string[] = [];
const tx = db.transaction(() => {
for (const m of misure) {
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null };
let fuori = 0;
if (m.valore_num !== undefined) {
if ((r.range_min !== null && m.valore_num < r.range_min) ||
(r.range_max !== null && m.valore_num > r.range_max)) {
fuori = 1;
fuoriRange.push(m.test_id);
}
}
ins.run(sessioneId, sess.client_code, m.test_id,
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori);
}
});
tx();
return { scritte: misure.length, fuoriRange };
}
@@ -0,0 +1,140 @@
/**
* La cascata a quattro livelli: dalle sotto-metriche normalizzate ai
* punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score
* (Performance/Energy/Recovery), fino alla Fitness Age.
*
* Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e
* `compute_fitness_age` dall'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4).
* Regola di copertura, uguale a ogni livello: se un elemento manca, il suo
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
*
* DUE DIVERGENZE VOLUTE dall'oracolo:
*
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
* dichiara insufficiente (la docstring dice il contrario di cio' che il
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
*
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
* del cliente su un profilo parziale concludera' che il porting e' rotto:
* non lo e', diverge di proposito qui.
*
* Tutto il resto e' fedele all'oracolo.
*/
import { COPERTURA_MINIMA } from '../db';
import { arrotonda1 } from './curve';
/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */
export type Punteggio =
| { stato: 'ok'; valore: number; copertura: number }
| { stato: 'insufficiente'; copertura: number };
/** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */
export type VocePesata = { punteggio: number | null; peso: number };
/**
* Media pesata delle voci disponibili, con i pesi delle voci mancanti
* ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la
* differenza di tipo descritta sopra.
*
* ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia
* usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella
* arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve
* solo al valore esposto nel campo `copertura` del risultato. Confrontare la
* copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura
* grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il
* numero pieno fasullo che il tipo `Punteggio` esiste per impedire.
*/
export function aggrega(voci: VocePesata[]): Punteggio {
const pesoTotale = voci.reduce((s, v) => s + v.peso, 0);
const disponibili = voci.filter((v) => v.punteggio !== null);
const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0);
const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0;
const copertura = arrotonda1(coperturaGrezza, 2);
if (disponibili.length === 0) {
return { stato: 'insufficiente', copertura: 0 };
}
if (coperturaGrezza < COPERTURA_MINIMA) {
return { stato: 'insufficiente', copertura };
}
const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0);
const valore = arrotonda1(sommaPesata / pesoDisponibile);
return { stato: 'ok', valore, copertura };
}
/**
* Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis`
* nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse,
* `punteggi` i valori 0-100 disponibili (assenti = mancanti).
*/
export function calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio {
const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: punteggi[chiave] ?? null,
peso,
}));
return aggrega(voci);
}
/**
* Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery).
* Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra
* col suo valore (che qui non esiste nemmeno), entra come mancante e il suo
* peso si ridistribuisce.
*/
export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio {
const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => {
const p = assi[asse];
return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso };
});
return aggrega(voci);
}
/**
* Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
* insufficiente non c'e' Fitness Age da mostrare.
*
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
* solo questo ultimo livello si ferma.
*/
export function calcolaFitnessAge(
etaAnagrafica: number | null,
pesi: Record<string, number>,
voci: Record<string, number | null>
): { fitnessAge: number | null; composito: Punteggio } {
const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: voci[chiave] ?? null,
peso,
}));
const composito = aggrega(vociPesate);
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
return { fitnessAge: null, composito };
}
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
return { fitnessAge, composito };
}
@@ -0,0 +1,178 @@
/**
* Curve di normalizzazione del questionario Longevity: portano una risposta
* grezza a un punteggio 0-100.
*
* `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau`
* e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`,
* `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e
* `score_direct_x10` dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario:
* curve generiche»). Non toccare quella logica: è verificata riga per riga
* contro `riferimento.json`.
*
* `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python:
* vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`)
* e non hanno un oracolo di riferimento — sono coperte solo dai test scritti
* a mano in `tests/longevity/motore-curve.test.ts`.
*/
/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */
export function clamp(x: number, lo = 0, hi = 100): number {
return Math.max(lo, Math.min(hi, x));
}
/**
* Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a
* [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1].
* Come `_lerp` nell'oracolo.
*/
export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number {
if (x1 === x0) return y0;
let t = (x - x0) / (x1 - x0);
t = Math.max(0, Math.min(1, t));
return y0 + t * (y1 - y0);
}
/**
* Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)`
* in Python: sui pareggi ESATTI (il valore binario del double è esattamente a
* metà, es. 86.25 per un decimale) arrotonda al pari (banker's rounding), non
* sempre in su come farebbe `Math.round`. Alcune curve a gradini producono
* pareggi veri — `lerp` che restituisce 86.25 esatto va a 86.2, non 86.3.
*
* Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un
* valore come 86.35 NON è un pareggio vero a un decimale (il double che gli
* sta dietro è 86.34999999999999431..., quindi Python arrotonda a 86.3, non
* a metà), ma `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 —
* un pareggio fasullo creato dalla moltiplicazione, non presente nel valore
* originale. Una prima versione con tolleranza ci cadeva (dava 86.4). Per
* evitarlo si scompone il double nella sua mantissa ed esponente esatti (bit
* IEEE 754, via `DataView`) e si confrontano `ax*10^decimali` e il pareggio
* con aritmetica razionale su `BigInt`, senza mai passare per una
* moltiplicazione in virgola mobile che potrebbe introdurre o cancellare un
* pareggio.
*
* Unica funzione di arrotondamento del motore: la usano `curve.ts`,
* `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due
* decimali, la copertura), per non avere due implementazioni che sui pareggi
* potrebbero divergere fra loro.
*/
export function arrotonda1(x: number, decimali = 1): number {
if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x;
const negativo = x < 0;
const ax = Math.abs(x);
const potenza = 10n ** BigInt(decimali);
// Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente.
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, ax);
const hi = view.getUint32(0);
const lo = view.getUint32(4);
const expBits = (hi >>> 20) & 0x7ff;
let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
let exponent: number;
if (expBits === 0) {
exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque
} else {
mantissa |= 1n << 52n; // bit implicito
exponent = expBits - 1075;
}
// ax*10^decimali = num/den, con num e den interi esatti: nessuna
// moltiplicazione in virgola mobile, quindi nessun pareggio fasullo
// introdotto qui.
let num: bigint;
let den: bigint;
if (exponent >= 0) {
num = mantissa * (potenza << BigInt(exponent));
den = 1n;
} else {
num = mantissa * potenza;
den = 1n << BigInt(-exponent);
}
const n = num / den; // floor(ax*10^decimali), esatto
const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto
let risultato: bigint;
if (resto2 < den) risultato = n;
else if (resto2 > den) risultato = n + 1n;
else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari
const valore = Number(risultato) / Number(potenza);
return negativo ? -valore : valore;
}
/**
* Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce
* verso low/high ai lati. Come `score_bell_curve` nell'oracolo.
*/
export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number {
if (v >= peakLow && v <= peakHigh) return 100;
if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100)));
return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10)));
}
/**
* Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing`
* nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale
* sopra 100 fuori da [best, worst].
*/
export function curvaDecrescente(v: number, best: number, worst: number): number {
return clamp(arrotonda1(lerp(v, best, worst, 100, 0)));
}
/**
* Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi.
* Come `score_increasing_plateau` nell'oracolo.
*/
export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number {
return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100)));
}
/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */
export function curvaDirettaX10(v: number): number {
return clamp(arrotonda1(v * 10));
}
/**
* Complemento della diretta: valore 0-10, punteggio decrescente. Assente
* nell'oracolo Python (è la quinta curva, quella che al motore del cliente
* manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`,
* cioè (10 - v) * 10.
*/
export function curvaDirettaX10Invertita(v: number): number {
return clamp(arrotonda1((10 - v) * 10));
}
/**
* Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per
* valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML
* del cliente (assente nell'oracolo Python).
*
* Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a
* tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le
* curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo
* gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che
* funge da "infinito").
*/
export function curvaGradini(
v: number,
steps: [number, number][],
zeroVal: number | undefined,
decrescente: boolean
): number {
if (v === 0 && zeroVal !== undefined) return zeroVal;
const ancoraggio: [number, number] = [0, decrescente ? 100 : 20];
const punti: [number, number][] = [ancoraggio, ...steps];
for (let i = 0; i < punti.length - 1; i++) {
const [x0, y0] = punti[i];
const [x1, y1] = punti[i + 1];
if (v <= x1) {
return clamp(arrotonda1(lerp(v, x0, x1, y0, y1)));
}
}
// v oltre l'ultimo gradino: resta sull'ultimo valore.
return punti[punti.length - 1][1];
}
@@ -0,0 +1,307 @@
/**
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
* test, o cambiare un parametro, resta una modifica ai dati.
*
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
* calcolo.
*
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`,
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette
* assi, i tre macro-score e la Fitness Age.
*
* `salvaScore` congela il risultato nella tabella `score`, una riga per
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
* a mano su una colonna nullable.
*
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
* forma negativa; `applicaCurva` valida i parametri della curva prima di
* applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
* (punto aperto in §12).
*/
import type Database from 'better-sqlite3';
import {
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
} from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = {
low?: number; peakLow?: number; peakHigh?: number; high?: number;
best?: number; worst?: number; plateauStart?: number;
steps?: [number, number][]; zeroVal?: number;
};
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
function numeroValido(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x);
}
/**
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
* punteggio pieno, la peggior direzione di guasto possibile per un motore
* clinico.
*/
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
switch (curva) {
case 'bell':
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
case 'lin_dec':
return numeroValido(p.best) && numeroValido(p.worst);
case 'inc_plateau':
return numeroValido(p.worst) && numeroValido(p.plateauStart);
case 'x10':
case 'x10_inv':
return true; // nessun parametro richiesto
case 'decstep':
case 'incstep':
return Array.isArray(p.steps) && p.steps.length > 0 &&
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
default:
return false;
}
}
/**
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
*/
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
if (!voce.curva) return null;
const p = (voce.params ?? {}) as ParamsCurva;
if (!paramsValidi(voce.curva, p)) return null;
switch (voce.curva) {
case 'bell':
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
case 'lin_dec':
return curvaDecrescente(valore, p.best as number, p.worst as number);
case 'inc_plateau':
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
case 'x10':
return curvaDirettaX10(valore);
case 'x10_inv':
return curvaDirettaX10Invertita(valore);
case 'decstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
case 'incstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
default:
return null;
}
}
/**
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age.
* `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
* e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la
* tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il
* composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che
* altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'.
*/
export type RisultatoSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
fitnessAge: number | null;
fitnessAgeCopertura: number;
modelVersion: string;
questVersion: string | null;
};
/**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
*/
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare(
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const attivi = testAttivi(db, sessione.data);
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
// passare le misure confermate, non tolto del tutto.
const misurate = new Map<string, number>(
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as
{ test_id: string; valore_num: number | null }[])
.filter((m) => m.valore_num !== null)
.map((m) => [m.test_id, m.valore_num as number])
);
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
const perSottoDominio = new Map<string, number[]>();
for (const voce of attivi) {
if (!voce.sotto_dominio) continue;
const grezzo = misurate.get(voce.test_id);
if (grezzo === undefined) continue;
const punteggio = applicaCurva(voce, grezzo);
if (punteggio === null) continue;
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
lista.push(punteggio);
perSottoDominio.set(voce.sotto_dominio, lista);
}
const sottoDominio: Record<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
const nomiAssi = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const assi: Record<string, Punteggio> = {};
for (const nomeAsse of nomiAssi) {
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
}
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
const nomiMacro = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const macro: Record<string, Punteggio> = {};
for (const nomeMacro of nomiMacro) {
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
}
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
// come nell'oracolo, che guarda quei due sotto-domini da soli.
const valoreAsse = (nome: string): number | null => {
const a = assi[nome];
return a && a.stato === 'ok' ? a.valore : null;
};
const vociFitnessAge: Record<string, number | null> = {
cardio: valoreAsse('Cardio-Respiratorio'),
handgrip_isolato: sottoDominio.handgrip ?? null,
hrv_isolato: sottoDominio.hrv ?? null,
forza_resto: valoreAsse('Forza & Struttura'),
composizione: valoreAsse('Composizione Corporea'),
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
};
const { fitnessAge, composito } = calcolaFitnessAge(
sessione.eta_alla_data,
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
vociFitnessAge
);
return {
assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura,
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
};
}
/**
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
*/
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const ins = db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
ins.run(
sessione.client_code, sessioneId, tipo, elemento,
p.stato === 'ok' ? p.valore : null,
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
);
};
const tx = db.transaction(() => {
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null
? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura }
: { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura };
riga('fitness_age', 'fitness_age', fitnessAgePunteggio);
});
tx();
}
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
export type PunteggiSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
fitnessAge: Punteggio | null;
};
/**
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
*
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
* che non è un criterio).
*/
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
const righe = db.prepare(
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
FROM score s
JOIN (
SELECT tipo, elemento, MAX(id) AS ultimo_id
FROM score
WHERE sessione_id = ?
GROUP BY tipo, elemento
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
WHERE s.sessione_id = ?`
).all(sessioneId, sessioneId) as
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
const assi: Record<string, Punteggio> = {};
const macro: Record<string, Punteggio> = {};
let fitnessAge: Punteggio | null = null;
for (const r of righe) {
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
else fitnessAge = aPunteggio(r);
}
return { assi, macro, fitnessAge };
}
@@ -0,0 +1,281 @@
/**
* Curve dei test fisici: da misura grezza (kg al dinamometro, secondi di
* plank, VO2max stimato...) a punteggio 0-100.
*
* Porting letterale delle funzioni dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni «Composizione
* Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza &
* Struttura», «Stabilità & Mobilità»). Coefficienti, soglie e tabelle sono
* copiati tali e quali e verificati contro `riferimento.json` in
* `tests/longevity/motore-fisici.test.ts`. Non "aggiustare" nulla qui dentro:
* l'oracolo è la definizione, non una proposta.
*
* `score_agility_ms` e `score_generic_range_local` NON sono portate: la prima
* perché il cliente ha rimosso l'agilità dallo score il 13/08 (scale non
* comparabili fra protocolli), la seconda perché non è mai chiamata da
* nessun peso.
*
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts`
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`,
* senza modo di passare età e sesso, che quasi tutte queste funzioni
* richiedono. Agganciarle è lavoro del piano degli import, non di questa
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far
* arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai
* dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve
* anche codice nuovo nel motore, non solo righe nuove in `registro_test`.
*/
import { clamp, lerp, arrotonda1 } from './curve';
export type Sesso = 'M' | 'F';
/** Come `sex.strip().lower().startswith("m")` nell'oracolo. */
function eMaschio(sesso: Sesso): boolean {
return sesso.trim().toLowerCase().startsWith('m');
}
// --- Composizione Corporea -------------------------------------------------
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */
export function scoreGrassoPercento(fatPct: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const bands: [number, number, number][] = m
? [[0, 2, 40], [2, 5, 65], [5, 13, 100], [13, 17, 90], [17, 24, 65], [24, 35, 30], [35, 200, 10]]
: [[0, 10, 40], [10, 13, 65], [13, 20, 100], [20, 24, 90], [24, 31, 65], [31, 40, 30], [40, 200, 10]];
for (const [lo, hi, val] of bands) {
if (lo <= fatPct && fatPct < hi) return val;
}
return 50;
}
/** Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device). Come `score_muscle_percent`. */
export function scoreMuscoloPercento(musclePct: number, sesso: Sesso): number {
const [lo, hi] = eMaschio(sesso) ? [42.9, 52.4] : [37.8, 46.2];
if (musclePct >= hi) return 100;
if (musclePct <= lo - 10) return 20;
return arrotonda1(lerp(musclePct, lo - 10, hi, 20, 100));
}
/** Come `score_whr`. */
export function scoreWhr(whr: number, sesso: Sesso): number {
const threshold = eMaschio(sesso) ? 0.9 : 0.85;
if (whr <= threshold - 0.1) return 100;
if (whr <= threshold) return arrotonda1(lerp(whr, threshold - 0.1, threshold, 100, 60));
return clamp(arrotonda1(60 - (whr - threshold) * 300));
}
// --- Cardio-Respiratorio ----------------------------------------------------
/** Queen's College Step Test (McArdle et al. 1972). Come `vo2max_from_step_test`. */
export function vo2maxDaStepTest(hrRecovery: number, sesso: Sesso): number {
if (eMaschio(sesso)) return 111.33 - 0.42 * hrRecovery;
return 65.81 - 0.1847 * hrRecovery;
}
/** UKK 2km Walking Test (Laukkanen/Oja). Come `vo2max_from_2km_walk`. */
export function vo2maxDa2kmWalk(tempoMin: number, hr: number, eta: number, bmi: number): number {
return 116.2 - 2.98 * tempoMin - 0.11 * hr - 0.14 * eta - 0.39 * bmi;
}
/** Formula ACSM su VAM raggiunta al tapis. Teorico/stimato. Come `vo2max_from_mutt`. */
export function vo2maxDaMutt(vamMMin: number): number {
return 0.2 * vamMMin + 3.5;
}
/** Formula ACSM cicloergometro. Teorico/stimato, valida 50-200W. Come `vo2max_from_milfit`. */
export function vo2maxDaMilfit(watt: number, pesoKg: number): number {
return (10.8 * watt) / pesoKg + 7;
}
/** ACSM/FRIEND Registry, bande approssimate per fascia d'età e sesso. Come `score_vo2max`. */
export function scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
const goodThresholdsM: Record<number, number> = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 };
const goodThresholdsF: Record<number, number> = { 20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23 };
const table = m ? goodThresholdsM : goodThresholdsF;
const decade = Math.min(70, Math.max(20, Math.floor(eta / 10) * 10));
const good = table[decade];
const poor = good * 0.65;
const superior = good * 1.35;
if (vo2max <= poor) return clamp(arrotonda1(lerp(vo2max, 0, poor, 10, 40)));
if (vo2max <= good) return arrotonda1(lerp(vo2max, poor, good, 40, 70));
return clamp(arrotonda1(lerp(vo2max, good, superior, 70, 100)));
}
/** Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico. Come `score_spo2`. */
export function scoreSpo2(spo2Pct: number): number {
if (spo2Pct >= 97) return 100;
if (spo2Pct >= 95) return 85;
if (spo2Pct >= 90) return 50;
return 15;
}
// --- Recupero & Sistema Nervoso --------------------------------------------
/** ESC/ESH 2018. Vale la categoria più alta tra sistolica e diastolica. Come `score_blood_pressure`. */
export function scorePressione(sistolica: number, diastolica: number): number {
if (sistolica >= 180 || diastolica >= 110) return 5;
if (sistolica >= 160 || diastolica >= 100) return 25;
if (sistolica >= 140 || diastolica >= 90) return 45;
if (sistolica >= 130 || diastolica >= 85) return 70;
if (sistolica >= 120 || diastolica >= 80) return 90;
return 100;
}
/** Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999). Come `score_hrr`. */
export function scoreRecuperoCardiaco(caloBpm1min: number): number {
if (caloBpm1min >= 12) return clamp(arrotonda1(lerp(caloBpm1min, 12, 30, 70, 100)));
return clamp(arrotonda1(lerp(caloBpm1min, 0, 12, 20, 70)));
}
// --- Forza & Struttura -------------------------------------------------------
/**
* Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'età.
* Come `score_handgrip`.
*
* L'oracolo usa un `for...else` di Python: se il ciclo trova l'intervallo fra
* due ancore, fa `break` con `mean_val` calcolato; se lo esaurisce senza mai
* entrare nel ramo, l'`else` del `for` scatta e vale l'ultima ancora. In
* TypeScript non esiste il for-else: lo si riproduce con un flag.
*/
export function scoreHandgrip(kg: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// ancore (età, valore medio kg) approssimate dalla letteratura citata
const anchorsM: [number, number][] = [[25, 49.7], [40, 46], [60, 38], [75, 30]];
const anchorsF: [number, number][] = [[25, 30], [40, 28], [60, 24], [75, 18.7]];
const anchors = m ? anchorsM : anchorsF;
const etaC = clamp(eta, 25, 75);
let meanVal: number | undefined;
for (let i = 0; i < anchors.length - 1; i++) {
const [aEta, aVal] = anchors[i];
const [bEta, bVal] = anchors[i + 1];
if (aEta <= etaC && etaC <= bEta) {
meanVal = lerp(etaC, aEta, bEta, aVal, bVal);
break;
}
}
if (meanVal === undefined) meanVal = anchors[anchors.length - 1][1];
// media = punteggio 70; +/- 40% della media copre la banda 20-100
const ratio = meanVal ? kg / meanVal : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.5, 20, 100)));
}
/** ACSM/CSEP, bande approssimate per decade. Come `score_pushup`. */
export function scorePushup(reps: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const baseGoodM = 22;
const baseGoodF = 15; // 35-39 anni, 'buono' minimo
const decadeOffset = Math.max(0, Math.floor((eta - 35) / 10)) * 2.5; // calo ~2.5 rip/decade dopo i 35
const good = (m ? baseGoodM : baseGoodF) - decadeOffset;
const superior = good * 1.6;
const poor = good * 0.5;
if (reps <= poor) return clamp(arrotonda1(lerp(reps, 0, poor, 10, 40)));
if (reps <= good) return arrotonda1(lerp(reps, poor, good, 40, 70));
return clamp(arrotonda1(lerp(reps, good, superior, 70, 100)));
}
/** Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite). Come `score_bw_ratio_lift`. */
export function scoreSollevamentoSuPeso(
caricoKg: number,
pesoKg: number,
rip: number,
tiersM: [number, number][],
tiersF: [number, number][],
sesso: Sesso
): number {
const rm1 = (caricoKg * 36) / (37 - rip); // Brzycki
const ratio = rm1 / pesoKg;
const tiers = eMaschio(sesso) ? tiersM : tiersF;
// tiers = [(soglia_ratio, punteggio), ...] crescente
let prevR = 0;
let prevS = 10;
for (const [r, s] of tiers) {
if (ratio <= r) return arrotonda1(lerp(ratio, prevR, r, prevS, s));
prevR = r;
prevS = s;
}
return 100;
}
export const TIERS_BENCH_M: [number, number][] = [[0.5, 30], [1.0, 60], [1.25, 80], [1.5, 100]];
export const TIERS_BENCH_F: [number, number][] = [[0.3, 30], [0.6, 60], [0.75, 80], [1.0, 100]];
export const TIERS_SQUAT_M: [number, number][] = [[0.75, 30], [1.5, 60], [1.75, 80], [2.0, 100]];
export const TIERS_SQUAT_F: [number, number][] = [[0.5, 30], [1.1, 60], [1.3, 80], [1.5, 100]];
export const TIERS_ROW_M: [number, number][] = [[0.45, 30], [0.70, 60], [0.95, 80], [1.20, 100]];
export const TIERS_ROW_F: [number, number][] = [[0.30, 30], [0.45, 60], [0.60, 80], [0.80, 100]];
/** Come `score_flexed_arm_hang`. */
export function scoreTrazioneIsometrica(sec: number, sesso: Sesso): number {
const [good, superior] = eMaschio(sesso) ? [45, 70] : [25, 50];
if (sec <= good * 0.4) return clamp(arrotonda1(lerp(sec, 0, good * 0.4, 10, 40)));
if (sec <= good) return arrotonda1(lerp(sec, good * 0.4, good, 40, 70));
return clamp(arrotonda1(lerp(sec, good, superior, 70, 100)));
}
/** Reference equation adulti 18-95 (Zalewski et al.-style). Come `score_sit_to_stand_1min`. */
export function scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi = 24): number {
const sexCode = eMaschio(sesso) ? 0 : 1;
const predicted = 61.53 - 0.34 * eta - 3.57 * sexCode - 0.33 * bmi;
const ratio = predicted ? reps / predicted : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.3, 20, 100)));
}
/** Come `score_plank`. */
export function scorePlank(sec: number, sesso: Sesso): number {
const bandsM: [number, number][] = [[79, 20], [97, 40], [122, 55], [157, 75], [201, 90]];
const bandsF: [number, number][] = [[35, 20], [63, 40], [84, 55], [108, 75], [142, 90]];
const bands = eMaschio(sesso) ? bandsM : bandsF;
let prevT = 0;
let prevS = 10;
for (const [t, s] of bands) {
if (sec <= t) return arrotonda1(lerp(sec, prevT, t, prevS, s));
prevT = t;
prevS = s;
}
return 100;
}
// --- Stabilità & Mobilità ----------------------------------------------------
/**
* Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
* Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato
* solido), estrapolato linearmente (~4cm/5 anni) per le altre età.
* Approssimazione da dichiarare, non tabella completa. Come `score_back_scratch`.
*/
export function scoreBackScratch(cm: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const anchorEta = 62;
const anchorCm = m ? -8.6 : -1.8;
const declinePerYear = 0.8; // cm peggioramento per anno di età in più
const expected = anchorCm - (eta - anchorEta) * declinePerYear;
// punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
const diff = cm - expected;
return clamp(arrotonda1(lerp(diff, -15, 15, 20, 100)));
}
/** Wellness Tower, riferimento 180 gradi (outreach/buckling). Come `score_shoulder_mobility_wt`. */
export function scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number {
const avg = (outreachDeg + bucklingDeg) / 2;
return clamp(arrotonda1((avg / 180) * 100));
}
/** Decrescente: meno cadute = meglio. Come `score_flamingo`. */
export function scoreFlamingo(cadute: number): number {
if (cadute <= 3) return 100;
if (cadute <= 7) return arrotonda1(lerp(cadute, 3, 7, 100, 80));
if (cadute <= 15) return arrotonda1(lerp(cadute, 7, 15, 80, 50));
return clamp(arrotonda1(lerp(cadute, 15, 30, 50, 10)));
}
/** Come `score_sit_and_reach`. */
export function scoreSitAndReach(cm: number, sesso: Sesso): number {
const median = eMaschio(sesso) ? 24 : 31;
return clamp(arrotonda1(lerp(cm, median - 20, median + 10, 20, 100)));
}
@@ -0,0 +1,64 @@
import type Database from 'better-sqlite3';
import { apriSessione, registraMisure } from './misure';
import { esisteTest } from './registro';
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
export const QUEST_VERSION = 'v1.0';
/** Versione del TESTO del consenso al trattamento dei dati sanitari — distinta da
* QUEST_VERSION apposta: se cambia la formulazione del consenso senza che cambi una
* domanda del questionario, il record del consenso deve poterlo dire. Prima di questa
* costante il record portava QUEST_VERSION, la versione sbagliata. */
export const CONSENT_VERSION = 'v1.0';
/**
* Campi testuali che si salvano ma non entrano MAI in una formula
* (indicazione esplicita del cliente, punto 7 della specifica).
*/
export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi'];
export function salvaCompilazione(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
liberi?: Record<string, string>;
}
): number {
// Validare PRIMA della transazione: se un test_id è sconosciuto, non apriamo
// nemmeno una sessione. La validazione non è nel corpo della transazione.
for (const id of Object.keys(input.risposte)) {
if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`);
}
// Avvolgi le tre scritture (apertura sessione, registra misure, inserisci note)
// in un'unica transazione: tutto o niente, senza tracce parziali su dati sanitari.
const tx = db.transaction(() => {
const sessioneId = apriSessione(db, {
client_code: input.client_code,
data: input.data,
tipo: 'questionario',
eta_alla_data: input.eta,
quest_version: QUEST_VERSION,
});
registraMisure(
db, sessioneId, 'questionario',
Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num }))
);
if (input.liberi) {
const ins = db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
);
for (const [campo, testo] of Object.entries(input.liberi)) {
if (testo.trim() === '') continue;
ins.run(input.client_code, sessioneId, campo, testo);
}
}
return sessioneId;
});
return tx();
}
@@ -0,0 +1,199 @@
import type Database from 'better-sqlite3';
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
export const MODEL_VERSION = 'v1.0';
export type VoceRegistro = {
test_id: string;
etichetta: string;
unita?: string;
tipo_valore: 'num' | 'txt';
curva?: Curva;
params?: unknown;
asse?: string;
sotto_dominio?: string;
range_min?: number;
range_max?: number;
attivo_da: string;
attivo_a?: string;
};
const DA = '2026-01-01';
// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html).
// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma
// dichiarata per ciascuna curva, non nell'array posizionale del prototipo.
const QUESTIONARIO: VoceRegistro[] = [
{ test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num',
curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA },
{ test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio',
tipo_valore: 'num', curva: 'x10_inv', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali',
tipo_valore: 'num', curva: 'x10', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA },
{ test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA },
// Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO),
// confermata il 21/08. Non e' un refuso per 0=100 -> 7=0.
{ test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 7, worst: 14 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA },
];
export function seedRegistro(db: Database.Database): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO registro_test
(test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio,
range_min, range_max, attivo_da, attivo_a, note)
VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio,
@range_min, @range_max, @attivo_da, @attivo_a, @note)`
);
const tx = db.transaction((voci: VoceRegistro[]) => {
for (const v of voci) {
ins.run({
test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null,
tipo_valore: v.tipo_valore, curva: v.curva ?? null,
params: v.params === undefined ? null : JSON.stringify(v.params),
asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null,
range_min: v.range_min ?? null, range_max: v.range_max ?? null,
attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null,
});
}
});
tx(QUESTIONARIO);
}
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono
// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge.
// ⚠️ Il plank compare volutamente in DUE assi: 'core' in Forza & Struttura (0.15) e
// 'plank' in Stabilità & Mobilità (0.20). Confermato da Nicola il 22/08 e motivato:
// il plank non è forza pura, richiede controllo neuromuscolare del tronco, quindi
// contribuisce davvero a entrambi i domini che misura. Non è un refuso da correggere.
const PESI_ASSE: Record<string, Record<string, number>> = {
'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 },
'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 },
'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 },
'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 },
'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 },
'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 },
'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 },
};
const PESI_MACRO: Record<string, Record<string, number>> = {
PERFORMANCE: {
'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15,
'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10,
'Energia & Regolazione Stress': 0.05,
},
ENERGY: {
'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20,
'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10,
},
RECOVERY: {
'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15,
'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05,
},
};
const PESI_FITNESS_AGE: Record<string, number> = {
cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20,
forza_resto: 0.15, composizione: 0.10, stabilita: 0.05,
};
export function seedPesi(db: Database.Database, modelVersion: string): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso)
VALUES (?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
for (const [asse, sd] of Object.entries(PESI_ASSE))
for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p);
for (const [macro, assi] of Object.entries(PESI_MACRO))
for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p);
for (const [el, p] of Object.entries(PESI_FITNESS_AGE))
ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p);
});
tx();
}
export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] {
const righe = db.prepare(
`SELECT * FROM registro_test
WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?)
ORDER BY test_id`
).all(alla, alla) as Record<string, unknown>[];
return righe.map((r) => ({
...r,
params: r.params ? JSON.parse(r.params as string) : undefined,
})) as VoceRegistro[];
}
export function esisteTest(db: Database.Database, testId: string): boolean {
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
return r !== undefined;
}
/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */
export function pesiDi(
db: Database.Database, modelVersion: string,
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
): Record<string, number> {
const righe = db.prepare(
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
}
+250
View File
@@ -0,0 +1,250 @@
//
// La vista che il questionario in pagina usa. Due responsabilità:
// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
// cosa) che un domani va dimostrato tanto quanto un assenso.
import type Database from 'better-sqlite3';
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore } from './motore';
export type CampoQuestionario = {
id: string;
etichetta: string;
tipo: 'num' | 'txt';
unita?: string;
min?: number;
max?: number;
sottoDominio?: string;
};
/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
* `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
return testAttivi(db, alla)
.filter((v) => v.test_id.startsWith('q_'))
.map((v) => ({
id: v.test_id,
etichetta: v.etichetta,
tipo: v.tipo_valore,
unita: v.unita,
min: v.range_min,
max: v.range_max,
sottoDominio: v.sotto_dominio,
}));
}
export type EsitoSalvataggio =
| { ok: true; sessioneId: number }
| { ok: false; errore: string };
const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
function consensoDato(risposta: string): boolean {
return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
}
/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso
* (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che
* cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla
* sessione solo quando il consenso è affermativo e la sessione esiste davvero. */
function registraConsenso(
db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
): void {
const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
const oggi = new Date().toISOString().slice(0, 10);
const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`;
db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
).run(clientCode, sessioneId, 'consenso_sanitario', testo);
}
/**
* Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
* qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
* note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
* scrive comunque, con l'esito letterale ricevuto.
*
* Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il
* salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la
* nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa
* correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava
* calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla
* transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che
* li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint
* quelle interne di `salvaCompilazione` e `salvaScore`.
*/
export function salvaDalForm(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
consensoSanitario: string;
liberi?: Record<string, string>;
}
): EsitoSalvataggio {
if (!consensoDato(input.consensoSanitario)) {
registraConsenso(db, input.client_code, null, input.consensoSanitario);
return {
ok: false,
errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
};
}
const tx = db.transaction(() => {
const sessioneId = salvaCompilazione(db, {
client_code: input.client_code,
data: input.data,
eta: input.eta,
risposte: input.risposte,
liberi: input.liberi,
});
// Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo
// successivo affidato a chi legge: senza questo, una compilazione restava per sempre
// senza i suoi punteggi.
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
return sessioneId;
});
return { ok: true, sessioneId: tx() };
}
//
// Il referto che il cliente legge: il radar a sette assi e le carte dei macro-score.
// Due regole non negoziabili, entrambe già chiuse a monte, non da questo modulo:
// - il valore viene da `leggiScore` (punteggi congelati in `score`), mai da un
// ricalcolo — cambiare i pesi non deve mai cambiare un referto già emesso;
// - un asse/macro insufficiente non porta un `valore` (resta `null`): è il tipo
// `Punteggio` del motore che lo rende impossibile da violare per distrazione.
//
/** Un asse del radar: il valore (assente se il dato non basta), la copertura di peso
* disponibile e — solo quando manca — l'elenco leggibile di cosa lo completerebbe. */
export type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] };
/** Una carta di macro-score: stesso principio degli assi, senza l'elenco di cosa manca
* (il "cosa manca" si legge scendendo ai sette assi, non qui). */
export type MacroVista = { nome: string; valore: number | null; copertura: number };
/**
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
* dove il registro non arriva ancora.
*/
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
handgrip: 'Forza di presa (handgrip)',
spinta: 'Test di spinta (push-up o panca)',
trazione: 'Test di trazione',
arti_inferiori: 'Test arti inferiori',
core: 'Test del core (plank)',
grasso: 'Composizione corporea — massa grassa',
muscolo: 'Composizione corporea — massa muscolare',
whr: 'Rapporto vita-fianchi (WHR)',
vo2max: 'Test cardio (VO2max)',
spirometria: 'Spirometria',
wellness_tower_cardio: 'Wellness Tower — cardio',
hrv: 'Variabilità della frequenza cardiaca (HRV)',
pressione: 'Pressione arteriosa',
hrr: 'Frequenza cardiaca di recupero (HRR)',
questionario_sonno: 'Questionario del sonno',
questionario_energia_stress: 'Questionario energia e stress',
flamingo: 'Test di equilibrio (flamingo)',
sit_and_reach: 'Test di flessibilità (sit and reach)',
plank: 'Plank (tenuta)',
back_scratch: 'Test di mobilità delle spalle (back scratch)',
wellness_tower_shoulder: 'Wellness Tower — spalla',
questionario_lifestyle: 'Questionario di stile di vita',
};
/** I contenitori (assi o macro-score) dichiarati nei pesi per il model version corrente,
* nell'ordine in cui compaiono nella tabella — che è l'ordine di inserimento di `seedPesi`,
* cioè quello dichiarato in registro.ts. Non un elenco duplicato qui: si legge dal dato. */
function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string[] {
return (db.prepare(
`SELECT contenitore FROM pesi WHERE model_version = ? AND livello = ?
GROUP BY contenitore ORDER BY MIN(rowid)`
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
}
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
* test sono stati fatti non è ricalcolare come pesano. */
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
const righe = db.prepare(
`SELECT DISTINCT rt.sotto_dominio AS sd
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND rt.sotto_dominio IS NOT NULL`
).all(sessioneId) as { sd: string }[];
return new Set(righe.map((r) => r.sd));
}
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
* che questa sessione non ha, tradotti nell'etichetta leggibile. */
function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, presenti: Set<string>): string[] {
const pesi = pesiDi(db, MODEL_VERSION, 'asse', nomeAsse);
return Object.keys(pesi)
.filter((sd) => !presenti.has(sd))
.map((sd) => ETICHETTE_SOTTO_DOMINIO[sd] ?? sd);
}
/**
* Il referto di una sessione: i sette assi del radar, i macro-score e la Fitness Age.
* Legge SOLO `leggiScore` per i valori (punteggi congelati): non chiama mai
* `calcolaSessione`, quindi un peso cambiato dopo il fatto non altera un referto già
* emesso. `mancano` guarda invece le misure grezze della sessione (quali test ci sono),
* non il punteggio — è informazione su cosa manca, non un ricalcolo di quanto vale.
*/
export function refertoDi(
db: Database.Database,
sessioneId: number
): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null; fitnessAgeCopertura: number } {
const punteggi = leggiScore(db, sessioneId);
const presenti = sottoDominiPresenti(db, sessioneId);
const assi: AsseVista[] = contenitoriDi(db, 'asse').map((nome) => {
const p = punteggi.assi[nome];
if (p?.stato === 'ok') return { nome, valore: p.valore, copertura: p.copertura, mancano: [] };
return {
nome, valore: null, copertura: p?.copertura ?? 0,
mancano: mancanoPer(db, sessioneId, nome, presenti),
};
});
const macro: MacroVista[] = contenitoriDi(db, 'macro').map((nome) => {
const p = punteggi.macro[nome];
return p?.stato === 'ok'
? { nome, valore: p.valore, copertura: p.copertura }
: { nome, valore: null, copertura: p?.copertura ?? 0 };
});
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
// Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve
// poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca:
// la copertura resta leggibile anche quando lo stato è 'insufficiente'.
const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0;
return { assi, macro, fitnessAge, fitnessAgeCopertura };
}
/** L'ultima sessione di un cliente (la più recente per data, a parità di data la più
* recente per id). `null` se il cliente non ha ancora nessuna sessione — la pagina del
* referto la usa per non dover interrogare `sessioni` da sola. */
export function ultimaSessioneDi(db: Database.Database, clientCode: string): number | null {
const r = db.prepare(
`SELECT id FROM sessioni WHERE client_code = ? ORDER BY data DESC, id DESC LIMIT 1`
).get(clientCode) as { id: number } | undefined;
return r?.id ?? null;
}
+8
View File
@@ -0,0 +1,8 @@
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
export function safeNext(next: string | null): string {
if (next && next.includes('\\')) return '/';
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
if (next.startsWith('/api/')) return '/';
return next;
}
@@ -0,0 +1,141 @@
// Calcoli della vista Analytics. Stanno fuori dal componente perché sono la parte con una
// logica vera — e quindi l'unica che vale la pena testare — mentre la vista si limita a
// disegnare ciò che esce da qui.
import type { Alert, Client, Measurement } from './data';
export interface Periodo { from: string; to: string }
export const METRICHE = [
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
{ key: 'balance', label: 'Equilibrio', color: '#7d9b96' },
{ key: 'energy', label: 'Energia', color: '#c08a3e' },
] as const;
export const FASCE = ['0-20', '21-40', '41-60', '61-80', '81-100'];
/** Fascia di appartenenza di uno score 0-100. */
export function fascia(value: number): string {
if (value <= 20) return FASCE[0];
if (value <= 40) return FASCE[1];
if (value <= 60) return FASCE[2];
if (value <= 80) return FASCE[3];
return FASCE[4];
}
const dentro = (date: string, p: Periodo) => date >= p.from && date <= p.to;
/** Misurazioni del periodo, cliente per cliente. */
export function misurazioniNelPeriodo(
byClient: Record<string, Measurement[]>,
periodo: Periodo
): Record<string, Measurement[]> {
const out: Record<string, Measurement[]> = {};
for (const [id, serie] of Object.entries(byClient)) {
out[id] = serie.filter((m) => dentro(m.date, periodo));
}
return out;
}
export interface Kpi { label: string; value: string | number; note: string }
export function kpis(
clients: Client[],
byClient: Record<string, Measurement[]>,
alerts: Alert[],
periodo: Periodo
): Kpi[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
const attivi = clients.filter((c) => (nelPeriodo[c.id] ?? []).length > 0).length;
const misurazioni = Object.values(nelPeriodo).reduce((n, s) => n + s.length, 0);
const alertAttivi = alerts.filter((a) => dentro(a.when, periodo)).length;
const aderenza = clients.length ? Math.round((attivi / clients.length) * 100) : 0;
return [
{ label: 'Clienti attivi', value: attivi, note: `/ ${clients.length} totali` },
{ label: 'Misurazioni', value: misurazioni, note: 'nel periodo' },
{ label: 'Alert attivi', value: alertAttivi, note: 'nel periodo' },
{ label: 'Aderenza', value: `${aderenza}%`, note: 'hanno misurato' },
];
}
/** Quante misurazioni cadono in ogni fascia, separatamente per ciascuna metrica. */
export function distribuzione(
byClient: Record<string, Measurement[]>,
periodo: Periodo
): Record<string, number | string>[] {
const righe = FASCE.map((f) => {
const riga: Record<string, number | string> = { range: f };
for (const m of METRICHE) riga[m.key] = 0;
return riga;
});
for (const serie of Object.values(misurazioniNelPeriodo(byClient, periodo))) {
for (const misura of serie) {
for (const metrica of METRICHE) {
const riga = righe[FASCE.indexOf(fascia(misura[metrica.key]))];
riga[metrica.key] = (riga[metrica.key] as number) + 1;
}
}
}
return righe;
}
export interface RigaClassifica { clientId: string; name: string; recovery: number; count: number }
/** Clienti ordinati per recupero medio nel periodo: i migliori in testa. */
export function classificaRecupero(
clients: Client[],
byClient: Record<string, Measurement[]>,
periodo: Periodo
): RigaClassifica[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
return clients
.map((c) => {
const serie = nelPeriodo[c.id] ?? [];
const media = serie.length ? serie.reduce((n, m) => n + m.recovery, 0) / serie.length : 0;
return { clientId: c.id, name: c.name, recovery: Math.round(media), count: serie.length };
})
.filter((r) => r.count > 0)
.sort((a, b) => b.recovery - a.recovery);
}
export type Dimensione = 'sesso' | 'eta' | 'tag';
export const DIMENSIONI: { key: Dimensione; label: string }[] = [
{ key: 'sesso', label: 'Sesso' },
{ key: 'eta', label: 'Età' },
{ key: 'tag', label: 'Tag principale' },
];
function gruppoDi(client: Client, dimensione: Dimensione): string {
if (dimensione === 'sesso') return client.sex === 'F' ? 'Donne' : 'Uomini';
if (dimensione === 'eta') return client.age < 35 ? 'Fino a 34' : client.age < 50 ? '35-49' : '50 e oltre';
return client.tags[0] ?? 'Senza tag';
}
/** Stress e recupero medi per gruppo, secondo la dimensione scelta. */
export function confrontoSegmenti(
clients: Client[],
byClient: Record<string, Measurement[]>,
periodo: Periodo,
dimensione: Dimensione
): Record<string, number | string>[] {
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
const gruppi = new Map<string, { stress: number; recovery: number; n: number }>();
for (const client of clients) {
const serie = nelPeriodo[client.id] ?? [];
if (!serie.length) continue;
const nome = gruppoDi(client, dimensione);
const acc = gruppi.get(nome) ?? { stress: 0, recovery: 0, n: 0 };
for (const m of serie) {
acc.stress += m.stress;
acc.recovery += m.recovery;
acc.n += 1;
}
gruppi.set(nome, acc);
}
return [...gruppi.entries()].map(([gruppo, a]) => ({
gruppo,
stress: Math.round(a.stress / a.n),
recovery: Math.round(a.recovery / a.n),
}));
}
+228
View File
@@ -0,0 +1,228 @@
// 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 type 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,
};
// `fill` colora la singola barra: le fasce basse sono "buone", le alte critiche.
export const scoreDistribution = [
{ range: '0-20', count: 0, fill: '#6f8f6a' },
{ range: '21-40', count: 2, fill: '#6f8f6a' },
{ range: '41-60', count: 3, fill: '#c08a3e' },
{ range: '61-80', count: 4, fill: '#b05a4e' },
{ range: '81-100', count: 1, fill: '#8d3f34' },
];
export const segments = [
{ 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 },
];
// Ultimo giorno coperto dai dati dimostrativi dello Sport.
const OGGI_SPORT = '2026-07-12';
export type Session = {
id: string;
athleteId: string;
/** Inizio della sessione, data e ora locali. */
startedAt: string;
durationSec: number;
/** Lo sport può non essere indicato: l'app lo ricava dal sensore solo se dichiarato. */
sport: string | null;
hrAvg: number;
hrMax: number;
trimp: number;
/** DFA Alpha1 a fine sessione; assente sulle registrazioni troppo brevi. */
dfaA1: number | null;
/** Sforzo percepito 1-10, inserito a mano dall'atleta. */
rpe: number | null;
};
export type Athlete = { clientId: string; name: string; sport: string | null };
export const athletes: Athlete[] = [
{ clientId: 'c2', name: 'A. Neri', sport: 'Corsa' },
{ clientId: 'c5', name: 'P. Colombo', sport: 'Ciclismo' },
{ clientId: 'c8', name: 'C. Greco', sport: null },
];
// Venti sessioni distribuite sugli ultimi due mesi, generate in modo deterministico dallo
// stesso schema seno/coseno usato per le misurazioni: nessun valore casuale, così server e
// client disegnano gli stessi grafici.
function buildSessions(): Session[] {
const out: Session[] = [];
const fine = new Date(`${OGGI_SPORT}T00:00:00`);
for (let i = 0; i < 20; i++) {
const atleta = athletes[i % athletes.length];
const giorno = new Date(fine);
giorno.setDate(giorno.getDate() - Math.round(i * 2.6));
const seed = i * 7 + atleta.clientId.charCodeAt(1);
const ora = 7 + (i % 12);
const minuti = (seed * 13) % 60;
const durata = i % 9 === 0 ? 5 + (seed % 40) : 540 + Math.round(Math.abs(Math.sin(seed)) * 4400);
const hrAvg = 95 + Math.round(Math.abs(Math.cos(seed)) * 55);
out.push({
id: `s${i + 1}`,
athleteId: atleta.clientId,
startedAt: `${giorno.toISOString().slice(0, 10)}T${String(ora).padStart(2, '0')}:${String(minuti).padStart(2, '0')}`,
durationSec: durata,
sport: atleta.sport,
hrAvg,
hrMax: hrAvg + 15 + (seed % 25),
trimp: durata < 300 ? 0 : Math.max(4, Math.round((durata / 60) * (hrAvg / 110))),
dfaA1: durata < 300 ? null : Math.round((0.85 + Math.abs(Math.sin(seed * 1.7)) * 0.85) * 100) / 100,
rpe: i % 7 === 2 ? null : 3 + (seed % 6),
});
}
return out.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
}
export const sessions: Session[] = buildSessions();
// "Oggi" della piattaforma: i dati dimostrativi sono ancorati a questa data, quindi i conteggi
// "giorni fa" restano coerenti invece di crescere col passare del tempo reale.
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',
};
@@ -0,0 +1,105 @@
// Calcoli e formattazioni del modulo Sport, tenuti fuori dalla vista perché sono la parte
// con una logica propria (finestre temporali, aggregazioni per settimana, riepiloghi atleta).
import type { Athlete, Session } from './data';
/** "1h 21m" oltre l'ora, "54m 35s" sopra il minuto, "5s" sotto. */
export function durata(sec: number): string {
if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`;
if (sec >= 60) return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`;
return `${sec}s`;
}
const MESI = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
/** "30 lug 2026" */
export function dataBreve(iso: string): string {
const [y, m, d] = iso.slice(0, 10).split('-');
return `${Number(d)} ${MESI[Number(m) - 1]} ${y}`;
}
/** "30 lug 2026 alle 11:27" */
export function dataOra(iso: string): string {
return `${dataBreve(iso)} alle ${iso.slice(11, 16)}`;
}
const giorniFra = (a: string, b: string) =>
Math.round((Date.parse(b.slice(0, 10)) - Date.parse(a.slice(0, 10))) / 86_400_000);
/** Sessioni degli ultimi `giorni` rispetto alla data di riferimento. */
export function ultimi(sessions: Session[], giorni: number, riferimento: string): Session[] {
return sessions.filter((s) => {
const d = giorniFra(s.startedAt, riferimento);
return d >= 0 && d < giorni;
});
}
export interface KpiSport { label: string; value: number; note?: string }
export function kpiSport(sessions: Session[], riferimento: string): KpiSport[] {
const settimana = ultimi(sessions, 7, riferimento);
const mese = ultimi(sessions, 30, riferimento);
const trimpSettimanale = settimana.reduce((n, s) => n + s.trimp, 0);
const atletiAttivi = new Set(mese.map((s) => s.athleteId)).size;
return [
{ label: 'Sessioni totali', value: sessions.length },
{ label: 'Questa settimana', value: settimana.length, note: 'ultimi 7gg' },
{ label: 'TRIMP medio', value: trimpSettimanale, note: 'settimanale' },
{ label: 'Atleti attivi', value: atletiAttivi, note: 'ultimi 30gg' },
];
}
/** Conteggio sessioni per settimana, etichettato col lunedì di ciascuna (dd/mm). */
export function perSettimana(sessions: Session[], settimane: number, riferimento: string): Record<string, string | number>[] {
// Tutta l'aritmetica sta in UTC: con date costruite in ora locale, `toISOString` riporta
// indietro di un giorno ovunque il fuso sia positivo, e le settimane slittano al giorno prima.
const GIORNO = 86_400_000;
const rif = Date.parse(riferimento.slice(0, 10));
// getUTCDay(): domenica = 0, quindi la domenica torna indietro di 6 giorni, non di -1.
const lunedi = rif - ((new Date(rif).getUTCDay() + 6) % 7) * GIORNO;
const righe: Record<string, string | number>[] = [];
for (let i = settimane - 1; i >= 0; i--) {
const inizio = lunedi - i * 7 * GIORNO;
const da = new Date(inizio).toISOString().slice(0, 10);
const a = new Date(inizio + 7 * GIORNO).toISOString().slice(0, 10);
righe.push({
settimana: `${da.slice(8, 10)}/${da.slice(5, 7)}`,
sessioni: sessions.filter((s) => s.startedAt.slice(0, 10) >= da && s.startedAt.slice(0, 10) < a).length,
});
}
return righe;
}
export interface RiepilogoAtleta {
clientId: string;
name: string;
sport: string | null;
sessioni30: number;
trimp7: number;
ultimoTrimp: number;
ultimaSessione: string | null;
/** Confronto fra il carico dell'ultima settimana e quello della precedente. */
trend: 'in crescita' | 'stabile' | 'in calo';
}
export function riepilogoAtleti(sessions: Session[], athletes: Athlete[], riferimento: string): RiepilogoAtleta[] {
return athletes.map((a) => {
const sue = sessions.filter((s) => s.athleteId === a.clientId);
const trimp7 = ultimi(sue, 7, riferimento).reduce((n, s) => n + s.trimp, 0);
const settimanaPrima = sue.filter((s) => {
const d = giorniFra(s.startedAt, riferimento);
return d >= 7 && d < 14;
}).reduce((n, s) => n + s.trimp, 0);
const ultima = sue[0] ?? null;
const scarto = trimp7 - settimanaPrima;
return {
clientId: a.clientId,
name: a.name,
sport: a.sport,
sessioni30: ultimi(sue, 30, riferimento).length,
trimp7,
ultimoTrimp: ultima?.trimp ?? 0,
ultimaSessione: ultima?.startedAt ?? null,
trend: Math.abs(scarto) <= 10 ? 'stabile' : scarto > 0 ? 'in crescita' : 'in calo',
};
});
}
+37
View File
@@ -0,0 +1,37 @@
import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE, canAccessPath, landingFor, isProtectedPath } from './lib/auth';
/**
* Tutta l'applicazione è riservata: si entra dal /login di quest'app, con gli utenti di
* quest'app. ⚠️ Una sessione aperta sul sito (insanitylab.it) qui non vale — cookie diverso,
* dominio diverso, database diverso. È la separazione, non un difetto.
*
* ⚠️ Le rotte /api rispondono con uno stato, non con un redirect: mandare un 302 verso una
* pagina di login a chi ha chiesto del JSON produce un 200 con dentro dell'HTML, cioè un
* errore che il chiamante scopre provando a leggerlo.
*/
export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url;
if (!isProtectedPath(pathname)) return next();
const json = (status: number, error: string) =>
new Response(JSON.stringify({ error }), { status, headers: { 'Content-Type': 'application/json' } });
const token = context.cookies.get(SESSION_COOKIE)?.value;
const user = token ? getSessionUser(getDb(), token) : null;
if (!user) {
if (pathname.startsWith('/api/')) return json(401, 'Non autorizzato');
return context.redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
if (!canAccessPath(user.role, pathname)) {
if (pathname.startsWith('/api/')) return json(403, 'Permessi insufficienti');
// Loggato ma senza i permessi per QUESTA sezione: si torna a casa propria, non si
// mostra una porta chiusa a chi una casa ce l'ha.
return context.redirect(landingFor(user.role));
}
context.locals.user = user;
return next();
});
+21
View File
@@ -0,0 +1,21 @@
---
// Il 404 dell'area riservata. ⚠️ Quello del sito non si poteva riusare: pesca i suoi testi
// dai contenuti taggati (content_blocks), che vivono nel database del sito e qui non
// esistono. Qui il testo è scritto in pagina — è una pagina d'errore, non un contenuto
// da modificare dal pannello.
import App from '../layouts/App.astro';
export const prerender = false;
---
<App title="Pagina non trovata" user={Astro.locals.user ?? null}>
<section class="nf">
<h1>Pagina non trovata</h1>
<p>L'indirizzo non corrisponde a niente di quest'area.</p>
<p><a href="/piattaforme">Torna alle piattaforme</a></p>
</section>
<style>
.nf { text-align: center; padding-block: 5rem; }
.nf h1 { font-family: var(--font-heading, inherit); font-size: 1.4rem; letter-spacing: .1em;
text-transform: uppercase; margin-bottom: 1rem; }
.nf p { color: var(--c-text-light, #666); }
</style>
</App>
@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
import { salvaDalForm } from '../../../lib/longevity/vista';
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
/**
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
*/
type CorpoQuestionario = {
risposte: Record<string, number>;
liberi: Record<string, string>;
consensoSanitario: string;
eta?: number;
};
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
* al chiamante, perché il tipo di ritorno non la contiene. */
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
if (typeof raw !== 'object' || raw === null) return null;
const grezzo = raw as Record<string, unknown>;
const risposteIn = grezzo.risposte;
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
const risposte: Record<string, number> = {};
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
}
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
const liberiIn = grezzo.liberi;
const liberi: Record<string, string> = {};
if (typeof liberiIn === 'object' && liberiIn !== null) {
for (const campo of CAMPI_LIBERI) {
const v = (liberiIn as Record<string, unknown>)[campo];
if (typeof v === 'string') liberi[campo] = v;
}
}
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
return { risposte, liberi, consensoSanitario, eta };
}
export const POST: APIRoute = async ({ request, locals }) => {
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
// prenderlo per errore.
const codice = codicePerUtenteLoggato(locals.user!.id);
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
let raw: unknown;
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
const corpo = corpoDaJson(raw);
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
const { risposte, liberi, consensoSanitario, eta } = corpo;
// La data della compilazione è quella del server, non quella (manomettibile) del client.
const oggi = new Date().toISOString().slice(0, 10);
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
let esito;
try {
esito = salvaDalForm(getLongevityDb(), {
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
});
} catch (err) {
console.error('Salvataggio questionario fallito:', err);
return json(400, {
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
});
}
if (!esito.ok) return json(422, { error: esito.errore });
return json(201, { sessioneId: esito.sessioneId });
};
@@ -0,0 +1,113 @@
---
// Due forme di URL:
// /campus/<area> → indice dei capitoli dell'area
// /campus/<area>/<guida> → corpo della guida
import Campus from '../../layouts/Campus.astro';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
const [areaSlug, lessonSlug] = parts;
const area = findArea(nav, areaSlug);
if (!area) return new Response('Pagina non trovata', { status: 404 });
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
const body = lesson ? readLessonHtml(lesson.slug) : null;
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
const chapter = lesson ? chapterOf(nav, lesson) : null;
---
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
{lesson ? (
<article>
<nav class="cp-crumbs">
<a href="/campus">Biohacking</a> <span>/</span>
<a href={`/campus/${area.slug}`}>{area.title}</a>
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
</nav>
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
<h1>{lesson.titleFull}</h1>
{lesson.concetti.length > 0 && (
<ul class="cp-concetti">
{lesson.concetti.map((c) => <li>{c}</li>)}
</ul>
)}
<div class="cp-body" set:html={body} />
<nav class="cp-prevnext">
{lesson.prev
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
: <span />}
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
</nav>
</article>
) : (
<>
<nav class="cp-crumbs"><a href="/campus">Biohacking</a> <span>/</span> <span>{area.title}</span></nav>
<p class="eyebrow">Area di studio</p>
<h1>{area.title}</h1>
<p class="cp-lead">{area.sub}</p>
{area.chapters.map((ch) => (
<section class="cp-chapter" style={`--c:${ch.color}`}>
<h2>{ch.num}. {ch.title}</h2>
<p class="cp-chapter__sub">{ch.sub}</p>
<ul class="cp-list">
{ch.lessons.map((l) => (
<li>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-list__label">{l.label}</span>
<span class="cp-list__title">{l.titleShort}</span>
<span class="cp-list__rel">{l.relatore}</span>
</a>
</li>
))}
</ul>
</section>
))}
</>
)}
</Campus>
<style is:global>
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
.cp-crumbs a:hover { text-decoration: underline; }
.cp-crumbs span { margin: 0 4px; }
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); }
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
.cp-chapter { margin-bottom: 42px; }
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
.cp-list { list-style: none; margin: 0; padding: 0; }
.cp-list li { border-bottom: 1px solid #efece7; }
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
text-decoration: none; color: inherit; padding: 11px 16px; }
.cp-list a:hover { background: var(--c-bg-alt); }
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
.cp-prevnext a:hover { text-decoration: underline; }
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
@media (max-width: 640px) {
.cp-list a { grid-template-columns: 56px 1fr; }
.cp-list__rel { display: none; }
}
</style>
@@ -0,0 +1,35 @@
// Immagini delle slide del Campus. Passano dal middleware come il resto di
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
import type { APIRoute } from 'astro';
import { createReadStream, statSync } from 'node:fs';
import { extname } from 'node:path';
import { Readable } from 'node:stream';
import { resolveAsset } from '../../../lib/campus';
export const prerender = false;
const TYPES: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
};
export const GET: APIRoute = ({ params }) => {
const rel = params.path;
if (!rel) return new Response('Not found', { status: 404 });
const type = TYPES[extname(rel).toLowerCase()];
if (!type) return new Response('Tipo non consentito', { status: 400 });
const file = resolveAsset(rel);
if (!file) return new Response('Not found', { status: 404 });
const { size } = statSync(file);
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': type,
'Content-Length': String(size),
'Cache-Control': 'private, max-age=86400',
},
});
};
@@ -0,0 +1,43 @@
---
import Campus from '../../layouts/Campus.astro';
import { getNav } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
---
<Campus title="Guida allo studio" nav={nav}>
<p class="eyebrow">Biohacking Campus 2025/2026</p>
<h1>Guida allo studio</h1>
<p class="cp-lead">
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
</p>
<div class="cp-grid">
{nav.areas.map((area) => (
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
<h2>{area.title}</h2>
<p>{area.sub}</p>
<ul>
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
</ul>
</a>
))}
</div>
</Campus>
<style is:global>
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
text-transform: uppercase; }
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
font-size: .82rem; color: var(--c-text-light); }
.cp-card ul li { margin: 0; padding: 2px 0; }
.cp-card ul span { color: var(--c-accent-dark); }
</style>
+7
View File
@@ -0,0 +1,7 @@
---
// La radice di apps.insanitylab.it non ha contenuto proprio: ognuno va a casa sua, e chi
// non è loggato ci finisce comunque passando dal middleware.
import { landingFor } from '../lib/auth';
export const prerender = false;
return Astro.redirect(landingFor(Astro.locals.user!.role));
---
+56
View File
@@ -0,0 +1,56 @@
---
// Il login di quest'app. ⚠️ Non è quello del sito: utenti e sessioni stanno in
// piattaforme.db, e chi è loggato su insanitylab.it qui è uno sconosciuto.
import App from '../layouts/App.astro';
import { getDb } from '../lib/db';
import { login, getSessionUser, SESSION_COOKIE, landingFor } from '../lib/auth';
import { safeNext } from '../lib/safe-next';
export const prerender = false;
const dest = safeNext(Astro.url.searchParams.get('next'));
const esistente = Astro.cookies.get(SESSION_COOKIE)?.value;
const giaDentro = esistente ? getSessionUser(getDb(), esistente) : null;
if (giaDentro) return Astro.redirect(dest !== '/' ? dest : landingFor(giaDentro.role));
let errore = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
if (token) {
Astro.cookies.set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
});
const utente = getSessionUser(getDb(), token)!;
const richiesto = safeNext(String(form.get('next') ?? '') || dest);
return Astro.redirect(richiesto !== '/' ? richiesto : landingFor(utente.role));
}
errore = 'Credenziali non valide.';
}
---
<App title="Accedi">
<section class="login">
<h1>Area riservata</h1>
{errore && <p class="login__err">{errore}</p>}
<form method="post" class="login__form">
<input type="hidden" name="next" value={dest} />
<input class="login__field" name="username" placeholder="Utente" required autocomplete="username" />
<input class="login__field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="login__btn" type="submit">Accedi</button>
</form>
<p class="login__back"><a href="https://insanitylab.it">← Torna al sito</a></p>
</section>
<style>
.login { max-width: 22rem; margin: 3rem auto; }
.login h1 { font-family: var(--font-heading, inherit); font-size: 1.2rem; letter-spacing: .12em;
text-transform: uppercase; margin-bottom: 1.6rem; }
.login__form { display: grid; gap: .8rem; }
.login__field { padding: .7rem .8rem; border: 1px solid rgba(0,0,0,.2); border-radius: 2px; font: inherit; }
.login__btn { padding: .75rem; border: 0; background: var(--c-heading, #111); color: #fff; font: inherit;
letter-spacing: .12em; text-transform: uppercase; font-size: .74rem; cursor: pointer; }
.login__err { color: #b00020; font-size: .85rem; }
.login__back { margin-top: 1.6rem; font-size: .8rem; }
.login__back a { color: var(--c-muted, #777); }
</style>
</App>
+11
View File
@@ -0,0 +1,11 @@
import type { APIRoute } from 'astro';
import { getDb } from '../lib/db';
import { logout, SESSION_COOKIE } from '../lib/auth';
export const prerender = false;
export const GET: APIRoute = ({ cookies, redirect }) => {
const token = cookies.get(SESSION_COOKIE)?.value;
if (token) logout(getDb(), token);
cookies.delete(SESSION_COOKIE, { path: '/' });
return redirect('/login');
};
@@ -0,0 +1,7 @@
---
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath
// e canAccessAdminPath in src/lib/auth.ts).
export const prerender = false;
return Astro.redirect('/longevity/io');
---
@@ -0,0 +1,72 @@
---
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
import Longevity from '../../layouts/Longevity.astro';
import Radar from '../../components/longevity/Radar';
import MacroScore from '../../components/longevity/MacroScore.astro';
import { getLongevityDb } from '../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
import { formattaNumero } from '../../lib/longevity/formato';
export const prerender = false;
const user = Astro.locals.user!;
const codice = codicePerUtenteLoggato(user.id);
const db = getLongevityDb();
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
---
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && !referto && (
<div class="lg-card lg-card--incompleto">
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Il tuo radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
<section class="lg-card">
<p>
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
raccontano come cambi, non solo come stai oggi.
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
</p>
</section>
</div>
)}
</Longevity>
@@ -0,0 +1,22 @@
---
// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
// /api/longevity/questionario, dove il consenso si verifica davvero.
import Longevity from '../../layouts/Longevity.astro';
import Questionario from '../../components/longevity/Questionario';
import { getLongevityDb } from '../../lib/longevity/db';
import { campiDelQuestionario } from '../../lib/longevity/vista';
export const prerender = false;
const oggi = new Date().toISOString().slice(0, 10);
const campi = campiDelQuestionario(getLongevityDb(), oggi);
---
<Longevity title="Questionario" crumbs={['Questionario']}>
<div class="lg-head">
<h1>Questionario</h1>
<p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
</div>
<Questionario client:load campi={campi} />
</Longevity>
@@ -0,0 +1,49 @@
---
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
import App from '../../layouts/App.astro';
import { piattaformePer } from '../../data/piattaforme';
export const prerender = false;
const utente = Astro.locals.user!;
const visibili = piattaformePer(utente.role);
---
<App title="Piattaforme" user={utente}>
<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">
{visibili.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>
</App>
<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,22 @@
---
// Analytics di studio. La pagina fornisce i dati e l'intervallo iniziale; il resto — filtri,
// aggregazioni e grafici — è nell'isola, perché ogni numero dipende dai controlli.
import StressIndex from '../../../layouts/StressIndex.astro';
import AnalyticsView from '../../../components/stress-index/AnalyticsView';
import { clients, measurementsByClient, alerts } from '../../../lib/stress-index/data';
export const prerender = false;
// Mese corrente dei dati dimostrativi.
const DA = '2026-07-01';
const A = '2026-07-31';
---
<StressIndex title="Analytics" crumbs={['Analytics']}>
<AnalyticsView
client:load
clients={clients}
measurementsByClient={measurementsByClient}
alerts={alerts}
from={DA}
to={A}
/>
</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,13 @@
---
// Modulo Sport. La pagina passa i dati; filtri, schede e aggregazioni stanno nell'isola.
import StressIndex from '../../../../layouts/StressIndex.astro';
import SportView from '../../../../components/stress-index/SportView';
import { sessions, athletes } from '../../../../lib/stress-index/data';
export const prerender = false;
// Ultimo giorno coperto dai dati dimostrativi.
const RIFERIMENTO = '2026-07-12';
---
<StressIndex title="Sport" crumbs={['Sport']}>
<SportView client:load sessions={sessions} athletes={athletes} riferimento={RIFERIMENTO} />
</StressIndex>
@@ -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>
+92
View File
@@ -0,0 +1,92 @@
/* === Design tokens (valori campionati da Figma) === */
:root {
--c-accent: #b8a98c;
--c-accent-dark: #9c8b70;
--c-dark: #3a2929;
--c-dark-2: #4a3a30;
--c-heading: #2b2b2b;
--c-text: #4b4b4b;
--c-text-light: #8a8a8a;
--c-bg: #ffffff;
--c-bg-alt: #f5f3f0;
--font-heading: 'Montserrat', sans-serif;
--font-body: 'Open Sans', sans-serif;
--header-h: 70px;
}
/* === Reset === */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); }
img { max-width: 100%; height: auto; display: block; }
h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; }
h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); }
h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
h3 { font-size: 1.3rem; }
a { color: inherit; }
p { margin: 0 0 1em; }
/* === Utility === */
.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; }
.section { padding-block: 90px; }
.section--alt { background: var(--c-bg-alt); }
.section--dark { background: var(--c-dark); color: #cfc6bd; }
.section--dark h2, .section--dark h3 { color: #fff; }
.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); }
.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; }
.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; }
.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; }
.btn:hover { background: var(--c-accent-dark); }
.btn::after { content: '→'; font-size: .9rem; }
.btn--dark { background: var(--c-dark); }
.btn--dark:hover { background: var(--c-dark-2); }
.btn--light { background: #fff; color: var(--c-heading); }
/* === Form === */
.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; }
.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; }
textarea.field { min-height: 120px; resize: vertical; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
/* honeypot */
.hp { position: absolute; left: -9999px; opacity: 0; }
/* === Animazioni (stile template Prowess) === */
/* Lo stato nascosto vale solo con JS attivo (html.js): senza JS tutto resta visibile. */
html.js [data-reveal], html.js .section-heading { opacity: 0; transform: translateY(40px); transition: opacity .9s ease, transform .9s ease; transition-delay: var(--reveal-delay, 0s); }
html.js [data-reveal="left"] { transform: translateX(-60px); }
html.js [data-reveal="right"] { transform: translateX(60px); }
html.js [data-reveal="fade"] { transform: none; }
html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1; transform: none; }
.btn::after { transition: transform .3s ease; }
.btn:hover::after { transform: translateX(5px); }
@media (prefers-reduced-motion: reduce) {
html.js [data-reveal], html.js .section-heading { opacity: 1; transform: none; transition: none; }
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
/* === Stili contenuto (preset pannello /admin/content) === */
.cs-size-s { font-size: .85em; }
.cs-size-m { font-size: 1em; }
.cs-size-l { font-size: 1.25em; }
.cs-size-xl { font-size: 1.5em; }
.cs-size-xxl { font-size: 2em; }
.cs-color-accent { color: var(--c-accent); }
.cs-color-accent-dark { color: var(--c-accent-dark); }
.cs-color-dark { color: var(--c-dark); }
.cs-color-heading { color: var(--c-heading); }
.cs-color-text { color: var(--c-text); }
.cs-color-text-light { color: var(--c-text-light); }
.cs-color-white { color: #fff; }
.cs-weight-normal { font-weight: 400; }
.cs-weight-bold { font-weight: 700; }
.cs-style-normal { font-style: normal; }
.cs-style-italic { font-style: italic; }
.cs-align-left { text-align: left; }
.cs-align-center { text-align: center; }
.cs-align-right { text-align: right; }
.cs-align-justify { text-align: justify; }
+141
View File
@@ -0,0 +1,141 @@
/* Stile della piattaforma Longevity.
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
ai componenti Astro sia alle isole React (questionario e radar), che lo scope di Astro
non raggiungerebbe. Tutto è prefissato `.lg` e usa i token del sito.
A differenza di Stress Index qui non c'è semaforo verde/ambra/mattone sui punteggi:
il servizio è premium ma non clinico, e un rosso su un punteggio del corpo sarebbe un
giudizio, non un'informazione. L'intensità di un punteggio si legge nell'opacità
dell'accento del sito (var(--lg-intensita), impostata inline dal componente che disegna
il punteggio), mai in un colore diverso. I due soli colori di segnale locali sono:
- --lg-tratteggio: il perimetro di ciò che non sappiamo ancora (un asse senza dati
sufficienti), non un errore;
- --lg-fuori-range: SOLO per una misura sospetta (es. un valore fisiologicamente
improbabile), mai per un punteggio — segnala un problema della misura, non della
persona. Riusa il mattone di Stress Index (--si-bad: #b05a4e). */
.lg {
--lg-line: #e4dfd7;
--lg-surface: #ffffff;
--lg-tratteggio: #d8d2c8;
--lg-fuori-range: #b05a4e;
display: flex;
justify-content: center;
min-height: 100vh;
background: var(--c-bg-alt);
}
/* --- guscio del referto: una colonna che scorre, senza navigazione --- */
.lg-shell {
width: 100%;
max-width: 760px;
min-height: 100vh;
background: var(--lg-surface);
display: flex;
flex-direction: column;
}
/* --- intestazione --- */
.lg-top {
padding: 18px 32px;
border-bottom: 1px solid var(--lg-line);
position: sticky;
top: 0;
z-index: 10;
background: var(--lg-surface);
}
.lg-top p { margin: 0; }
.lg-top__crumbs {
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-text-light);
}
.lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
.lg-main { flex: 1; padding: 34px 32px 70px; }
/* --- intestazione di pagina --- */
.lg-head { margin-bottom: 28px; }
.lg-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
.lg-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
/* --- la carta di un punteggio ---
Nessun colore «buono» o «cattivo»: l'intensità si legge nell'opacità dell'accento
(--lg-intensita, 0..1, impostata inline dal componente in base al punteggio). */
.lg-card { background: var(--lg-surface); border: 1px solid var(--lg-line); padding: 20px 22px; }
.lg-card--incompleto { border-style: dashed; border-color: var(--lg-tratteggio); }
.lg-score { display: flex; align-items: baseline; gap: 8px; }
.lg-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
.lg-score__max { font-size: .82rem; color: var(--c-text-light); }
.lg-score__label {
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
}
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
.lg-muted { color: var(--c-text-light); }
/* i numeri che si confrontano nel tempo non devono ballare a ogni cifra */
.lg-num { font-variant-numeric: tabular-nums; }
/* --- il referto: colonna di sezioni --- */
.lg-referto { display: flex; flex-direction: column; gap: 24px; }
/* --- il radar dei sette assi: area piena + perimetro tratteggiato ---
Il disegno vive nell'isola React (Radar.tsx); qui solo il testo sotto al grafico —
l'elenco di ciò che manca, che è la parte che rende il buco un invito. */
.lg-radar { display: flex; flex-direction: column; gap: 20px; }
.lg-radar__grafico { width: 100%; }
.lg-radar__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__mancano { display: flex; flex-direction: column; gap: 14px; }
.lg-radar__voce { border-top: 1px dashed var(--lg-tratteggio); padding-top: 12px; }
.lg-radar__voce-nome {
margin: 0 0 6px; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
color: var(--c-heading);
}
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__lista li { margin-bottom: 2px; }
/* --- le carte dei macro-score --- */
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
/* --- il questionario: un blocco alla volta, avanti/indietro --- */
.lg-form { display: flex; flex-direction: column; gap: 24px; }
.lg-form__passo {
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--c-text-light); margin: 0;
}
.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
.lg-field > span { color: var(--c-heading); font-weight: 500; }
.lg-field input[type="number"],
.lg-field textarea {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
}
.lg-field textarea { min-height: 68px; resize: vertical; }
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
.lg-form__nav button {
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
background: transparent; color: var(--c-heading); cursor: pointer;
}
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
@media (max-width: 640px) {
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
}
+310
View File
@@ -0,0 +1,310 @@
/* 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;
}
/* --- 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; }
/* --- analytics: intervallo e classifiche --- */
.si-range { display: flex; align-items: center; gap: 8px; }
.si-range__sep { color: var(--c-text-light); }
.si-range input { padding: 7px 10px; }
.si-card__head p { margin: 4px 0 0; }
.si-rank { list-style: none; margin: 0; padding: 0; }
.si-rank li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--si-line); }
.si-rank li:last-child { border-bottom: 0; }
.si-rank a { text-decoration: none; color: var(--c-heading); font-weight: 600; font-size: .9rem; }
.si-rank a:hover { color: var(--c-accent-dark); }
.si-rank__value { display: block; text-align: right; font-family: var(--font-heading); font-size: 1.05rem; }
.si-rank__value--ok { color: var(--si-ok); }
.si-rank__value--bad { color: var(--si-bad); }
.si-rank__count { display: block; text-align: right; font-size: .72rem; color: var(--c-text-light); }
/* --- modulo sport --- */
.si-badge--pro { background: var(--c-accent); color: #fff; vertical-align: middle; margin-left: 8px; }
.si-filters { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.si-filters__count { margin: 0 0 8px auto; }
.si-athlete__name { font-size: 1.05rem; text-transform: none; letter-spacing: .02em; margin: 0 0 2px; }
.si-athlete__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.si-athlete__stats > div { background: var(--c-bg-alt); padding: 14px 10px; text-align: center; }
.si-athlete__stats p { margin: 0; }
.si-athlete__num { font-family: var(--font-heading); font-size: 1.3rem; color: var(--c-heading); }
.si-athlete__foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;
padding-top: 14px; border-top: 1px solid var(--si-line); text-decoration: none;
font-size: .82rem; color: var(--c-text-light);
}
.si-athlete__foot:hover { color: var(--c-accent-dark); }