Files
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

1430 lines
63 KiB
Diff

# Review package: 779aef3..HEAD
## Commits
a56398e longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score
6630be7 longevity: la prova sul congelamento ora discrimina davvero il ricalcolo
7f01ee0 longevity: il referto del cliente, e il radar che dice cosa non sa
8c98075 longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts
f0629db longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
f5ca841 longevity: il questionario in pagina, e il consenso che si puo negare davvero
2ea822a longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto
34c66ca longevity: il guscio della piattaforma, senza semaforo sui punteggi
## Files changed
src/components/longevity/MacroScore.astro | 37 +++++
src/components/longevity/Questionario.tsx | 192 +++++++++++++++++++++++
src/components/longevity/Radar.tsx | 86 +++++++++++
src/layouts/Longevity.astro | 47 ++++++
src/lib/longevity/anagrafica.ts | 23 +++
src/lib/longevity/db.ts | 14 ++
src/lib/longevity/vista.ts | 227 ++++++++++++++++++++++++++++
src/pages/api/longevity/questionario.ts | 66 ++++++++
src/pages/longevity/index.astro | 7 +
src/pages/longevity/io.astro | 57 +++++++
src/pages/longevity/questionario.astro | 22 +++
src/styles/longevity.css | 141 +++++++++++++++++
tests/longevity/anagrafica.test.ts | 20 ++-
tests/longevity/pagine.test.ts | 96 ++++++++++++
tests/longevity/questionario-pagina.test.ts | 87 +++++++++++
tests/longevity/referto.test.ts | 103 +++++++++++++
16 files changed, 1223 insertions(+), 2 deletions(-)
## Diff
diff --git a/src/components/longevity/MacroScore.astro b/src/components/longevity/MacroScore.astro
new file mode 100644
index 0000000..2b220e3
--- /dev/null
+++ b/src/components/longevity/MacroScore.astro
@@ -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.
+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 ? 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>
+ {valore === null && (
+ <p class="lg-muted lg-macro__nota">
+ Copertura dati: {Math.round(copertura * 100)}% — non ancora sufficiente per un numero.
+ </p>
+ )}
+</div>
diff --git a/src/components/longevity/Questionario.tsx b/src/components/longevity/Questionario.tsx
new file mode 100644
index 0000000..5f8ae1e
--- /dev/null
+++ b/src/components/longevity/Questionario.tsx
@@ -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')}
+ />
+ Sì, 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>
+ );
+}
diff --git a/src/components/longevity/Radar.tsx b/src/components/longevity/Radar.tsx
new file mode 100644
index 0000000..26799c2
--- /dev/null
+++ b/src/components/longevity/Radar.tsx
@@ -0,0 +1,86 @@
+// 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">
+ <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">
+ <p className="lg-radar__intro">
+ L'area piena è quello che sappiamo davvero. Il perimetro tratteggiato si completa così:
+ </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>
+ );
+}
diff --git a/src/layouts/Longevity.astro b/src/layouts/Longevity.astro
new file mode 100644
index 0000000..8ec8396
--- /dev/null
+++ b/src/layouts/Longevity.astro
@@ -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>
diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts
index 5d60a0e..cfea267 100644
--- a/src/lib/longevity/anagrafica.ts
+++ b/src/lib/longevity/anagrafica.ts
@@ -1,16 +1,28 @@
//
// 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(
@@ -55,19 +67,30 @@ export function creaCliente(
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;
}
diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts
index 62737f1..3aa81b7 100644
--- a/src/lib/longevity/db.ts
+++ b/src/lib/longevity/db.ts
@@ -127,10 +127,24 @@ function apri(p: string, schema: string): Database.Database {
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;
+}
diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts
new file mode 100644
index 0000000..9a35ad7
--- /dev/null
+++ b/src/lib/longevity/vista.ts
@@ -0,0 +1,227 @@
+//
+// 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, QUEST_VERSION } from './questionario';
+import { leggiScore } 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 questionario —
+ * 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} — questionario ${QUEST_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.
+ */
+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 sessioneId = salvaCompilazione(db, {
+ client_code: input.client_code,
+ data: input.data,
+ eta: input.eta,
+ risposte: input.risposte,
+ liberi: input.liberi,
+ });
+
+ registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
+
+ return { ok: true, sessioneId };
+}
+
+//
+// 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 } {
+ 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;
+
+ return { assi, macro, fitnessAge };
+}
+
+/** 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;
+}
diff --git a/src/pages/api/longevity/questionario.ts b/src/pages/api/longevity/questionario.ts
new file mode 100644
index 0000000..7d8f470
--- /dev/null
+++ b/src/pages/api/longevity/questionario.ts
@@ -0,0 +1,66 @@
+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' } });
+
+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: qui infatti il corpo non viene mai letto per questo campo. La
+ // risoluzione passa da anagrafica.ts (l'unico modulo autorizzato a toccare quella
+ // connessione): questo file apre solo longevity, per salvare.
+ const codice = codicePerUtenteLoggato(locals.user!.id);
+ if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
+
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
+
+ const risposteIn = data.risposte;
+ if (typeof risposteIn !== 'object' || risposteIn === null) {
+ return json(400, { error: 'Risposte mancanti.' });
+ }
+ 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 = data.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 data.consensoSanitario === 'string' ? data.consensoSanitario : '';
+ const eta = typeof data.eta === 'number' && Number.isFinite(data.eta) ? data.eta : undefined;
+
+ // 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 });
+};
diff --git a/src/pages/longevity/index.astro b/src/pages/longevity/index.astro
new file mode 100644
index 0000000..34516da
--- /dev/null
+++ b/src/pages/longevity/index.astro
@@ -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');
+---
diff --git a/src/pages/longevity/io.astro b/src/pages/longevity/io.astro
new file mode 100644
index 0000000..6d2b992
--- /dev/null
+++ b/src/pages/longevity/io.astro
@@ -0,0 +1,57 @@
+---
+// 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';
+
+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="lg-card">
+ <p class="lg-score__label">Fitness Age</p>
+ <div class="lg-score">
+ <span class="lg-score__value lg-num">{referto.fitnessAge ?? '—'}</span>
+ {referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
+ </div>
+ </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>
+ </div>
+ )}
+</Longevity>
diff --git a/src/pages/longevity/questionario.astro b/src/pages/longevity/questionario.astro
new file mode 100644
index 0000000..57dd505
--- /dev/null
+++ b/src/pages/longevity/questionario.astro
@@ -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>
diff --git a/src/styles/longevity.css b/src/styles/longevity.css
new file mode 100644
index 0000000..daaac5c
--- /dev/null
+++ b/src/styles/longevity.css
@@ -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; }
+}
diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts
index bf232be..27fa181 100644
--- a/tests/longevity/anagrafica.test.ts
+++ b/tests/longevity/anagrafica.test.ts
@@ -81,35 +81,51 @@ describe('anagrafica pseudonimizzata', () => {
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
- it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
+ it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
+ // ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
+ // esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
+ // parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
+ // Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
+ // tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
+ // longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
+ // contiene singolarmente le due parole. Un controllo file-per-file non vede una
+ // giunzione che attraversa più file, e non c'è formulazione di questo test che lo
+ // possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
+ // Questo test resta come rete secondaria — cattura il caso comune (una giunzione
+ // dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
+ // esportata per errore — non come prova che la giunzione non possa esistere.
+ // Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
+ // come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
+ // un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
+ // sono nati dopo la prima versione di questo test e sono passati indisturbati).
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
for (const f of files) {
const path = join(srcDir, f);
// Salta node_modules e dist
if (f.includes('node_modules') || f.includes('dist')) continue;
// Accetta solo .ts e .astro
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
// Esclude i moduli autorizzati
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
const src = readFileSync(path, 'utf8');
- if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) {
+ if (src.includes('Identity') && src.includes('Longevity')) {
colpevoli.push(f);
}
}
expect(colpevoli).toEqual([]);
});
});
diff --git a/tests/longevity/pagine.test.ts b/tests/longevity/pagine.test.ts
new file mode 100644
index 0000000..c4ce3c7
--- /dev/null
+++ b/tests/longevity/pagine.test.ts
@@ -0,0 +1,96 @@
+// tests/longevity/pagine.test.ts
+import { describe, it, expect } from 'vitest';
+import { readFileSync, existsSync } from 'node:fs';
+import { join } from 'node:path';
+
+const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
+
+describe('il guscio della piattaforma', () => {
+ it('il layout esiste e non monta header e footer pubblici', () => {
+ const l = leggi('src/layouts/Longevity.astro');
+ expect(l).not.toMatch(/import\s+Header/);
+ expect(l).not.toMatch(/import\s+Footer/);
+ expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
+ });
+
+ it('il foglio di stile e globale e prefissato, non scoped', () => {
+ expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
+ const css = leggi('src/styles/longevity.css');
+ const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
+ expect(classi.length).toBeGreaterThan(3);
+ // "lg" da solo o "lg-qualcosa": il solo startsWith('lg') lascerebbe passare anche "lgfoo".
+ expect(classi.every((c) => c === 'lg' || c.startsWith('lg-'))).toBe(true);
+ });
+
+ it('usa i token del sito invece di inventare colori', () => {
+ const css = leggi('src/styles/longevity.css');
+ expect(css).toMatch(/var\(--c-/);
+ });
+
+ it('nessuna variabile porta un nome da semaforo (verde/buono/ok)', () => {
+ const css = leggi('src/styles/longevity.css').toLowerCase();
+ // il mattone del fuori range e ammesso; un verde "tutto bene" no
+ expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
+ });
+
+ it('in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate', () => {
+ // Il test sopra cerca nomi vietati: chi scrivesse un verde in esadecimale, o lo
+ // chiamasse con un nome innocuo (var(--lg-buona-salute)), passerebbe indisturbato.
+ // Qui si fa il contrario: si elenca ciò che PUÒ comparire, e qualunque altro colore —
+ // letterale (esadecimale, rgb(), nome CSS) o var() con un altro nome — è una
+ // regressione.
+ //
+ // PRIMA questo test filtrava le regole sul selettore ("solo quelle con 'lg-score' nel
+ // nome"): un elenco di QUALI regole guardare invecchia appena nasce una classe nuova —
+ // è successo con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3): non
+ // contengono la stringa "lg-score", quindi non venivano MAI controllate, in silenzio.
+ // Il foglio è interamente prefissato `lg-` per costruzione (verificato dal test sopra):
+ // non serve scegliere quali regole guardare, si guardano TUTTE, e le eccezioni
+ // legittime si dichiarano una per una — selettore, valore esatto, motivo — così un
+ // colore fuori lista entra solo con una decisione visibile, mai con un buco nel filtro.
+ const css = leggi('src/styles/longevity.css');
+ const AMMESSI = new Set([
+ '--c-accent', '--c-accent-dark', // l'accento del sito: l'unica leva per l'intensità
+ '--c-heading', '--c-text', '--c-text-light', // testo neutro, uguale ovunque nel sito
+ '--c-bg', '--c-bg-alt', // sfondi neutri
+ '--lg-line', '--lg-surface', // struttura locale, neutra
+ '--lg-tratteggio', // il perimetro di ciò che non sappiamo
+ '--lg-fuori-range', // SOLO una misura sospetta, mai un punteggio
+ ]);
+ // Eccezioni dichiarate: selettore esatto + valore esatto + perché non è un giudizio sul
+ // punteggio. Ogni riga qui è una decisione presa a vista, non un buco silenzioso.
+ const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [
+ {
+ selettore: '.lg-form__nav button:last-child:not(:only-child)',
+ valore: '#fff',
+ motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio",
+ },
+ ];
+ const NON_COLORE = /^(transparent|inherit|initial|unset|currentcolor|none)$/i;
+ const PROP_COLORE = /^(color|background|background-color|border(-\w+)?-color|outline-color|fill|stroke)$/i;
+
+ const violazioni: string[] = [];
+ for (const regola of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+ const selettore = regola[1].trim();
+ for (const dich of regola[2].split(';')) {
+ const due = dich.indexOf(':');
+ if (due < 0) continue;
+ const prop = dich.slice(0, due).trim().toLowerCase();
+ const val = dich.slice(due + 1).trim();
+ if (!PROP_COLORE.test(prop) || val === '' || NON_COLORE.test(val)) continue;
+ const varMatch = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,.*)?\)$/i);
+ if (varMatch && AMMESSI.has(varMatch[1])) continue;
+ if (ECCEZIONI.some((e) => e.selettore === selettore && e.valore === val)) continue;
+ violazioni.push(`${selettore} { ${prop}: ${val} }`);
+ }
+ }
+ expect(violazioni).toEqual([]);
+ });
+
+ it('la rotta del cliente e sotto /longevity, e rimanda davvero al referto', () => {
+ const p = join(process.cwd(), 'src/pages/longevity/index.astro');
+ expect(existsSync(p)).toBe(true);
+ const src = readFileSync(p, 'utf8');
+ expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
+ });
+});
diff --git a/tests/longevity/questionario-pagina.test.ts b/tests/longevity/questionario-pagina.test.ts
new file mode 100644
index 0000000..225de9b
--- /dev/null
+++ b/tests/longevity/questionario-pagina.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { createLongevityDb } from '../../src/lib/longevity/db';
+import { seedRegistro } from '../../src/lib/longevity/registro';
+import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
+
+function dbPronto() {
+ const db = createLongevityDb(':memory:');
+ seedRegistro(db);
+ db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
+ return db;
+}
+
+describe('i campi del questionario vengono dal registro', () => {
+ it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
+ const db = dbPronto();
+ const campi = campiDelQuestionario(db, '2026-08-22');
+ expect(campi.length).toBe(20);
+ const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
+ expect(alcol.etichetta).toMatch(/alcoliche/i);
+ expect(alcol.max).toBe(30);
+ });
+
+ it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
+ const db = dbPronto();
+ db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
+ const campi = campiDelQuestionario(db, '2026-08-22');
+ expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
+ expect(campi.length).toBe(19);
+ });
+});
+
+describe('il salvataggio, e il consenso', () => {
+ const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
+
+ it('salva una compilazione quando il consenso e dato', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'Si', liberi: {},
+ });
+ expect(esito.ok).toBe(true);
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
+ expect(n.n).toBe(1);
+ });
+
+ it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'No', liberi: {},
+ });
+ expect(esito.ok).toBe(false);
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
+ expect(n.n).toBe(0); // niente sessione, niente misure
+ });
+
+ it('NON salva se il consenso manca del tutto', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: '', liberi: {},
+ });
+ expect(esito.ok).toBe(false);
+ });
+
+ it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
+ const db = dbPronto();
+ salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'No', liberi: {},
+ });
+ const note = db.prepare(
+ `SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
+ ).all() as { campo_id: string; testo: string }[];
+ expect(note.length).toBeGreaterThan(0);
+ expect(note[0].testo).toMatch(/No/);
+ });
+
+ it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
+ const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
+ // il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
+ expect(src).toMatch(/codicePerUtente/);
+ expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
+ });
+});
diff --git a/tests/longevity/referto.test.ts b/tests/longevity/referto.test.ts
new file mode 100644
index 0000000..c128885
--- /dev/null
+++ b/tests/longevity/referto.test.ts
@@ -0,0 +1,103 @@
+// tests/longevity/referto.test.ts
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { createLongevityDb } from '../../src/lib/longevity/db';
+import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
+import { salvaCompilazione } from '../../src/lib/longevity/questionario';
+import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
+import { refertoDi } from '../../src/lib/longevity/vista';
+
+function conCompilazione() {
+ const db = createLongevityDb(':memory:');
+ seedRegistro(db); seedPesi(db, MODEL_VERSION);
+ db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
+ const s = salvaCompilazione(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
+ });
+ salvaScore(db, s, calcolaSessione(db, s));
+ return { db, s };
+}
+
+/**
+ * Come conCompilazione, con IN PIU' una risposta di questionario_sonno (q_riposato).
+ * Serve solo alla prova sul congelamento, che ha bisogno di un asse con PIU' sotto-domini
+ * pesati e copertura parziale non nulla — vedi il commento sopra la mutazione qui sotto per
+ * il perche' conCompilazione da sola non basta.
+ */
+function conCoperturaParziale() {
+ const db = createLongevityDb(':memory:');
+ seedRegistro(db); seedPesi(db, MODEL_VERSION);
+ db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
+ const s = salvaCompilazione(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte: {
+ q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
+ q_riposato: 8,
+ },
+ });
+ salvaScore(db, s, calcolaSessione(db, s));
+ return { db, s };
+}
+
+describe('il referto del cliente', () => {
+ it('restituisce i sette assi', () => {
+ const { db, s } = conCompilazione();
+ expect(refertoDi(db, s).assi.length).toBe(7);
+ });
+
+ it('un asse senza dati NON porta un numero', () => {
+ const { db, s } = conCompilazione();
+ const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
+ expect(forza.valore).toBeNull();
+ });
+
+ it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
+ const { db, s } = conCompilazione();
+ const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
+ expect(forza.mancano.length).toBeGreaterThan(0);
+ });
+
+ it('un asse coperto porta il suo numero', () => {
+ const { db, s } = conCompilazione();
+ const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
+ expect(stile.valore).not.toBeNull();
+ expect(stile.mancano).toEqual([]);
+ });
+
+ it('legge i punteggi congelati, non li ricalcola al volo', () => {
+ const { db, s } = conCoperturaParziale();
+ const prima = JSON.stringify(refertoDi(db, s));
+ // ATTENZIONE alla scelta della mutazione: non una qualunque, una che DISCRIMINA
+ // davvero un ricalcolo da una lettura congelata.
+ //
+ // 'Recupero & Sistema Nervoso' pesa QUATTRO sotto-domini (hrv 0.50, pressione 0.15,
+ // hrr 0.15, questionario_sonno 0.20); questa sessione ne misura UNO solo
+ // (questionario_sonno, via q_riposato in conCoperturaParziale) — copertura vera
+ // 0.20/1.00=0.20, sotto COPERTURA_MINIMA (0.4): l'asse e' 'insufficiente', ma con
+ // disponibili.length===1, non 0 — la formula del peso viene davvero eseguita, non
+ // bypassata dalla scorciatoia "nessun dato disponibile" di aggrega() in cascata.ts.
+ // Azzerando il peso di 'hrv' (un sotto-dominio ASSENTE) il totale scende a 0.50 e la
+ // copertura vera diventa 0.20/0.50=0.40: attraversa la soglia, l'asse passerebbe da
+ // insufficiente a ok SE il referto ricalcolasse (provato: vedi task-3-report.md).
+ //
+ // Una mutazione come questa NON discrimina, ed e' un errore da non ripetere:
+ // il peso dell'UNICO sotto-dominio di un asse a un solo elemento (es.
+ // 'questionario_lifestyle', 1.00 in 'Stile di Vita & Sonno') si semplifica sempre,
+ // sia al numeratore sia al denominatore della media pesata — cambiarlo non altera
+ // ne' il valore ne' la copertura, quindi il test resterebbe verde anche se
+ // refertoDi ricalcolasse davvero (era il bug di questo stesso test, corretto qui).
+ db.prepare(
+ `UPDATE pesi SET peso = 0 WHERE model_version = ? AND livello='asse' AND contenitore='Recupero & Sistema Nervoso' AND elemento='hrv'`
+ ).run(MODEL_VERSION);
+ expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
+ });
+
+ it('nessuna pagina interroga il database da sola', () => {
+ for (const f of ['src/pages/longevity/io.astro', 'src/pages/longevity/questionario.astro']) {
+ const src = readFileSync(join(process.cwd(), f), 'utf8');
+ expect(src, `${f} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
+ }
+ });
+});