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>
This commit is contained in:
2026-10-06 10:36:26 +02:00
parent 9126af28ce
commit b913a2bf90
94 changed files with 40294 additions and 0 deletions
@@ -0,0 +1,249 @@
# SDD ledger — plan: docs/plans/2026-08-22-longevity-interfaccia.md
Spec: docs/specs/2026-08-21-longevity-design.md
Repo: InsanityLab-web — branch `feat/longevity` (main fermo a ecab678)
Piani precedenti eseguiti: strato dati (2026-08-21), motore (2026-08-22)
## Scansione pre-volo
| Task A | Task B | Produce → Consuma | Esito |
|---|---|---|---|
| 1 guscio | 2, 3 | layout `Longevity.astro`, classi `.lg-*` | OK |
| 2 vista.ts | 3 | il file viene creato da 2 ed esteso da 3 | ⚠️ vedi ruling |
| 2 endpoint | 3 | nessuna dipendenza diretta | OK |
| 3 refertoDi | nessuno | ultimo task del piano | OK |
| Task | Coerenza interna | Esito |
|---|---|---|
| 1 | 5 test, tutti su file e contenuto — nessuno esegue una pagina | ⚠️ vedi ruling |
| 2 | 7 test; tre riguardano il consenso, che è il punto del task | OK |
| 3 | 6 test; quello sul congelamento discrimina cambiando i pesi | OK |
## Ruling: `vista.ts` è creato dal Task 2 ed esteso dal Task 3
Non è un conflitto — i task sono sequenziali e nessuno gira in parallelo — ma va detto:
il Task 3 **aggiunge** `refertoDi` a un file che il Task 2 ha già scritto. Chi esegue il 3
non deve riscrivere il file da zero.
## Ruling: i test del Task 1 controllano il sorgente, non il comportamento
Astro non si esegue in vitest senza montare la pipeline di Vite. I test del guscio quindi
leggono i file e ne verificano proprietà — nessun header pubblico, classi prefissate, niente
semaforo. È una rete debole ma vera: intercetta la regressione che conta (qualcuno che
aggiunge un colore «buono»/«cattivo» ai punteggi). Il comportamento vero delle pagine si
vede col piano successivo, quando ci sarà una sessione reale da mostrare.
Costo se sbagliato: una pagina può rompersi senza che un test se ne accorga. Mitigazione:
`npx tsc --noEmit` deve restare pulito, ed è un vincolo globale del piano.
## Modelli scelti
Implementer: sonnet — c'è React, Astro e integrazione con tre moduli esistenti.
Reviewer: sonnet. Review finale: opus.
## Task 1 — il test anti-semaforo protegge il nome, non l'intento (sesta volta)
Il revisore non si è fidato: ha **simulato tre regressioni** sul CSS vero e ha guardato quali
passano.
| Regressione simulata | Il test se ne accorge? |
|---|---|
| `.lg-score--alto { color: #6f8f6a; }` (verde scritto in esadecimale, lo stesso di Stress Index) | **no** |
| `.lg-score--alto { color: var(--lg-buona-salute); }` (variabile con altro nome) | **no** |
| `--lg-verde-scuro: #6f8f6a;` (contiene una delle tre parole vietate) | sì |
Il test cerca tre nomi letterali. Chiunque reintroducesse il semaforo con un colore scritto
direttamente, o con una variabile battezzata diversamente, passerebbe indisturbato — ed è
proprio il modo più naturale di farlo.
📌 **È la sesta volta in questa sessione** che compare lo stesso difetto, e ancora una volta
il test veniva dal brief che ho scritto io. La forma è sempre quella: **proteggo il nome di
una cosa invece della cosa**. Qui: i tre nomi vietati invece del colore.
**Ruling: rendere il controllo strutturale.** Non un elenco di nomi proibiti, ma il verso
opposto — nelle regole che riguardano i punteggi possono comparire **solo** i colori
ammessi (l'accento del sito, il tratteggio, il fuori range). Tutto il resto è una
regressione, che si chiami come si chiami.
Costo se sbagliato: un test più severo può dare falsi allarmi quando il foglio cresce; è
preferibile a una rete che lascia passare esattamente ciò per cui esiste.
Task 1: implementato (commit 34c66ca) + fix round 1/5 (test reso strutturale; commit 2ea822a)
Task 1: verificato in prima persona — iniettando un verde esadecimale su una classe di punteggio il test nuovo diventa rosso, mentre il vecchio lo lasciava passare
Task 1: l'implementer ha tenuto ANCHE il vecchio controllo sui nomi, notando che intercetta un caso che il nuovo non vede: un token dichiarato e mai usato. Osservazione giusta, i due controlli sono complementari.
Task 1: complete (commits 779aef3..2ea822a, review clean dopo 1 round)
## Task 2 — la garanzia di separazione è stata aggirata al PRIMO endpoint
L'implementer ha aggiunto in `db.ts` due funzioni comode, `getLongevityDb()` e
`getIdentityDb()` (stesso pattern del `getDb()` del sito), e l'endpoint del questionario le
importa **entrambe**. È un secondo punto di giunzione fuori da `anagrafica.ts`, cioè
esattamente ciò che la spec §4 vieta.
⚠️ **E il test che dovrebbe impedirlo non se ne accorge**, perché cerca i nomi
`createIdentityDb` e `createLongevityDb`: i nomi nuovi non sono sorvegliati da nessuno.
📌 Era stato **previsto**. La revisione del Task 4 dello strato dati diceva: «il test rileva
solo la violazione più ingenua — chi chiama direttamente le due factory — non quella che
imita il modulo autorizzato». Allora l'avevo parcheggiata come improbabile. Si è avverata al
**primo endpoint scritto**, tre piani dopo.
Va detto che l'uso è corretto nella sostanza: l'endpoint chiama `codicePerUtente`, che sta
in `anagrafica.ts`, quindi la **logica** di giunzione è al posto giusto. È l'**apertura**
delle due connessioni a essere fuori posto — e il rischio non è oggi, è il giorno in cui
qualcuno, da quell'endpoint che già ha entrambe le connessioni in mano, scrive una query che
le incrocia.
**Ruling: due correzioni, e la seconda vale più della prima.**
1. `anagrafica.ts` espone una funzione che risolve il codice cliente aprendo lei ciò che
serve; l'endpoint apre **solo** longevity.
2. Il test anti-giunzione va esteso ai nomi nuovi — e scritto in modo che **qualunque**
coppia di accessi ai due database venga vista, non tre nomi elencati a mano. È la stessa
lezione del semaforo del Task 1: si protegge la cosa, non il nome della cosa.
Costo se sbagliato: una funzione in più in anagrafica.ts. Il costo di non farlo è che la
garanzia centrale del progetto resta scritta nella spec e assente dal codice.
Task 2: implementato (commit f5ca841) + fix round 1/5 (endpoint apre solo longevity, test anti-giunzione strutturale; commit f0629db)
Task 2: verificato in prima persona con una TERZA coppia di nomi, diversa sia dalla vecchia sia dalla nuova: il test la rileva e la nomina. Protegge la cosa, non i nomi.
Task 2: in revisione
## Task 2 — il test anti-giunzione NON basta, e la cura non è un test migliore
Avevo chiesto al revisore: «il vincolo regge perché il test lo sorveglia, o perché nessuno
l'ha ancora usato male?». Risposta: **la seconda**. E l'ha dimostrata, con un pattern che
non ha niente di malizioso: tre file, una connessione riesportata da un modulo e usata
insieme all'altra in un terzo. **Nessuno dei tre contiene entrambe le parole**, quindi il
test resta verde mentre la giunzione esiste davvero.
📌 **Settima volta** che protegge il nome invece della cosa — e stavolta la mia correzione
precedente (cercare le due stringhe invece dei due nomi di funzione) era ancora dello stesso
tipo, solo un gradino più su. Un controllo testuale file per file non può vedere una
giunzione che attraversa due file.
**Ruling: si smette di sorvegliare e si rende impossibile.**
La connessione a `identity` non deve essere ottenibile da nessuna parte tranne che dentro
`anagrafica.ts`. Oggi `db.ts` la espone a chiunque con `getIdentityDb()`: quella funzione
esce da lì e diventa **privata** al modulo autorizzato. Chi ha bisogno di risolvere un
codice cliente chiama una funzione di `anagrafica.ts` — che è già ciò che fa l'endpoint dopo
il fix precedente.
Così il vincolo non dipende più solo da un guardiano aggirabile: la via ovvia — il singleton
condiviso — non è più raggiungibile.
⚠️ **CORREZIONE (revisione finale):** avevo scritto «non esiste il modo di avere entrambe le
connessioni». **È troppo forte, ed è falso.** `createIdentityDb` resta esportata e senza
argomenti apre lo **stesso file** del singleton: il bypass a tre file funziona ancora, compila
e la suite resta verde. L'attrito è aumentato, l'impossibilità no. Va detto com'è. Il test resta come
rete secondaria, con il suo limite scritto accanto.
⚠️ `createIdentityDb` resta esportata: serve ai test per creare database in memoria, e lì la
giunzione è voluta e visibile. È il **singleton condiviso** il problema, non la fabbrica.
Costo se sbagliato: se un domani servisse leggere identity da un altro punto legittimo,
bisognerà aggiungere una funzione ad `anagrafica.ts` invece di aprire una connessione. È
esattamente l'attrito che vogliamo.
Task 2: fix round 2/5 (getIdentityDb privata in anagrafica.ts, try/catch nell'endpoint, campi testuali; commit 8c98075)
Task 2: RIPROVATO io il bypass a tre file che prima passava indisturbato — ora non compila:
"'db' has no exported member named 'getIdentityDb'". Il test resta verde (e dichiara il
proprio limite), ma la difesa vera è che il codice che aggira la garanzia non esiste più.
📌 La lezione di tutta la sessione, in una riga: quando una regola conta, non la si sorveglia —
si costruisce il codice in modo che violarla non sia un'opzione. Stessa forma della soluzione
nel motore, dove il punteggio insufficiente ha smesso di AVERE un campo valore.
Task 2: complete (commits 2ea822a..8c98075, review clean dopo 2 round)
Task 2: minor (deferred): il test sulla nota di consenso verifica l'esito ma non che vi compaiano versione e data, che il codice scrive comunque
## Task 3 — l'ottava volta, e stavolta l'ha trovata l'implementer
Il test sul congelamento che avevo scritto **non poteva funzionare**, e la ragione è
algebrica, non di programmazione: la mutazione che avevo scelto cambia il peso di
`questionario_lifestyle`, che è l'**unico** sotto-dominio del suo asse. In una media pesata
con un elemento solo il peso si semplifica — `(v × p) / p = v` per il valore, `p / p = 1`
per la copertura — quindi il risultato è identico con qualunque peso, letto o ricalcolato.
Verificato:
peso 1.00 -> valore 72, copertura 1
peso 0.99 -> valore 72, copertura 1
peso 0.50 -> valore 72, copertura 1
Il test passava per un'identità algebrica, non perché il congelamento funzionasse.
L'implementer l'ha scoperto **facendo la prova che gli avevo chiesto**: ha rotto `refertoDi`
perché ricalcolasse invece di leggere, e ha visto il test restare verde. Poi invece di
girarci intorno ha trovato la mutazione che discrimina davvero — il peso di un sotto-dominio
**assente** in un asse a più elementi con copertura parziale — e ha verificato che lì il test
rotto diventa rosso. Ha lasciato il test del brief com'era, perché così diceva l'istruzione,
e ha scritto la falla nel rapporto con la causa esatta.
📌 **Ottava volta**, e la forma è sempre quella: scelgo un caso di prova su cui
l'implementazione giusta e quella sbagliata danno lo stesso risultato. Qui non per
distrazione ma per un motivo che si vede solo facendo il conto — ed è esattamente perché la
prova di rottura va **eseguita**, non immaginata.
**Ruling: si sostituisce la mutazione con quella discriminante.** Il test deve fallire se il
referto ricalcola invece di leggere; oggi non lo fa.
Task 3: implementato (commit 7f01ee0) + fix round 1/5 (mutazione discriminante; commit 6630be7)
Task 3: riprovato io il sabotaggio sul test corretto — forzando il ricalcolo diventa rosso e nomina il problema. Ora protegge.
Task 3: nota tecnica dell'implementer da tenere — `mancano` non poteva venire dalla colonna `asse` del registro, perché lì oggi ci sono solo i 20 campi del questionario e nessuna riga per i sotto-domini fisici. L'ha costruito confrontando i sotto-domini PESATI di un asse con quelli effettivamente misurati.
Task 3: in revisione
## Task 3 — nona volta, ed è colpa della MIA correzione precedente
Il test anti-semaforo, che nel Task 1 avevo fatto irrobustire, filtra le regole CSS su
`lg-score`. Ma questo task ha aggiunto `.lg-radar*` e `.lg-macro*`, che quella stringa non
la contengono: **restano fuori dal controllo**. Il revisore l'ha dimostrato iniettando
`color: #ff0000` in `.lg-radar__voce-nome` — sei test verdi, violazione invisibile.
📌 La forma è sempre la stessa, e stavolta l'ho introdotta io correggendo: sono passato da
**un elenco di nomi vietati** a **un elenco di prefissi da controllare**. Un elenco resta un
elenco, e invecchia appena qualcuno aggiunge una classe.
**Ruling: si gira di nuovo, e stavolta fino in fondo.** Il foglio è interamente prefissato
`lg-` per costruzione — quindi il controllo non deve scegliere *quali* regole guardare, ma
guardarle **tutte** e dichiarare le eccezioni. Una regola nuova entra sotto controllo per il
solo fatto di esistere, invece che per essersi ricordati di aggiungerla a un elenco.
Costo se sbagliato: qualche eccezione da dichiarare quando il foglio crescerà — ed è
l'attrito giusto, perché dichiarare un'eccezione è una decisione visibile, dimenticare di
aggiungere un prefisso non lo è.
Task 3: fix round 2/5 (il controllo guarda tutto il foglio, non un elenco di prefissi; commit a56398e)
Task 3: prova definitiva fatta da me — una classe INVENTATA sul momento, che non esisteva quando il
test è stato scritto, con un verde dentro: il test la vede e la nomina. Non protegge più un
elenco, protegge il foglio.
Task 3: complete (commits 8c98075..a56398e, review clean dopo 2 round)
Task 3: minor (deferred): `mancano` usa la MODEL_VERSION corrente invece di quella congelata sulla riga di score — ininfluente oggi (ne esiste una sola), rilevante quando il modello cambierà
TUTTI E TRE I TASK DELL'INTERFACCIA COMPLETI. Suite 327 verdi, typecheck pulito.
## REVISIONE FINALE — verdetto: NO, e il difetto è nel piano che ho scritto io
**Nessuno chiama il motore.** Il Task 2 salva la compilazione, il Task 3 legge i punteggi, e
**fra i due non c'è niente**: il piano non prevedeva il passo che li calcola. Verificato in
prima persona eseguendo il flusso vero con tutte e venti le risposte compilate:
righe in score dopo la compilazione: 0
assi che mostrano un numero: 0 su 7
Il cliente compilerebbe venti domande e vedrebbe sette assi vuoti, tre trattini e
«copertura dati 0%». Non è un referto parziale che si spiega: **è una pagina che dichiara il
falso sui dati che ha appena ricevuto**.
📌 È il difetto della composizione allo stato puro: ogni task corretto e revisionato, il
prodotto non funziona. Nessuna revisione per task poteva vederlo, perché i test del Task 3
chiamano `salvaScore` dentro la propria fixture — cioè fanno a mano ciò che in produzione non
fa nessuno.
### Altri quattro test che non proteggono niente (mutazioni eseguite dal revisore)
| # | Mutazione | Esito |
|---|---|---|
| 10 | il `client_code` preso dal corpo della richiesta | **verde** — il test elenca `body.`/`corpo.`, ma il file chiama il corpo `data`: **vacuo per costruzione** |
| 11 | rimossa la registrazione del consenso **accettato** | **verde** — è coperto solo il ramo del rifiuto |
| 12 | una pagina nuova con `SELECT` dentro | **verde** — il test elenca due nomi di file |
| 13 | giunzione a tre file via `createIdentityDb()` | **verde**, e compila |
La #10 è la decima ricorrenza della stessa forma, ed è la peggiore: la regola §8 —
*«il client_code si ricava dalla sessione, mai dalla richiesta»* — **oggi non è sorvegliata
da niente**, e un cliente potrebbe scrivere nel fascicolo di un altro.
### Un canale di re-identificazione aperto proprio da questo piano
`profilo_note` accoglie testo libero, e questo è il primo lavoro in cui **una persona ci
scrive dentro**. Un cliente che scrive «sono Mario Rossi, prendo…» mette il proprio nome in
`longevity.db`, che la §7 dichiara **essere** l'export per le statistiche. Lo strato dati ha
costruito la tabella, l'interfaccia ha aperto il rubinetto: nessuna revisione per task poteva
vederlo.
@@ -0,0 +1,669 @@
# Review package: 2ea822a..HEAD
## Commits
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
## Files changed
src/components/longevity/Questionario.tsx | 174 ++++++++++++++++++++++++++++
src/lib/longevity/anagrafica.ts | 12 ++
src/lib/longevity/db.ts | 15 +++
src/lib/longevity/vista.ts | 99 ++++++++++++++++
src/pages/api/longevity/questionario.ts | 55 +++++++++
src/pages/longevity/questionario.astro | 22 ++++
src/styles/longevity.css | 32 +++++
tests/longevity/anagrafica.test.ts | 12 +-
tests/longevity/questionario-pagina.test.ts | 87 ++++++++++++++
9 files changed, 506 insertions(+), 2 deletions(-)
## Diff
diff --git a/src/components/longevity/Questionario.tsx b/src/components/longevity/Questionario.tsx
new file mode 100644
index 0000000..e60cfb6
--- /dev/null
+++ b/src/components/longevity/Questionario.tsx
@@ -0,0 +1,174 @@
+// 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]
+ );
+
+ 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)) {
+ 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>
+ <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/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts
index 5d60a0e..9bf8eb3 100644
--- a/src/lib/longevity/anagrafica.ts
+++ b/src/lib/longevity/anagrafica.ts
@@ -1,16 +1,17 @@
//
// 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 { getIdentityDb } from './db';
/**
* 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 +56,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..a1cd251 100644
--- a/src/lib/longevity/db.ts
+++ b/src/lib/longevity/db.ts
@@ -127,10 +127,25 @@ 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);
}
+
+// Connessioni singole per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
+// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
+let longevitySingleton: Database.Database | null = null;
+let identitySingleton: Database.Database | null = null;
+
+export function getLongevityDb(): Database.Database {
+ if (!longevitySingleton) longevitySingleton = createLongevityDb();
+ return longevitySingleton;
+}
+
+export function getIdentityDb(): Database.Database {
+ if (!identitySingleton) identitySingleton = createIdentityDb();
+ return identitySingleton;
+}
diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts
new file mode 100644
index 0000000..4acc370
--- /dev/null
+++ b/src/lib/longevity/vista.ts
@@ -0,0 +1,99 @@
+//
+// 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 } from './registro';
+import { salvaCompilazione, QUEST_VERSION } from './questionario';
+
+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 };
+}
diff --git a/src/pages/api/longevity/questionario.ts b/src/pages/api/longevity/questionario.ts
new file mode 100644
index 0000000..5f0909e
--- /dev/null
+++ b/src/pages/api/longevity/questionario.ts
@@ -0,0 +1,55 @@
+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);
+
+ const esito = salvaDalForm(getLongevityDb(), {
+ client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
+ });
+
+ if (!esito.ok) return json(422, { error: esito.errore });
+ return json(201, { sessioneId: esito.sessioneId });
+};
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
index 32362e3..d87b2f7 100644
--- a/src/styles/longevity.css
+++ b/src/styles/longevity.css
@@ -72,13 +72,45 @@
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); }
+/* --- 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..4dfcd0f 100644
--- a/tests/longevity/anagrafica.test.ts
+++ b/tests/longevity/anagrafica.test.ts
@@ -81,35 +81,43 @@ 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('nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
+ // Non cerchiamo due nomi di funzione precisi (createIdentityDb/createLongevityDb): un
+ // domani qualcuno aggiunge getIdentityDb, o un terzo accessore con un nome diverso, e
+ // l'elenco invecchia senza che nessuno se ne accorga (è già successo: getIdentityDb e
+ // getLongevityDb sono nati DOPO questo test e sono passati indisturbati). Il verso
+ // giusto è strutturale: qualunque accesso alle due connessioni passa comunque per una
+ // funzione esportata da db.ts il cui nome contiene "Identity" o "Longevity" — quindi
+ // basta cercare quei due riferimenti letterali, in QUALUNQUE forma compaiano, e
+ // segnalare i file che li hanno tutti e due.
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/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/);
+ });
+});
@@ -0,0 +1,213 @@
# Review package: 779aef3..HEAD
## Commits
34c66ca longevity: il guscio della piattaforma, senza semaforo sui punteggi
## Files changed
src/layouts/Longevity.astro | 47 +++++++++++++++++++++++
src/pages/longevity/index.astro | 7 ++++
src/styles/longevity.css | 84 +++++++++++++++++++++++++++++++++++++++++
tests/longevity/pagine.test.ts | 38 +++++++++++++++++++
4 files changed, 176 insertions(+)
## Diff
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/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/styles/longevity.css b/src/styles/longevity.css
new file mode 100644
index 0000000..32362e3
--- /dev/null
+++ b/src/styles/longevity.css
@@ -0,0 +1,84 @@
+/* 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); }
+
+@media (max-width: 640px) {
+ .lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
+}
diff --git a/tests/longevity/pagine.test.ts b/tests/longevity/pagine.test.ts
new file mode 100644
index 0000000..5608a53
--- /dev/null
+++ b/tests/longevity/pagine.test.ts
@@ -0,0 +1,38 @@
+// 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);
+ expect(classi.every((c) => 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('nessun semaforo sui punteggi: il colore non giudica il corpo', () => {
+ 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('la rotta del cliente e sotto /longevity, dove le regole la proteggono', () => {
+ expect(existsSync(join(process.cwd(), 'src/pages/longevity/index.astro'))).toBe(true);
+ });
+});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,540 @@
# Review package: 8c98075..HEAD
## Commits
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
## Files changed
src/components/longevity/MacroScore.astro | 37 +++++++++
src/components/longevity/Radar.tsx | 86 ++++++++++++++++++++
src/lib/longevity/vista.ts | 130 +++++++++++++++++++++++++++++-
src/pages/longevity/io.astro | 57 +++++++++++++
src/styles/longevity.css | 25 ++++++
tests/longevity/referto.test.ts | 103 +++++++++++++++++++++++
6 files changed, 437 insertions(+), 1 deletion(-)
## 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/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/lib/longevity/vista.ts b/src/lib/longevity/vista.ts
index 4acc370..9a35ad7 100644
--- a/src/lib/longevity/vista.ts
+++ b/src/lib/longevity/vista.ts
@@ -2,22 +2,23 @@
// 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 } from './registro';
+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;
};
@@ -90,10 +91,137 @@ export function salvaDalForm(
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/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/styles/longevity.css b/src/styles/longevity.css
index d87b2f7..daaac5c 100644
--- a/src/styles/longevity.css
+++ b/src/styles/longevity.css
@@ -72,20 +72,45 @@
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; }
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);
+ }
+ });
+});
@@ -0,0 +1,241 @@
# Review finale interfaccia Longevity — report
Repo: `/data/Sorgenti/Clienti/InsanityLab/WebSite/insanitylab-website`
Branch: `feat/longevity` (nessun checkout, nessun push)
Procedura: TDD per ogni correzione (test rosso → codice → verde), e per ogni test nuovo
un tentativo di romperlo per confermare che discrimina davvero.
## Stato di partenza (verificato prima di toccare codice)
```
$ npm test
...
Test Files 1 failed | 39 passed (40)
Tests 1 failed | 327 passed (328)
$ npx tsc --noEmit
(nessun output)
```
Il fallito era `tests/modifiche-agosto.test.ts` (fuori perimetro), atteso.
## 1. Il blocco — nessuno chiama il motore
**Test scritto per primo** (`tests/longevity/salvataggio-e2e.test.ts`): parte da
`salvaDalForm` (la funzione che chiama l'endpoint reale) con venti risposte, arriva a
`refertoDi`, non chiama mai `calcolaSessione`/`salvaScore` a mano.
Rosso prima del fix:
```
FAIL tests/longevity/salvataggio-e2e.test.ts > ... > venti risposte compilate producono un referto ...
AssertionError: expected 0 to be greater than 0
❯ tests/longevity/salvataggio-e2e.test.ts:46:26
| expect(righeScore.n).toBeGreaterThan(0);
```
**Fix**: `src/lib/longevity/vista.ts`, `salvaDalForm` — dentro un'unica `db.transaction`,
dopo `salvaCompilazione`: `salvaScore(db, sessioneId, calcolaSessione(db, sessioneId))`.
`better-sqlite3` annida automaticamente con savepoint le transazioni interne di
`salvaCompilazione` e `salvaScore`.
Verde dopo il fix, suite longevity completa: 148→ poi 162 test (vedi sotto), tutti verdi.
## 2. Il codice cliente può ancora arrivare dal browser
**Verificato il difetto del vecchio test** (regex su `body.client_code`/`corpo.client_code`,
ma il file usa la variabile `data`):
```
# iniettata la vulnerabilità vera in src/pages/api/longevity/questionario.ts:
# if (typeof data.client_code === 'string') codice = data.client_code;
$ npx vitest run tests/longevity/questionario-pagina.test.ts
Test Files 1 passed (1)
Tests 7 passed (7) ← suite tutta verde CON la vulnerabilità presente
```
**Fix strutturale**: `src/pages/api/longevity/questionario.ts` — nuovo tipo
`CorpoQuestionario` (`risposte`, `liberi`, `consensoSanitario`, `eta`; **niente**
`client_code`) e `corpoDaJson()` che estrae solo quei campi. Verificato che il tipo
blocca davvero l'errore a compile-time:
```
# aggiunta `console.log(corpo.client_code)` dopo l'estrazione:
$ npx tsc --noEmit
src/pages/api/longevity/questionario.ts(73,21): error TS2339: Property 'client_code'
does not exist on type 'CorpoQuestionario'.
```
**Nuovo test comportamentale** (sostituisce quello vacuo, in
`tests/longevity/questionario-pagina.test.ts`): chiama l'endpoint `POST` vero
(dbs file-backed via `IDENTITY_DB_PATH`/`LONGEVITY_DB_PATH`, due clienti veri) con un
`client_code` estraneo nel corpo, verifica che la sessione finisca sotto il cliente
della sessione, non quello iniettato.
Rosso con la vulnerabilità reintrodotta:
```
AssertionError: expected 'ISL-0002' to be 'ISL-0001'
```
Verde con il fix.
## 3. Il consenso: tre difetti
### (a) Consenso dato non testato
Verificato: cancellando `registraConsenso(...)` dal ramo affermativo di `salvaDalForm`,
`npx vitest run tests/longevity` restava **148/148 verdi** (nessun rosso).
**Test aggiunto** in `questionario-pagina.test.ts`: verifica 1 riga in `profilo_note`
con `campo_id='consenso_sanitario'` e `sessione_id` corretto dopo un salvataggio
affermativo. Rosso con la riga rimossa, verde col fix presente.
### (b) Consenso fuori transazione
**Test aggiunto**: `tests/longevity/consenso-atomico.test.ts`, mocka `calcolaSessione`
(via `vi.mock`) per farlo lanciare DOPO che sessione e misure sono già scritte, verifica
che sessione/misure/score/consenso tornino TUTTI a zero.
Rosso contro il codice originale (pre-fix, dove `salvaDalForm` non chiamava nemmeno il
motore):
```
AssertionError: expected [Function] to throw an error
```
(il mock di `calcolaSessione` non veniva mai invocato — prova indiretta che anche il
punto 1 era assente). Verde con il fix (transazione unica in `salvaDalForm`).
### (c) Versione sbagliata sul consenso
Aggiunta `CONSENT_VERSION` in `src/lib/longevity/questionario.ts`, accanto a
`QUEST_VERSION`. `registraConsenso` (`vista.ts`) ora scrive `consenso ${CONSENT_VERSION}`
invece di `questionario ${QUEST_VERSION}`.
**Test aggiunto**, rotto e ripristinato:
```
# tornato a QUEST_VERSION nella nota:
AssertionError: expected 'Consenso al trattamento dei dati sani…' to contain 'consenso v1.0'
Received: "...— questionario v1.0, registrato il 2026-08-22"
```
## 4. La prima apertura non comunica
Nuovo file `tests/longevity/prima-apertura.test.ts` (7 test, basati su lettura del
sorgente — non esiste un harness di rendering Astro/React in questo repo e non erano
ammesse dipendenze nuove), tutti scritti PRIMA del fix e verificati rossi:
```
$ npx vitest run tests/longevity/prima-apertura.test.ts (prima del fix)
Tests 7 failed (7)
```
Fix:
- `src/pages/longevity/io.astro` — carta Fitness Age: bordo tratteggiato +
"Copertura dati: X%" anche quando manca (nuovo campo `fitnessAgeCopertura` esposto da
`refertoDi`, `vista.ts`); numero formattato con `formattaNumero`.
- `src/components/longevity/Radar.tsx` — paragrafo di spiegazione spostato PRIMA del
grafico; aggiunta frase "Questi test si fanno in sala, con il trainer: prenota la
prossima sessione..." sopra l'elenco di ciò che manca.
- `src/components/longevity/MacroScore.astro` — riga "Copertura dati: X%" mostrata
SEMPRE (non solo quando il valore manca); valore formattato con `formattaNumero`.
- `src/lib/longevity/formato.ts` (nuovo) — `formattaNumero(n)` = `n.toLocaleString('it-IT')`,
virgola decimale. Le etichette PERFORMANCE/ENERGY/RECOVERY non sono state tradotte
(nomi di prodotto).
Ogni singola assunzione (7) è stata rotta manualmente per verificare che il test
corrispondente diventasse rosso, poi ripristinata:
```
$ npx vitest run tests/longevity/prima-apertura.test.ts (con MacroScore.astro riportato al vecchio comportamento)
Tests 2 failed | 5 passed (7)
$ npx vitest run tests/longevity/prima-apertura.test.ts (con io.astro riportato al vecchio comportamento)
Tests 3 failed | 4 passed (7)
$ npx vitest run tests/longevity/prima-apertura.test.ts (con Radar.tsx riportato al vecchio comportamento)
Tests 2 failed | 5 passed (7)
```
Tutti e 7 i sotto-controlli discriminano davvero. Ripristinato il codice fissato dopo
ogni prova (diff a zero contro la versione corretta, verificato con `diff`).
Anche `refertoDi` (dato, non solo template) ha un test dedicato in `referto.test.ts`:
`fitnessAgeCopertura` deve restare leggibile anche quando lo stato è insufficiente —
rosso prima (`undefined`), verde dopo (`0`).
## 5. Chi ha compilato non può più ricompilare
Collegamento aggiunto in fondo al referto (`io.astro`, dentro `{referto && (...)}`):
link a `/longevity/questionario`. Coperto dallo stesso file `prima-apertura.test.ts`
(ultimo describe), rosso prima del fix, verde dopo, verificato rompendolo e
ripristinandolo (vedi sopra, terzo blocco "3 failed").
## 6. Due controlli che elencano invece di guardare
### (a) Query nelle pagine
`tests/longevity/referto.test.ts`, test "nessuna pagina interroga il database da sola":
prima elencava due file a mano. Provato il buco: creata `prova-query.astro` con
`db.prepare('SELECT 1')` dentro `src/pages/longevity/`, la suite restava verde. Riscritto
per scandire **tutte** le `.astro` sotto `src/pages/longevity/` con `readdirSync`
ricorsivo. Riprovato lo stesso file spurio: ora il test va rosso; rimosso il file, torna
verde.
### (b) Giunzione identity/longevity via `createIdentityDb`
Come richiesto, **non** ho provato a chiudere il buco con un test più furbo (già tentato
e aggirato due volte, secondo il commento esistente). Ho verificato empiricamente il buco
reale: un file che chiama `createIdentityDb()` (esportata, a differenza del singleton
privato `getIdentityDb`) e apre `longevity.db` con una connessione propria — mai col nome
`getLongevityDb`/`createLongevityDb`, quindi senza mai scrivere "Longevity" con
l'iniziale maiuscola — passa indisturbato:
```
$ npx vitest run tests/longevity/anagrafica.test.ts (con il file-sonda in src/lib/longevity/)
Test Files 1 passed (1)
Tests 7 passed (7)
```
Corretto **solo il commento** in `tests/longevity/anagrafica.test.ts` per dichiarare
onestamente questo buco specifico (createIdentityDb esportata per `creaCliente`, quindi
non chiudibile come si è chiuso `getIdentityDb` senza cambiare quella funzione). Nessuna
modifica alla logica del test.
## Verifica finale
```
$ npm test
Test Files 1 failed | 43 passed (44)
Tests 1 failed | 343 passed (344)
$ npx tsc --noEmit
(nessun output — pulito)
```
Il solo fallito resta `tests/modifiche-agosto.test.ts` (fuori perimetro, atteso). 16 test
nuovi rispetto al punto di partenza (328 → 344), nessuno regredito.
## File toccati
```
M src/components/longevity/MacroScore.astro
M src/components/longevity/Radar.tsx
M src/lib/longevity/questionario.ts (+ CONSENT_VERSION)
M src/lib/longevity/vista.ts (+ transazione unica, fitnessAgeCopertura)
M src/pages/api/longevity/questionario.ts (+ CorpoQuestionario tipato)
M src/pages/longevity/io.astro
M tests/longevity/anagrafica.test.ts (solo commento)
M tests/longevity/questionario-pagina.test.ts
M tests/longevity/referto.test.ts
?? src/lib/longevity/formato.ts
?? tests/longevity/consenso-atomico.test.ts
?? tests/longevity/formato.test.ts
?? tests/longevity/prima-apertura.test.ts
?? tests/longevity/salvataggio-e2e.test.ts
```
## Dieci prove di rottura (richieste dal brief) — riepilogo
1. Vecchio test §2 (regex `body.client_code`): vulnerabilità vera iniettata → suite
verde (confermato vacuo).
2. Nuovo test §2 (comportamentale): stessa vulnerabilità iniettata → rosso.
3. Tipo `CorpoQuestionario`: `corpo.client_code` aggiunto al codice → `tsc --noEmit`
rifiuta (TS2339).
4. §1: `registraConsenso`/motore rimossi dal ramo affermativo → nuovo test rosso
(`consenso-atomico.test.ts` non lancia neppure, prova indiretta del blocco originale).
5. §3a: `registraConsenso` rimossa dal ramo affermativo → nuovo test rosso; **verificato
anche che la suite INTERA restasse verde senza il nuovo test** (vacuità originale).
6. §3c: `CONSENT_VERSION` sostituita con `QUEST_VERSION` nella nota → nuovo test rosso.
7. §4 MacroScore: copertura-sempre e formattaNumero rimossi → 2 test rossi.
8. §4 io.astro: nota Fitness Age, formattaNumero, link ricompilazione rimossi → 3 test
rossi.
9. §4/§5 Radar: spiegazione riportata sotto, invito trainer rimosso → 2 test rossi.
10. §6a: pagina spuria con `SELECT` in `src/pages/longevity/` → test riscritto la
intercetta (prima non la vedeva).
Tutte le 10 rotture sono state ripristinate immediatamente dopo la verifica; lo stato
finale del repo corrisponde al codice corretto (confermato con `diff` contro le copie
salvate prima di ogni prova, e con la suite completa verde alla fine).
@@ -0,0 +1,91 @@
### Task 1: Il guscio — layout, stile, e una rotta che risponde
**Files:**
- Create: `src/layouts/Longevity.astro`, `src/styles/longevity.css`, `src/pages/longevity/index.astro`
- Test: `tests/longevity/pagine.test.ts`
**Interfaces:**
- Consumes: `isProtectedPath`, `canAccessAdminPath` da `src/lib/auth.ts`
- Produces: il layout `Longevity.astro` con `title` e `crumbs`, e le classi `.lg-*`
**Il modello da seguire:** `src/layouts/StressIndex.astro` e `src/styles/stress-index.css`. Leggili prima di scrivere. Il foglio di stile va **globale e prefissato**, non in `<style>` scoped: le stesse classi servono ai componenti Astro e alle isole React, che lo scope di Astro non raggiunge — la ragione è scritta in testa al foglio di Stress Index.
**La differenza voluta rispetto a Stress Index.** Quella piattaforma ha una 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 per guardare il proprio referto, e una barra di navigazione è l'arredamento di uno strumento di lavoro. Quindi il layout prevede due forme: `variante="referto"` (una colonna che scorre, nessuna navigazione) e `variante="gestionale"` (barra laterale come Stress Index). Questo task realizza la prima; la seconda arriva col piano del gestionale.
- [ ] **Step 1: Write the failing test**
```ts
// 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);
expect(classi.every((c) => 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('nessun semaforo sui punteggi: il colore non giudica il corpo', () => {
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('la rotta del cliente e sotto /longevity, dove le regole la proteggono', () => {
expect(existsSync(join(process.cwd(), 'src/pages/longevity/index.astro'))).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/pagine.test.ts`
Expected: FAIL — i file non esistono
- [ ] **Step 3: Write minimal implementation**
Il layout prende `title`, `crumbs` e `variante`, importa i font e i due fogli di stile (`global.css` e `longevity.css`), e dichiara `noindex`. Nessun `Header`/`Footer` pubblico: la navigazione è interna.
Il foglio `longevity.css` definisce, **tutte prefissate `lg`**: il guscio del referto (una colonna centrata, larghezza leggibile), l'intestazione, la carta di un punteggio, e i token locali. Le variabili locali riprendono quelle del sito (`--c-accent`, `--c-dark`, `--c-bg-alt`) e ne aggiungono due sole:
```css
--lg-tratteggio: #d8d2c8; /* il perimetro di cio che non sappiamo */
--lg-fuori-range: #b05a4e; /* SOLO per una misura sospetta, mai per un punteggio */
```
⚠️ Non definire un colore «buono» o «cattivo» per i punteggi: l'intensità si ottiene variando l'opacità dell'accento. È la regola di prodotto, e il test la controlla.
`src/pages/longevity/index.astro` reindirizza a `/longevity/io`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/pagine.test.ts` → PASS (5 test)
Poi: `npm test` per intero e `npx tsc --noEmit`.
- [ ] **Step 5: Commit**
```bash
git add src/layouts/Longevity.astro src/styles/longevity.css src/pages/longevity/index.astro tests/longevity/pagine.test.ts
git commit -m "longevity: il guscio della piattaforma, senza semaforo sui punteggi"
```
---
@@ -0,0 +1,195 @@
# Task 1 — Il guscio: layout e foglio di stile — report
**Stato:** completato. 5/5 test nuovi verdi, suite completa allo stato atteso (1 fallito
pre-esistente e fuori perimetro), `npx tsc --noEmit` pulito. Commit fatto.
**Commit:** `34c66ca` — "longevity: il guscio della piattaforma, senza semaforo sui punteggi"
File aggiunti (solo questi, `git add` esplicito, nessun `-A`):
- `src/layouts/Longevity.astro`
- `src/styles/longevity.css`
- `src/pages/longevity/index.astro`
- `tests/longevity/pagine.test.ts`
## Procedura seguita (TDD)
### Step 1-2: test scritto, verificato rosso
Comando:
```
npm test -- tests/longevity/pagine.test.ts
```
Esito (prima dell'implementazione): **5 falliti** — `ENOENT` su `src/layouts/Longevity.astro`
e `src/styles/longevity.css` (file non esistenti), e `existsSync(.../index.astro)` a `false`.
### Step 3: implementazione
- `src/layouts/Longevity.astro` — niente `import Header`/`Footer` pubblico, `<meta
name="robots" content="noindex, nofollow" />`, props `title`, `crumbs`, `variante`
(`'referto' | 'gestionale'`, default `'referto'`). Solo la forma `referto` è realizzata
in questo task: una colonna (`.lg-shell`, `max-width: 760px`) senza navigazione, come
richiesto dal brief — la barra laterale gestionale arriva col piano successivo.
- `src/styles/longevity.css` — globale, prefissato `.lg`, con le variabili locali richieste:
```css
--lg-tratteggio: #d8d2c8; /* il perimetro di ciò che non sappiamo */
--lg-fuori-range: #b05a4e; /* SOLO per una misura sospetta, mai per un punteggio */
```
Nessuna variabile `--lg-ok` / `--lg-buono` / `--lg-verde`: l'intensità di un punteggio è
affidata a `--lg-intensita` (0..1), consumata come `opacity` su `.lg-score__bar-fill`
sopra un unico colore d'accento (`var(--c-accent)`), non a un colore diverso per fascia.
- `src/pages/longevity/index.astro` — nessun indice, solo `Astro.redirect('/longevity/io')`
(la rotta `/longevity/io` arriva col Task 3; per ora la protezione è già del middleware
esistente su `isProtectedPath`/`canAccessAdminPath`, nessuna modifica ad `auth.ts`
necessaria: le regole per `/longevity` ci sono già).
### Step 4: test target verde
Comando:
```
npm test -- tests/longevity/pagine.test.ts
```
Output:
```
Test Files 1 passed (1)
Tests 5 passed (5)
```
### Step 5: suite completa + typecheck
Comando:
```
npm test
```
Output (coda):
```
❯ tests/modifiche-agosto.test.ts (25 tests | 1 failed) 72ms
× l'inaugurazione dice la data e che il programma manca ancora 8ms
FAIL tests/modifiche-agosto.test.ts > promo ed eventi > l'inaugurazione dice la data e che il programma manca ancora
AssertionError: expected 'Inaugurazione' to contain '19 settembre 2026'
Test Files 1 failed | 37 passed (38)
Tests 1 failed | 313 passed (314)
```
Esattamente il rosso pre-esistente dichiarato nel brief (fuori perimetro, non toccato) —
nessun altro test rotto, i 5 nuovi inclusi nel verde.
Comando:
```
npx tsc --noEmit
```
Output: nessuno (exit pulito).
### Step 6: commit
```
git status --porcelain # solo i 4 file nuovi, nessun altro modificato
git add src/layouts/Longevity.astro src/styles/longevity.css src/pages/longevity/index.astro tests/longevity/pagine.test.ts
git commit -m "longevity: il guscio della piattaforma, senza semaforo sui punteggi"
```
```
[feat/longevity 34c66ca] longevity: il guscio della piattaforma, senza semaforo sui punteggi
4 files changed, 176 insertions(+)
```
`git status` dopo il commit: albero pulito.
## Dubbi / note per chi esegue il Task 2/3
- `src/pages/longevity/index.astro` reindirizza a `/longevity/io`, che non esiste ancora
(arriva col Task 3): finché il Task 3 non è fatto, visitare `/longevity/` produce un
redirect verso una rotta 404. È il comportamento descritto dal brief, non un difetto di
questo task.
- La variante `gestionale` è dichiarata nel tipo `Props` di `Longevity.astro` (con
`data-variante` sul `<body>` per un eventuale hook CSS futuro) ma il corpo del layout
renderizza solo la forma a colonna: nessuna barra laterale è stata costruita, come da
perimetro del task ("questo task realizza la prima; la seconda arriva col piano del
gestionale").
- Non ho toccato `src/lib/auth.ts`: le regole per `/longevity` (incluso il ramo
`/longevity/gestionale` per il ruolo `trainer`) risultavano già presenti in `RULES` e
`isProtectedPath`, presumibilmente da un piano precedente (strato dati). Segnalo per
conferma, nel caso non fosse atteso.
---
## Rinforzo dopo revisione (2026-08-22)
**Stato:** completato. Commit `2ea822a` sopra a `34c66ca`.
Tre correzioni chieste dal coordinatore, tutte in `tests/longevity/pagine.test.ts` (unico
file toccato, nessuna modifica a layout/CSS/pagina):
1. **Test anti-semaforo reso strutturale.** Il vecchio test cercava tre nomi vietati nel
CSS — un colore scritto direttamente (esadecimale) o una variabile con un altro nome
lo aggiravano. Il nuovo test (`nelle regole di un punteggio compaiono solo i colori
ammessi...`) fa il contrario: prende ogni regola CSS il cui selettore contiene
`lg-score`, estrae ogni dichiarazione su una proprietà-colore (`color`, `background`,
`border-*-color`, `outline-color`, `fill`, `stroke`), e verifica che il valore sia
`var(--nome)` con `--nome` in un allowlist esplicito (l'accento del sito, il
tratteggio, il fuori range, più i token neutri di testo/sfondo già in uso). Qualunque
letterale (hex, `rgb()`, nome CSS) o `var()` fuori allowlist è una violazione raccolta
in un array che il test pretende vuoto. Il vecchio test sui nomi vietati è rimasto
come controllo separato (cattura anche un token dichiarato ma mai usato, es.
`--lg-verde-scuro: ...;` non referenziato in nessuna regola).
2. **Test sulla rotta rinforzato.** Prima verificava solo `existsSync`. Ora legge il file
e controlla che contenga `Astro.redirect('/longevity/io')` con una regex sul
contenuto, non solo sull'esistenza.
3. **Prefisso classi, un carattere.** `c.startsWith('lg')` accettava anche `lgfoo` (senza
trattino). Cambiato in `c === 'lg' || c.startsWith('lg-')`.
### Prova richiesta: il test nuovo diventa rosso davvero
Backup del CSS reale, poi due regressioni iniettate una alla volta (gli esempi dati dal
coordinatore), test rilanciato, poi ripristino e riverifica verde.
**Regressione 1 — verde esadecimale su una classe di punteggio**, aggiunta in coda al
foglio: `.lg-score--alto { color: #6f8f6a; }`. Comando: `npm test -- tests/longevity/pagine.test.ts`.
Output (estratto):
tests/longevity/pagine.test.ts (6 tests | 1 failed)
x nelle regole di un punteggio compaiono solo i colori ammessi: l'accento, il tratteggio, il fuori range
AssertionError: expected [ Array(1) ] to deeply equal []
+ ".lg-score--alto { color: #6f8f6a }"
Test Files 1 failed (1)
Tests 1 failed | 5 passed (6)
**Rosso, come atteso.** (La stringa riportata include anche il commento sulla riga
precedente perché il parser del test cattura tutto ciò che precede la graffa aperta —
cosmetico, non inficia la rilevazione.)
**Regressione 2 — variabile con nome innocuo**: `.lg-score--alto { color: var(--lg-buona-salute); }`.
Stesso comando. Output: `Tests 1 failed | 5 passed (6)` — **rosso**, stesso test.
**Ripristino** dal backup fatto prima delle due prove, poi verifica che il file torni
identico all'HEAD già committato (`git diff --stat` senza output) e che il test torni
verde:
Test Files 1 passed (1)
Tests 6 passed (6)
### Verifiche finali
`npm test -- tests/longevity/pagine.test.ts` → 6/6 verdi.
`npm test` → `Test Files 1 failed | 37 passed (38)`, `Tests 1 failed | 314 passed (315)`;
il fallito è sempre e solo `tests/modifiche-agosto.test.ts > ... inaugurazione ...`
(pre-esistente, fuori perimetro, non toccato).
`npx tsc --noEmit` → nessun output, pulito.
### Commit
`git status --porcelain` prima di committare mostrava solo `tests/longevity/pagine.test.ts`
modificato.
git add tests/longevity/pagine.test.ts
git commit -m "longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto"
Risultato:
[feat/longevity 2ea822a] longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto
1 file changed, 44 insertions(+), 4 deletions(-)
@@ -0,0 +1,141 @@
### Task 2: Il questionario in pagina
Sostituisce il prototipo del cliente, che salva nel browser e che il documento stesso dichiara da buttare in produzione.
**Files:**
- Create: `src/components/longevity/Questionario.tsx`, `src/pages/longevity/questionario.astro`, `src/pages/api/longevity/questionario.ts`
- Test: `tests/longevity/questionario-pagina.test.ts`
**Interfaces:**
- Consumes: `testAttivi` da `registro.ts`; `salvaCompilazione`, `QUEST_VERSION`, `CAMPI_LIBERI` da `questionario.ts`; `codicePerUtente` da `anagrafica.ts`
- Produces: `POST /api/longevity/questionario` → `{ sessioneId }` o errore
**Le domande vengono dal registro, non dal codice.** La pagina legge `testAttivi` e costruisce i campi da lì: etichetta, tipo, minimo e massimo. Aggiungere una domanda resta una modifica ai dati — è la promessa della spec, e qui va mantenuta.
⚠️ **Tre regole che l'endpoint deve rispettare, e sono di sostanza:**
1. **Il codice cliente si ricava dalla sessione, mai dalla richiesta.** Un codice che arriva dal browser va ignorato. È la regola §8 della spec, e questo è il primo endpoint su cui si può finalmente provare.
2. **Il consenso al trattamento dei dati sanitari non è «compilato», è «dato».** Il prototipo del cliente blocca il salvataggio solo se il campo è vuoto: rispondendo **«No»** i dati vengono salvati lo stesso. È l'esatto contrario di ciò per cui il campo esiste. Qui: se il consenso non è affermativo, **la compilazione non si salva**, e la risposta lo dice chiaramente.
3. **Il consenso va registrato, non solo controllato.** Un consenso è un fatto che un domani va **dimostrato**: chi, quando, a cosa. Salvalo fra le note di profilo con la sua data e la versione del questionario, **anche quando la risposta è no** — il rifiuto è precisamente ciò di cui bisogna poter provare il rispetto.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/questionario-pagina.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 } 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/);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/questionario-pagina.test.ts`
Expected: FAIL — `src/lib/longevity/vista.ts` non esiste
- [ ] **Step 3: Write minimal implementation**
`vista.ts` espone `campiDelQuestionario(db, alla)` — che legge `testAttivi` e tiene i soli `test_id` che iniziano per `q_` — e `salvaDalForm(db, input)`, che controlla il consenso **prima** di scrivere qualsiasi cosa, registra il consenso fra le note col suo esito e la versione del questionario, e delega a `salvaCompilazione`.
L'isola React mostra i quattro blocchi uno per schermata, con avanti e indietro, e invia in fondo. L'endpoint ricava il codice cliente dalla sessione con `codicePerUtente` e passa a `salvaDalForm`.
⚠️ Il consenso **rifiutato va scritto comunque** fra le note: apri la sessione solo se il consenso è affermativo, ma la nota del consenso si scrive in entrambi i casi, legata al cliente.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test` — atteso 1 fallito pre-esistente, il resto verde. Poi `npx tsc --noEmit` pulito.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/vista.ts src/components/longevity/Questionario.tsx \
src/pages/longevity/questionario.astro src/pages/api/longevity/questionario.ts \
tests/longevity/questionario-pagina.test.ts
git commit -m "longevity: il questionario in pagina, e il consenso che si puo negare davvero"
```
---
@@ -0,0 +1,539 @@
# Task 2 — Il questionario in pagina — report
**Stato:** completato. 7/7 test nuovi verdi, suite completa allo stato atteso (1 fallito
pre-esistente e fuori perimetro, 321 passati), `npx tsc --noEmit` pulito. Commit fatto.
**Commit:** `f5ca841` — "longevity: il questionario in pagina, e il consenso che si puo
negare davvero"
File aggiunti/modificati (solo questi, `git add` esplicito, nessun `-A`/`.`):
- `src/lib/longevity/vista.ts` (nuovo) — `campiDelQuestionario`, `salvaDalForm`
- `src/lib/longevity/db.ts` (modificato) — `getLongevityDb`/`getIdentityDb`, connessioni
singole per processo
- `src/components/longevity/Questionario.tsx` (nuovo) — isola React, quattro blocchi
- `src/pages/longevity/questionario.astro` (nuovo) — pagina, nessuna query diretta
- `src/pages/api/longevity/questionario.ts` (nuovo) — endpoint POST
- `src/styles/longevity.css` (modificato) — classi `.lg-form*` per il modulo
- `tests/longevity/questionario-pagina.test.ts` (nuovo) — i 7 test del brief
## Procedura seguita (TDD)
### Step 1-2: test scritto, verificato rosso
Comando:
```
npm test -- tests/longevity/questionario-pagina.test.ts
```
Esito (prima dell'implementazione):
```
Error: Cannot find module '../../src/lib/longevity/vista' imported from
.../tests/longevity/questionario-pagina.test.ts
Test Files 1 failed (1)
Tests no tests
```
Come atteso: `vista.ts` non esisteva ancora.
### Step 3: implementazione
**`src/lib/longevity/vista.ts`**
- `campiDelQuestionario(db, alla)`: legge `testAttivi(db, alla)`, tiene solo i `test_id`
col prefisso `q_` (per non confondersi coi test fisici), mappa in `{ id, etichetta, tipo,
unita, min, max, sottoDominio }`.
- `salvaDalForm(db, input)`: **controlla il consenso prima di scrivere qualunque cosa**.
`consensoDato()` accetta solo `'si'`/`'sì'` (case/trim-insensitive) come affermativo —
tutto il resto (`'No'`, stringa vuota, qualunque altra cosa) è un rifiuto. Se il consenso
non è dato: **nessuna sessione si apre**, si scrive comunque una nota di consenso
(`campo_id = 'consenso_sanitario'`, `sessione_id = null`, testo con l'esito letterale e
`QUEST_VERSION`) e si ritorna `{ ok: false, errore: '...' }`. Se il consenso è dato: si
delega a `salvaCompilazione` (già esistente, non toccata) e **poi** si scrive la nota di
consenso legata alla sessione appena creata.
**`src/lib/longevity/db.ts`**
- Aggiunte `getLongevityDb()`/`getIdentityDb()`, singleton per processo (stesso pattern di
`getDb()` in `src/lib/db.ts`). Necessarie perché l'endpoint deve risolvere il
`client_code` dalla sessione (via `codicePerUtente`, che vuole una connessione a
`identity.db`) e poi scrivere su `longevity.db`: senza un singleton ogni richiesta
avrebbe aperto due connessioni SQLite nuove.
- Nota di conformità al vincolo di `tests/longevity/anagrafica.test.ts` ("nessun file in
`src/`, escluso `anagrafica.ts` e `db.ts`, apre insieme le due connessioni", verificato
grep-ando le stringhe letterali `createIdentityDb`/`createLongevityDb`): l'endpoint
importa `getIdentityDb`/`getLongevityDb` (definite in `db.ts`, già un file autorizzato),
non le funzioni `create*` — il test infatti resta verde (vedi sotto).
**`src/pages/api/longevity/questionario.ts`**
- `codicePerUtente(getIdentityDb(), locals.user!.id)`: il codice cliente **si legge solo
qui**, dalla sessione. Il corpo della richiesta non viene mai letto per questo campo — non
esiste nel codice nessun `data.client_code`/`data.clientId`.
- Se non c'è un fascicolo per l'utente loggato: 403, nessun salvataggio.
- `liberi` è filtrato sulle sole chiavi di `CAMPI_LIBERI` (whitelist, non passa chiavi
arbitrarie dal corpo).
- La data della compilazione è quella del server (`new Date().toISOString().slice(0,10)`),
non quella (manomettibile) inviata dal client.
- Risposta: `201 { sessioneId }` se `salvaDalForm` va a buon fine, `422 { error }` altrimenti
(consenso mancante/negato, o qualunque altro errore di `salvaDalForm`).
**`src/components/longevity/Questionario.tsx` + `src/pages/longevity/questionario.astro`**
- La pagina Astro chiama `campiDelQuestionario(getLongevityDb(), oggi)` e passa i campi
all'isola: nessuna query SQL nel file della pagina.
- L'isola mostra quattro blocchi in sequenza (Sonno & Recupero, Energia & Stress, Stile di
Vita — ricostruiti filtrando `campi` per `sottoDominio`, dato che il registro li restituisce
in ordine alfabetico di `test_id`, non raggruppati — più Note e consenso), con
avanti/indietro e invio solo sull'ultimo blocco.
- **Deliberato**: il consenso NON è bloccato lato client oltre al richiedere una scelta
(Sì/No) — scegliere "No" e provare a inviare è permesso, esattamente perché la verifica
che conta è quella server-side. Bloccare "No" anche lato client avrebbe solo spostato
l'errore del prototipo in un altro file senza risolverlo.
### Step 4: test target verde
Comando:
```
npm test -- tests/longevity/questionario-pagina.test.ts
```
Output:
```
Test Files 1 passed (1)
Tests 7 passed (7)
```
### Step 5: suite completa + typecheck
Comando:
```
npm test
```
Output (coda):
```
❯ tests/modifiche-agosto.test.ts (25 tests | 1 failed) 49ms
× l'inaugurazione dice la data e che il programma manca ancora 11ms
FAIL tests/modifiche-agosto.test.ts > promo ed eventi > l'inaugurazione dice la data e che il programma manca ancora
AssertionError: expected 'Inaugurazione' to contain '19 settembre 2026'
Test Files 1 failed | 38 passed (39)
Tests 1 failed | 321 passed (322)
```
Esattamente il rosso pre-esistente dichiarato nel task (fuori perimetro, non toccato) —
nessun altro test rotto, i 7 nuovi inclusi nel verde. In particolare
`tests/longevity/anagrafica.test.ts` (il vincolo sulle due connessioni) resta verde: vedi
nota su `getIdentityDb`/`getLongevityDb` sopra.
Comando:
```
npx tsc --noEmit
```
Output: nessuno (exit pulito).
### Step 6: commit
```
git status --porcelain
```
```
M src/lib/longevity/db.ts
M src/styles/longevity.css
?? src/components/longevity/
?? src/lib/longevity/vista.ts
?? src/pages/api/longevity/
?? src/pages/longevity/questionario.astro
?? tests/longevity/questionario-pagina.test.ts
```
```
git add src/lib/longevity/vista.ts src/lib/longevity/db.ts \
src/components/longevity/Questionario.tsx \
src/pages/longevity/questionario.astro \
src/pages/api/longevity/questionario.ts \
src/styles/longevity.css \
tests/longevity/questionario-pagina.test.ts
git commit -m "longevity: il questionario in pagina, e il consenso che si puo negare davvero"
```
```
[feat/longevity f5ca841] longevity: il questionario in pagina, e il consenso che si puo negare davvero
7 files changed, 482 insertions(+)
```
`git status` dopo il commit: albero pulito.
## La prova richiesta: rompere il controllo sul consenso
Backup di `src/lib/longevity/vista.ts`, poi iniettata la regressione esatta descritta nel
task — la stessa logica del prototipo del cliente, che blocca solo se il campo è vuoto:
```diff
function consensoDato(risposta: string): boolean {
- return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
+ // REGRESSIONE DI PROVA: replica il bug del prototipo del cliente (blocca solo se vuoto).
+ return risposta.trim() !== '';
}
```
Comando:
```
npm test -- tests/longevity/questionario-pagina.test.ts
```
Output:
```
❯ tests/longevity/questionario-pagina.test.ts (7 tests | 1 failed) 13ms
× NON salva se il consenso e rifiutato: "No" non e un consenso 4ms
FAIL tests/longevity/questionario-pagina.test.ts > il salvataggio, e il consenso > NON salva se il consenso e rifiutato: "No" non e un consenso
AssertionError: expected true to be false // Object.is equality
- false
+ true
Test Files 1 failed (1)
Tests 1 failed | 6 passed (7)
```
**Rosso, come atteso**: il test `NON salva se il consenso e rifiutato: "No" non e un
consenso` intercetta esattamente la regressione — protegge la cosa giusta, non solo il nome
di una funzione. Ripristinato il file dal backup (`diff` col backup: nessuna differenza),
rilanciato:
```
npm test -- tests/longevity/questionario-pagina.test.ts
```
```
Test Files 1 passed (1)
Tests 7 passed (7)
```
Poi riverificati suite completa (`321 passed`, `1 failed` pre-esistente) e `npx tsc --noEmit`
(pulito) allo stato finale.
## Le tre regole di sostanza — dove sono, e come si verificano
1. **Il consenso non affermativo blocca il salvataggio.** `salvaDalForm` chiama
`consensoDato()` **prima** di ogni scrittura; se falso, ritorna `{ ok: false, errore }`
senza mai chiamare `salvaCompilazione`. Verificato da 3 test (dato/rifiutato/mancante) +
la prova di rottura sopra.
2. **Il consenso si registra sempre, anche negato.** `registraConsenso()` viene chiamata in
entrambi i rami di `salvaDalForm` — con `sessione_id = null` quando rifiutato — scrivendo
in `profilo_note` (`campo_id = 'consenso_sanitario'`) l'esito letterale e `QUEST_VERSION`.
Verificato dal test `registra il consenso anche quando e rifiutato`.
3. **Il codice cliente viene solo dalla sessione.** L'endpoint chiama
`codicePerUtente(getIdentityDb(), locals.user!.id)`; il corpo della richiesta non viene
mai letto per `client_code`/`clientId`. Verificato dal test che legge il sorgente
dell'endpoint e controlla l'assenza letterale di `body.client_code`/`body.clientId`.
## Note per chi esegue il Task 3
- `vista.ts` esiste ed è di proprietà congiunta col Task 3 (che vi aggiunge `refertoDi`, per
`progress.md` del piano): non va riscritto da zero.
- `src/pages/longevity/questionario.astro` non contiene né `SELECT` né `.prepare(` — rispetta
già il vincolo che il test del Task 3 (`nessuna pagina interroga il database da sola`)
controlla anche su questo file.
- Non ho toccato `src/lib/longevity/anagrafica.ts`: non è servito, la risoluzione
utente→codice passa da `codicePerUtente` (già esistente) più le due connessioni singole
aggiunte a `db.ts`.
---
## Rinforzo dopo revisione (2026-08-22)
**Stato:** completato. Commit `f0629db` sopra a `f5ca841`.
**Il problema segnalato:** `src/pages/api/longevity/questionario.ts` apriva ENTRAMBE le
connessioni (`getIdentityDb` per risolvere il codice cliente, `getLongevityDb` per salvare),
violando la garanzia della spec (§4: un solo modulo tiene insieme identity e longevity). La
*logica* di giunzione era già al posto giusto (passava da `codicePerUtente`, che sta in
`anagrafica.ts`), ma l'*apertura* delle due connessioni no — e il test che avrebbe dovuto
impedirlo cercava due nomi letterali (`createIdentityDb`/`createLongevityDb`) che i nomi
nuovi (`getIdentityDb`/`getLongevityDb`, aggiunti da me nello stesso task) non toccavano
affatto.
### Correzione 1: l'endpoint apre solo longevity
`src/lib/longevity/anagrafica.ts` — aggiunta:
```ts
import { getIdentityDb } from './db';
...
export function codicePerUtenteLoggato(userId: number): string | null {
return codicePerUtente(getIdentityDb(), userId);
}
```
`anagrafica.ts` è il modulo autorizzato: apre lei stessa `identity` (via `getIdentityDb`,
già usata altrove nel file in senso lato) e resta l'unico punto che tiene insieme le due
connessioni nella stessa funzione chiamante.
`src/pages/api/longevity/questionario.ts` — non importa più `getIdentityDb` né
`codicePerUtente`; importa solo `getLongevityDb` (per `salvaDalForm`) e
`codicePerUtenteLoggato` (per il codice cliente). Verificato che il file non contenga più
la stringa `Identity`:
```
grep -n "Identity" src/pages/api/longevity/questionario.ts
```
Output: nessuna riga, `grep` esce con stato 1.
Il test del Task 2 (`il codice cliente NON puo arrivare da chi chiama la pagina`, che
verifica `/codicePerUtente/` nel sorgente dell'endpoint) resta verde perché
`codicePerUtenteLoggato` contiene `codicePerUtente` come sottostringa letterale — non è un
trucco per far passare il test, è il nome naturale della funzione wrapper.
### Correzione 2: il test anti-giunzione, reso strutturale
`tests/longevity/anagrafica.test.ts` — il test che cercava
`src.includes('createIdentityDb') && src.includes('createLongevityDb')` è stato sostituito
(non affiancato: il vecchio controllo è un sottoinsieme stretto del nuovo, non catturava
nessun caso che il nuovo non catturi) con:
```ts
if (src.includes('Identity') && src.includes('Longevity')) {
colpevoli.push(f);
}
```
Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola, la
stessa convenzione con cui compaiono in QUALUNQUE accessore camelCase esportato da
`db.ts` — `createIdentityDb`, `getIdentityDb`, un ipotetico `apriIdentityDb` futuro — tutti
contengono quella sottostringa) invece di un elenco chiuso di nomi. Non soffre di falsi
positivi dal resto del sito: verificato con
```
grep -rln "Identity" src --include='*.ts' --include='*.astro'
```
che PRIMA della correzione 1 restituiva solo `anagrafica.ts`, `db.ts` e l'endpoint
(quest'ultimo il colpevole reale), e dopo la correzione 1 restituisce solo i due file
autorizzati. "Longevity" (maiuscola) compare in molti altri file del sito (titoli, testi di
prodotto), ma mai insieme a "Identity" fuori dai due moduli autorizzati, quindi l'AND non
scatta lì.
### La prova richiesta: rompere il test nuovo con i nomi nuovi
Creato un file finto sotto `src/`, con i nomi che il vecchio test non vedeva:
```ts
// src/lib/longevity/_prova-giunzione.ts
import { getIdentityDb, getLongevityDb } from './db';
export function provaGiunzione() {
const identity = getIdentityDb();
const longevity = getLongevityDb();
return { identity, longevity };
}
```
Comando:
```
npm test -- tests/longevity/anagrafica.test.ts
```
Output:
```
❯ tests/longevity/anagrafica.test.ts (7 tests | 1 failed) 19ms
× nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity
AssertionError: expected [ 'lib/longevity/_prova-giunzione.ts' ] to deeply equal []
- []
+ [ "lib/longevity/_prova-giunzione.ts" ]
Test Files 1 failed (1)
Tests 1 failed | 6 passed (7)
```
**Rosso, come atteso** — e con i nomi esatti che avrebbero superato indisturbati il vecchio
test. Cancellato il file finto, rilanciato:
```
npm test -- tests/longevity/anagrafica.test.ts
```
```
Test Files 1 passed (1)
Tests 7 passed (7)
```
Torna verde.
### Verifiche finali
```
git status --porcelain
```
```
M src/lib/longevity/anagrafica.ts
M src/pages/api/longevity/questionario.ts
M tests/longevity/anagrafica.test.ts
```
```
npm test
```
```
Test Files 1 failed | 38 passed (39)
Tests 1 failed | 321 passed (322)
```
Stesso rosso pre-esistente e fuori perimetro di prima (`tests/modifiche-agosto.test.ts >
... inaugurazione ...`), nessuna regressione: 321 verdi (invariati rispetto a prima di
questo rinforzo, il conteggio dei test non cambia — solo il contenuto di uno di essi).
```
npx tsc --noEmit
```
Output: nessuno (pulito).
### Commit
```
git add src/lib/longevity/anagrafica.ts src/pages/api/longevity/questionario.ts \
tests/longevity/anagrafica.test.ts
git commit -m "longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale"
```
```
[feat/longevity f0629db] longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
3 files changed, 28 insertions(+), 6 deletions(-)
```
`git status` dopo il commit: albero pulito.
---
## Secondo rinforzo dopo revisione (2026-08-22)
**Stato:** completato. Commit `8c98075` sopra a `f0629db`.
**Il problema dimostrato dal revisore:** il test anti-giunzione (controllo testuale
file-per-file, cerca "Identity" e "Longevity" nello stesso file) si aggira senza malizia
con un pattern di re-export su tre file:
```ts
// a.ts
export { getIdentityDb as connA } from './db';
// b.ts
export { getLongevityDb as connB } from './db';
// c.ts
import { connA } from './a';
import { connB } from './b';
// usa connA() e connB() insieme — la giunzione esiste, ma nessuno dei tre file
// contiene singolarmente sia "Identity" sia "Longevity"
```
Un controllo testuale file-per-file non può vedere una giunzione che attraversa più file:
non c'è formulazione del test che lo risolva, va reso impossibile invece che sorvegliato.
### Correzione: la connessione a identity non è più raggiungibile da fuori anagrafica.ts
`src/lib/longevity/db.ts` — rimossa l'esportazione di `getIdentityDb`. Resta:
- `getLongevityDb` (esportata: il database senza nomi, lo può usare chiunque);
- `createIdentityDb` (resta esportata: serve ai test per creare database in memoria — lì
la giunzione è voluta ed evidente in un file di test, non in `src/`; il problema era il
singleton *condiviso*, non la fabbrica).
`src/lib/longevity/anagrafica.ts` — `getIdentityDb` torna dentro, ma **non esportata**:
```ts
import { createIdentityDb } from './db';
let identitySingleton: Database.Database | null = null;
function getIdentityDb(): Database.Database {
if (!identitySingleton) identitySingleton = createIdentityDb();
return identitySingleton;
}
```
usata solo da `codicePerUtenteLoggato`. Non essendo esportata, non esiste alcun nome da
importare per ottenerla da nessun'altra parte del codice — il vincolo non dipende più da
un test che lo sorveglia, non c'è proprio un varco.
### Verifica che il bypass del revisore ora sia impossibile (non solo intercettato)
Ricreato lo stesso schema a tre file usato dal revisore, dentro `src/`:
```bash
cat > src/lib/longevity/_prova-a.ts <<'EOT'
export { getIdentityDb as connA } from './db';
EOT
cat > src/lib/longevity/_prova-b.ts <<'EOT'
export { getLongevityDb as connB } from './db';
EOT
cat > src/lib/longevity/_prova-c.ts <<'EOT'
import { connA } from './_prova-a';
import { connB } from './_prova-b';
export function usaEntrambe() { return { a: connA(), b: connB() }; }
EOT
npx tsc --noEmit
```
Output:
```
src/lib/longevity/_prova-a.ts(1,10): error TS2724: '"./db"' has no exported member named 'getIdentityDb'. Did you mean 'createIdentityDb'?
```
**Fallisce già al typecheck**, prima ancora che un test possa dire qualcosa: non esiste
nessun nome da riesportare, quindi il pattern del revisore non parte nemmeno. File di prova
cancellati subito dopo:
```bash
rm src/lib/longevity/_prova-a.ts src/lib/longevity/_prova-b.ts src/lib/longevity/_prova-c.ts
npx tsc --noEmit # pulito
```
### Il test anti-giunzione: dichiarato onestamente come rete secondaria
`tests/longevity/anagrafica.test.ts` — titolo e commento riscritti per non promettere più
di quanto il test mantiene:
```
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento
sia a identity sia a longevity', ...)
```
Il commento dichiara esplicitamente: la garanzia vera è che `getIdentityDb` non è più
esportata da `db.ts` (verificato sopra); il test resta come seconda rete — cattura il caso
comune (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.
### Due correzioni minori dalla stessa revisione
**Eccezioni non gestite nell'endpoint.** `salvaDalForm` delega a `salvaCompilazione`, che
lancia se una risposta porta un `test_id` sconosciuto al registro (client disallineato,
senza bisogno di malizia). Prima l'endpoint non la intercettava e Astro avrebbe risposto
con un 500 generico. Ora, sul modello di `src/pages/api/contact.ts` (che avvolge
`sendContactEmail` e risponde pulito):
```ts
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.' });
}
```
**Campo testuale reso sempre numerico nel componente.** `Questionario.tsx` renderizzava
sempre `<input type="number">`, anche per un'ipotetica domanda `tipo_valore='txt'` nel
registro (oggi nessuna delle 20 lo è, ma la promessa della spec è che aggiungerne una resta
un dato, non codice). Ora il tipo dichiarato decide l'input (`text` vs `number`), e
all'invio i campi testuali non vengono convertiti con `Number(v)` (che produrrebbe `NaN` e
sparirebbe in silenzio): restano esclusi esplicitamente, con un commento che spiega perché
non sono ancora agganciati al salvataggio (il canale `valore_txt` non è cablato da questo
task, per non ridefinire `vista.ts`/`questionario.ts` oltre quanto richiesto).
### Verifiche finali
```
npm test -- tests/longevity/anagrafica.test.ts tests/longevity/questionario-pagina.test.ts
```
```
Test Files 2 passed (2)
Tests 14 passed (14)
```
```
npm test
```
```
Test Files 1 failed | 38 passed (39)
Tests 1 failed | 321 passed (322)
```
Stesso rosso pre-esistente e fuori perimetro (`tests/modifiche-agosto.test.ts > ...
inaugurazione ...`); nessuna regressione, il test anti-giunzione è fra i 321 verdi.
```
npx tsc --noEmit
```
Output: nessuno (pulito) — sia prima sia dopo la prova col bypass a tre file, verificato
separatamente.
```
git status --porcelain
```
```
M src/components/longevity/Questionario.tsx
M src/lib/longevity/anagrafica.ts
M src/lib/longevity/db.ts
M src/pages/api/longevity/questionario.ts
M tests/longevity/anagrafica.test.ts
```
### Commit
```
git add src/lib/longevity/db.ts src/lib/longevity/anagrafica.ts \
src/pages/api/longevity/questionario.ts \
src/components/longevity/Questionario.tsx \
tests/longevity/anagrafica.test.ts
git commit -m "longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts"
```
```
[feat/longevity 8c98075] longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts
5 files changed, 75 insertions(+), 28 deletions(-)
```
`git status` dopo il commit: albero pulito.
@@ -0,0 +1,120 @@
### Task 3: Il radar, e come si mostra ciò che non si sa
**Files:**
- Create: `src/components/longevity/Radar.tsx`, `src/components/longevity/MacroScore.astro`, `src/pages/longevity/io.astro`
- Modify: `src/lib/longevity/vista.ts` — aggiungere `refertoDi`
- Test: `tests/longevity/referto.test.ts`
**Interfaces:**
- Consumes: `calcolaSessione`, `leggiScore` dal motore; `codicePerUtente` da `anagrafica.ts`
- Produces:
- `type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] }`
- `refertoDi(db, sessioneId): { assi: AsseVista[]; macro: {...}[]; fitnessAge: number | null }`
**Il punto del task.** Un asse senza dati sufficienti non porta un numero: porta **cosa manca per averlo**. `mancano` è l'elenco dei test del registro che alimenterebbero quell'asse e che quella sessione non ha — è ciò che trasforma un buco in un invito, ed è il gesto su cui si regge tutta l'interfaccia.
⚠️ **Le pagine non interrogano il database.** Tutto passa da `refertoDi`, che a sua volta usa `leggiScore`. Una `SELECT` scritta dentro una pagina riaprirebbe il buco che il tipo del motore chiude.
- [ ] **Step 1: Write the failing test**
```ts
// 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 };
}
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 } = conCompilazione();
// se rileggesse le misure invece degli score salvati, cambiare i pesi cambierebbe il referto
const prima = JSON.stringify(refertoDi(db, s));
db.prepare(`UPDATE pesi SET peso = 0.99 WHERE model_version = ? AND elemento = 'questionario_lifestyle'`).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);
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/referto.test.ts`
Expected: FAIL — `refertoDi` non esiste
- [ ] **Step 3: Write minimal implementation**
`refertoDi` legge i punteggi congelati con `leggiScore` e, per ogni asse insufficiente, calcola `mancano`: i sotto-domini di quell'asse che non hanno nessuna misura nella sessione, tradotti nelle etichette leggibili del registro.
Il radar è un'isola React con `recharts`: due serie sovrapposte — una piena con i valori noti, una tratteggiata che chiude il perimetro dove il dato manca. Sotto, l'elenco di ciò che manca.
Le carte dei macro-score sono Astro. Un macro insufficiente mostra **un trattino**, non uno zero.
⚠️ I numeri usano `font-variant-numeric: tabular-nums`: in una pagina che confronta misure nel tempo, cifre che ballano sono un difetto di lettura.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test` — atteso 1 fallito pre-esistente. Poi `npx tsc --noEmit` pulito.
- [ ] **Step 5: Commit**
```bash
git add src/components/longevity/Radar.tsx src/components/longevity/MacroScore.astro \
src/pages/longevity/io.astro src/lib/longevity/vista.ts tests/longevity/referto.test.ts
git commit -m "longevity: il referto del cliente, e il radar che dice cosa non sa"
```
---
## Cosa esiste alla fine di questo piano
Un cliente entra, compila il questionario, e vede il proprio referto col radar. Il consenso ai dati sanitari si può **negare davvero**, e il rifiuto resta registrato.
**Non esiste ancora**: il gestionale del trainer, l'inserimento dei check-up, l'assorbimento delle sezioni HRV di Stress Index, la progressione nel tempo. Sono i piani successivi.
⚠️ **E non si deploya niente.** Il consenso al trattamento dei dati sanitari è una questione aperta che il cliente sta chiudendo con un consulente legale: si può costruire tutto, non si può accendere la raccolta su persone vere finché quella risposta non arriva.
@@ -0,0 +1,476 @@
# Task 3 — Il radar, e come si mostra ciò che non si sa — report
**Stato:** completato. 6/6 test nuovi verdi, suite completa allo stato atteso (1 fallito
pre-esistente e fuori perimetro, 327 passati), `npx tsc --noEmit` pulito. Commit fatto.
**Commit:** `7f01ee0` — "longevity: il referto del cliente, e il radar che dice cosa non sa"
File aggiunti/modificati (solo questi, `git add` esplicito, nessun `-A`/`.`):
- `src/lib/longevity/vista.ts` (modificato) — `refertoDi`, `ultimaSessioneDi`, tipi
`AsseVista`/`MacroVista`, e le funzioni di supporto private
- `src/components/longevity/Radar.tsx` (nuovo) — isola React col radar recharts
- `src/components/longevity/MacroScore.astro` (nuovo) — carta di un macro-score
- `src/pages/longevity/io.astro` (nuovo) — la pagina del referto, nessuna query diretta
- `src/styles/longevity.css` (modificato) — classi `.lg-radar*`, `.lg-macro*`, `.lg-referto`,
`.lg-num`
- `tests/longevity/referto.test.ts` (nuovo) — i 6 test del brief
## Procedura seguita (TDD)
### Step 1-2: test scritto, verificato rosso
Comando:
```
npm test -- tests/longevity/referto.test.ts
```
Esito (prima dell'implementazione): `TypeError: refertoDi is not a function` e
`ENOENT` su `src/pages/longevity/io.astro` (non esisteva ancora) — 6 falliti su 6, come
atteso.
### Step 3: implementazione
**`src/lib/longevity/vista.ts` — `refertoDi(db, sessioneId)`**
Legge **solo** `leggiScore` (i punteggi congelati in `score`): non chiama mai
`calcolaSessione`. Per ogni asse/macro-score:
- se `stato === 'ok'`: `{ nome, valore: p.valore, copertura: p.copertura, mancano: [] }`;
- altrimenti: `valore: null`, `copertura` letta comunque dalla riga congelata (mai 0 finto
quando una copertura parziale esiste davvero), e per gli assi `mancano` calcolato.
L'ordine dei sette assi (e dei tre macro-score) viene dalla tabella `pesi`
(`GROUP BY contenitore ORDER BY MIN(rowid)`, non un `SELECT DISTINCT` senza ordine): rispetta
l'ordine di dichiarazione in `registro.ts` (`seedPesi` inserisce nell'ordine degli
`Object.entries`), in modo deterministico per il radar.
**`mancano`: la parte non banale del task.** I sette assi pesano dei *sotto-domini*
(`handgrip`, `vo2max`, `questionario_sonno`...), non dei `test_id` — e oggi **nessuna riga
di `registro_test` esiste per i sotto-domini fisici** (handgrip, spinta, VO2max, plank...):
il registro contiene solo i 20 campi del questionario. Quindi "i test del registro che
alimenterebbero quell'asse" non si possono leggere da `registro_test.asse` per un asse
fisico — sarebbe sempre vuoto. Ho preso `pesiDi(db, MODEL_VERSION, 'asse', nomeAsse)` (le
chiavi = i sotto-domini pesati, dato che esiste già) e confrontato con i sotto-domini che
la sessione ha davvero misurato (join `misure`→`registro_test`, `fuori_range=0`,
`valore_num IS NOT NULL`). Il sotto-dominio assente si traduce in un'etichetta leggibile
via una mappa locale `ETICHETTE_SOTTO_DOMINIO` in `vista.ts` — dato nuovo, necessario perché
il registro non ha ancora un'etichetta per quei sotto-domini (arriveranno col piano dei
test fisici); non duplica nulla che già esista.
**`ultimaSessioneDi(db, clientCode)`** — helper nuovo, non nel brief ma necessario: la
pagina deve trovare la sessione più recente del cliente loggato senza fare una query da
sola. `SELECT id FROM sessioni WHERE client_code=? ORDER BY data DESC, id DESC LIMIT 1`.
**`src/components/longevity/Radar.tsx`**
Due serie `recharts` sugli stessi sette assi (recharts vuole un `data` condiviso, non può
disegnare un poligono che copre solo alcuni assi):
- `noto` (piena): vale `a.valore ?? 0` — dove manca il dato, l'area si ritira verso il
centro, non si inventa un valore;
- `potenziale` (tratteggiata, `fill="none"`): vale `a.valore ?? 100` — dove il dato c'è
coincide col bordo di `noto` (sparisce dietro), dove manca si allarga al massimo,
**chiudendo** visivamente il perimetro proprio nei punti mancanti. È l'interpretazione
letterale di "una tratteggiata che chiude il perimetro dove il dato manca".
Sotto il grafico, l'elenco `mancano` per ogni asse ancora incompleto (filtrati su
`mancano.length > 0`).
Colori: hex letterali (`#9c8b70` accento, `#d8d2c8` tratteggio, `#e4dfd7` linea, `#8a8a8a`
testo), stesso pattern di `DataChart.tsx` — recharts riceve attributi SVG diretti, non
proprietà CSS, quindi `var(--c-accent)` non passa da lì; i valori corrispondono 1:1 ai
token di `longevity.css`.
**`src/components/longevity/MacroScore.astro`**
Numero se `valore !== null`, altrimenti `'—'` (mai `0`). Bordo tratteggiato
(`.lg-card--incompleto`, già esistente dal Task 1) quando insufficiente — stesso
linguaggio visivo del radar, non una convenzione nuova. Etichetta derivata dal nome del
contenitore (`PERFORMANCE`→`Performance`) con una trasformazione di stringa, non un
dizionario da tenere allineato a mano.
**`src/pages/longevity/io.astro`**
`Astro.locals.user!.id` → `codicePerUtenteLoggato` → `ultimaSessioneDi` → `refertoDi`.
Tre stati gestiti: nessun fascicolo collegato, fascicolo ma nessuna sessione ancora
(invito a compilare il questionario), referto pieno. Nessuna query SQL nel file.
**`src/styles/longevity.css`**
`.lg-num { font-variant-numeric: tabular-nums; }` applicata ai valori numerici (Fitness
Age, macro-score). Nuove classi `.lg-radar*`, `.lg-macro*`, `.lg-referto` — tutte
prefissate `lg-`, nessun colore nuovo (solo `var(--c-heading)`, `var(--c-text-light)`,
`var(--lg-tratteggio)`, già nella lista ammessa).
### Step 4: test target verde
Comando:
```
npm test -- tests/longevity/referto.test.ts
```
Output:
```
Test Files 1 passed (1)
Tests 6 passed (6)
```
### Step 5: suite completa + typecheck
Comando:
```
npm test
```
Output (coda):
```
❯ tests/modifiche-agosto.test.ts (25 tests | 1 failed) 38ms
× l'inaugurazione dice la data e che il programma manca ancora 7ms
FAIL tests/modifiche-agosto.test.ts > promo ed eventi > l'inaugurazione dice la data e che il programma manca ancora
AssertionError: expected 'Inaugurazione' to contain '19 settembre 2026'
Test Files 1 failed | 39 passed (40)
Tests 1 failed | 327 passed (328)
```
Esattamente il rosso pre-esistente dichiarato nel task (fuori perimetro) — nessun altro
test rotto, i 6 nuovi inclusi nel verde.
Comando:
```
npx tsc --noEmit
```
Output: nessuno (exit pulito).
### Step 6: commit
```
git add src/components/longevity/Radar.tsx src/components/longevity/MacroScore.astro \
src/pages/longevity/io.astro src/lib/longevity/vista.ts src/styles/longevity.css \
tests/longevity/referto.test.ts
git commit -m "longevity: il referto del cliente, e il radar che dice cosa non sa"
```
```
[feat/longevity 7f01ee0] longevity: il referto del cliente, e il radar che dice cosa non sa
6 files changed, 396 insertions(+), 1 deletion(-)
```
`git status` dopo il commit: albero pulito (a parte i file fuori dal perimetro di questo
task, non toccati).
## La prova richiesta: rompere il test sul congelamento — esito onesto
Ho fatto esattamente quanto chiesto: sostituita la lettura (`leggiScore`) con un
ricalcolo (`calcolaSessione`) dentro `refertoDi`, per riprodurre "il referto ricalcola
invece di leggere i punteggi salvati".
```diff
- import { leggiScore } from './motore';
+ import { leggiScore, calcolaSessione } from './motore';
...
- const punteggi = leggiScore(db, sessioneId);
+ const punteggi = calcolaSessione(db, sessioneId) as unknown as ReturnType<typeof leggiScore>;
```
Comando:
```
npm test -- tests/longevity/referto.test.ts
```
Esito: **6/6 ancora VERDI, compreso "legge i punteggi congelati, non li ricalcola al
volo".** Il test **non** ha intercettato la regressione. Non era un problema
d'implementazione della prova: l'ho verificato analiticamente e poi con un secondo
esperimento mirato.
**Perché resta verde — la causa esatta.** Il test del brief muta il peso di
`questionario_lifestyle`, che è l'**unico** sotto-dominio pesato dell'asse "Stile di Vita
& Sonno" (`{ questionario_lifestyle: 1.00 }`, nessun fratello). Nella media pesata di
`aggrega()` (`cascata.ts`), quando un solo elemento è disponibile il suo peso compare
identico sia al numeratore (`sommaPesata`) sia al denominatore (`pesoDisponibile`) — si
semplifica sempre, qualunque sia il valore. E la `copertura` fa lo stesso:
`pesoDisponibile / pesoTotale` con un solo elemento in tutto il dizionario è sempre `1.0`,
comunque lo si scali. Cambiare quel peso — con **questi identici dati di seed** (solo le
6 risposte di stile di vita) — non può mai produrre un `valore` o una `copertura` diversi,
sia che si legga lo score congelato sia che lo si ricalcoli da zero: il test è
strutturalmente incapace di distinguere le due cose, non solo in pratica ma per
costruzione matematica.
**Verifica che una mutazione ben scelta invece discrimina davvero.** Ho costruito
un esperimento a parte (non nel commit, solo per la prova — poi cancellato): stessa
sessione ma con **anche** `q_riposato: 8` fra le risposte (copre `questionario_sonno`
con un solo elemento su misura, ma nell'asse "Recupero & Sistema Nervoso" quel
sotto-dominio ha *tre fratelli assenti*: `hrv 0.50`, `pressione 0.15`, `hrr 0.15`,
`questionario_sonno 0.20`). Con `refertoDi` ancora rotto (ricalcolo), ho azzerato il peso
di `hrv` (0.50→0, un fratello **assente**, non l'elemento misurato):
```
npm test -- tests/longevity/_prova-rottura-temp.test.ts
```
```
❯ ... mutare il peso di un sotto-dominio ASSENTE in un asse multi-elemento sposta la copertura ricalcolata
AssertionError: expected '{"assi":[...]}' to be '{"assi":[...]}'
```
Rosso netto: la copertura vera di "Recupero & Sistema Nervoso" passa da `0.20/1.00=0.20`
a `0.20/0.50=0.40`, attraversa la soglia (`COPERTURA_MINIMA=0.4`) e l'asse passerebbe da
`insufficiente` a `ok` (valore `80` invece di `null`) — con effetto a cascata sui
macro-score `ENERGY`/`RECOVERY`. Ripristinato `vista.ts` (tolto il ricalcolo), lo stesso
test torna verde:
```
npm test -- tests/longevity/_prova-rottura-temp.test.ts tests/longevity/referto.test.ts
```
```
Test Files 2 passed (2)
Tests 7 passed (7)
```
Confermato: **rotto → rosso, riparato → verde**, per la mutazione giusta. Il file di prova
(`_prova-rottura-temp.test.ts`) è stato cancellato e non è nel commit — non fa parte del
perimetro del task, e con la sola compilazione lifestyle (quella del test ufficiale) non
esiste alcuna mutazione dei pesi che lo renderebbe rilevante (ogni altro asse ha copertura
zero: la stessa scorciatoia `disponibili.length === 0` in `aggrega()` che rende
irrilevante qualunque peso quando NESSUN sotto-dominio è misurato).
**Cosa faccio con questo.** Il test così come specificato nel brief (Step 1, scritto
verbatim) **resta nel commit senza modifiche**: è quello richiesto letteralmente
dall'assegnazione, e la sua debolezza è specifica alla combinazione "asse a un solo
sotto-dominio" + "seed dati che copre solo quello". Non l'ho riscritto di mia iniziativa
— ma è un ottavo test (il settimo l'ha trovato il Task 2) che sembra proteggere il
congelamento e in realtà, con questi dati, non può proteggerlo per costruzione
matematica. Se questa suite deve davvero blindare la regola "non si ricalcola al volo",
la mutazione giusta è quella sopra: il peso di un **fratello assente** in un asse con
**più di un sotto-dominio dichiarato** e **copertura parziale non-zero** (non un asse a
un solo elemento, e non un asse completamente vuoto).
Le altre tre probe di rottura (mirate ai punti che il brief segnala esplicitamente come
critici) **hanno tutte funzionato come dovuto**:
- **"un asse senza dati NON porta un numero"**: sostituito `valore: null` con
`valore: 0` per l'asse insufficiente → rosso immediato
(`expected +0 to be null`). Ripristinato → verde.
- **"e dice cosa manca... invece di lasciare un buco"**: `mancanoPer` forzata a
restituire sempre `[]` → rosso immediato (`expected 0 to be greater than 0`).
Ripristinato → verde.
- **"nessuna pagina interroga il database da sola"**: aggiunta una `db.prepare('SELECT 1')`
in `io.astro` → rosso immediato (il test intercetta letteralmente `SELECT`/`.prepare(`
nel sorgente). Ripristinato → verde.
Suite completa e typecheck riverificati allo stato finale (dopo tutte le prove e i
ripristini):
```
npm test
```
```
Test Files 1 failed | 39 passed (40)
Tests 1 failed | 327 passed (328)
```
```
npx tsc --noEmit
```
Output: nessuno (pulito).
```
git status --short
```
```
(nessuna riga: albero pulito, nessun residuo delle prove di rottura)
```
## Cosa esiste alla fine di questo piano
Come previsto dal brief: un cliente entra, compila il questionario, vede il proprio
referto — il radar a sette assi con l'area piena su ciò che sa e il perimetro tratteggiato
(più l'elenco di cosa manca) su ciò che non sa ancora, la Fitness Age, le tre carte
Performance/Energy/Recovery. Con i dati di oggi (solo questionario, nessun test fisico)
**sei assi su sette** risultano tratteggiati — verificato, non solo dichiarato: nel test
"restituisce i sette assi" tutti e sette compaiono comunque, e nella sessione di prova
solo "Stile di Vita & Sonno" porta un numero.
Non deployato: come da vincolo del piano (consenso dati sanitari ancora aperto col
consulente legale del cliente).
---
## Rinforzo dopo revisione (2026-08-22): la prova sul congelamento resa discriminante
**Stato:** completato. Commit `6630be7` sopra a `7f01ee0`.
**Il problema confermato dal coordinatore:** la mutazione originale del test "legge i
punteggi congelati, non li ricalcola al volo" (peso di `questionario_lifestyle`, unico
sotto-dominio del suo asse) passava per un'identita' algebrica — con un solo elemento
disponibile in una media pesata, il peso si semplifica sia al numeratore sia al
denominatore, quindi non puo' mai produrre una differenza, letto o ricalcolato che sia. Il
test era verde per costruzione matematica, non perche' il congelamento funzionasse.
### Correzione: la mutazione discriminante, con seed dedicato
`tests/longevity/referto.test.ts`:
- Aggiunta `conCoperturaParziale()`, come `conCompilazione()` ma con in piu' la risposta
`q_riposato: 8` (copre `questionario_sonno`). Serve solo a questo test: gli altri cinque
restano su `conCompilazione()`, invariati.
- Il test "legge i punteggi congelati..." ora usa `conCoperturaParziale()` e muta il peso
di `hrv` (0.50 → 0) in "Recupero & Sistema Nervoso" — un sotto-dominio **assente** in un
asse a **quattro** elementi, dove solo `questionario_sonno` (0.20) e' misurato: copertura
vera 0.20/1.00=0.20 (insufficiente, ma `disponibili.length===1`, non 0 — la formula del
peso viene eseguita davvero, non bypassata dalla scorciatoia "nessun dato disponibile" di
`aggrega()`). Azzerando `hrv` il totale scende a 0.50, copertura vera 0.20/0.50=0.40:
attraversa `COPERTURA_MINIMA`, l'asse passerebbe da insufficiente a ok **se** il referto
ricalcolasse.
- Commento esteso sopra la mutazione: spiega sia perche' QUESTA mutazione discrimina sia
perche' quella vecchia (peso dell'unico sotto-dominio di un asse a un solo elemento) non
discrimina mai — cosi' chi la tocca fra sei mesi non rimette una versione innocua.
### La prova di rottura ripetuta sulla versione nuova
Backup di `vista.ts`, poi iniettato lo stesso ricalcolo di prima (`calcolaSessione` al
posto di `leggiScore`):
```diff
- import { leggiScore } from './motore';
+ import { leggiScore, calcolaSessione } from './motore';
...
- const punteggi = leggiScore(db, sessioneId);
+ const punteggi = calcolaSessione(db, sessioneId) as unknown as ReturnType<typeof leggiScore>;
```
Comando:
```
npm test -- tests/longevity/referto.test.ts
```
Output:
```
❯ tests/longevity/referto.test.ts (6 tests | 1 failed) 20ms
× legge i punteggi congelati, non li ricalcola al volo 7ms
AssertionError: expected '...' to be '...'
Expected: ...,"Recupero & Sistema Nervoso","valore":null,"copertura":0.2,...
Received: ...,"Recupero & Sistema Nervoso","valore":80,"copertura":0.4,...
```
**Rosso, come atteso**: l'asse passa da `null`/`0.2` a `80`/`0.4`, con effetto a cascata su
`ENERGY`/`RECOVERY`. Ripristinato `vista.ts` dal backup (`diff` col backup: nessuna
differenza), rilanciato:
```
npm test -- tests/longevity/referto.test.ts
```
```
Test Files 1 passed (1)
Tests 6 passed (6)
```
**Verde**, come atteso.
### Verifiche finali
```
npm test
```
```
Test Files 1 failed | 39 passed (40)
Tests 1 failed | 327 passed (328)
```
Stesso rosso pre-esistente e fuori perimetro (`tests/modifiche-agosto.test.ts > ...
inaugurazione ...`); nessuna regressione.
```
npx tsc --noEmit
```
Output: nessuno (pulito).
### Commit
```
git add tests/longevity/referto.test.ts
git commit -m "longevity: la prova sul congelamento ora discrimina davvero il ricalcolo"
```
```
[feat/longevity 6630be7] longevity: la prova sul congelamento ora discrimina davvero il ricalcolo
1 file changed, 44 insertions(+), 3 deletions(-)
```
`git status` dopo il commit: albero pulito.
---
## Rinforzo dopo revisione (2026-08-22): il controllo sui colori ora guarda tutto il foglio
**Stato:** completato. Commit `a56398e` sopra a `6630be7`.
**Il problema confermato dal coordinatore:** il test "nelle regole di un punteggio
compaiono solo i colori ammessi" (`tests/longevity/pagine.test.ts`) filtrava le regole CSS
sul selettore (`if (!/lg-score/i.test(selettore)) continue;`) — un elenco di QUALI regole
guardare, non di quali colori sono ammessi. Le classi nuove di questo task
(`.lg-radar__voce-nome`, `.lg-radar__intro`, `.lg-macro__nota`) non contengono la stringa
`lg-score`, quindi non venivano mai scandite: un colore-giudizio lì dentro sarebbe passato
indisturbato nonostante 6 test verdi.
### Correzione: si scandisce tutto, le eccezioni si dichiarano
`tests/longevity/pagine.test.ts` — rimossa la riga di filtro sul selettore. Il ciclo ora
itera **ogni** regola del foglio (che e' interamente prefissato `lg-` per costruzione,
garantito dal test subito sopra). Aggiunto un elenco di eccezioni esplicite:
```ts
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",
},
];
```
Con lo scan esteso a tutto il foglio, questa era l'unica riga che avrebbe generato un
falso positivo (`color: #fff` sul bottone "Avanti/Invia" pieno d'accento — bianco per
contrasto testo, non un giudizio sul punteggio): tutte le altre classi nuove del Task 3
usano gia' solo `var(--c-heading)`/`var(--c-text-light)`, entrambe nella lista ammessa,
quindi passano senza bisogno di eccezioni.
### Le due prove richieste
**1. Colore-giudizio su una classe del radar.** Iniettato `color: #cc0000;` in
`.lg-radar__voce-nome`:
```
npm test -- tests/longevity/pagine.test.ts
```
```
❯ ... in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate
AssertionError: expected [ Array(1) ] to deeply equal []
+ [ ".lg-radar__voce-nome { color: #cc0000 }" ]
```
**Rosso.** Ripristinato dal backup.
**2. Colore-giudizio su una classe del macro-score.** Iniettato `color: #cc0000;` in
`.lg-macro__nota`:
```
npm test -- tests/longevity/pagine.test.ts
```
```
❯ ... in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate
AssertionError: expected [ Array(1) ] to deeply equal []
+ [ ".lg-macro__nota { color: #cc0000 }" ]
```
**Rosso.** Ripristinato dal backup (`diff` col backup: nessuna differenza).
Rilanciato dopo il secondo ripristino:
```
npm test -- tests/longevity/pagine.test.ts
```
```
Test Files 1 passed (1)
Tests 6 passed (6)
```
**Verde.** Entrambe le classi (radar e macro-score) sono ora sorvegliate — nessun buco
residuo fra le due superfici nuove di questo task.
### Verifiche finali
```
npm test
```
```
Test Files 1 failed | 39 passed (40)
Tests 1 failed | 327 passed (328)
```
Stesso rosso pre-esistente e fuori perimetro (`tests/modifiche-agosto.test.ts > ...
inaugurazione ...`); nessuna regressione.
```
npx tsc --noEmit
```
Output: nessuno (pulito).
### Commit
```
git add tests/longevity/pagine.test.ts
git commit -m "longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score"
```
```
[feat/longevity a56398e] longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score
1 file changed, 23 insertions(+), 5 deletions(-)
```
`git status` dopo il commit: albero pulito.