diff --git a/.superpowers/sdd/.gitignore b/.superpowers/sdd/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/.superpowers/sdd/.gitignore @@ -0,0 +1 @@ +* diff --git a/.superpowers/sdd/2026-08-22-longevity-interfaccia/progress.md b/.superpowers/sdd/2026-08-22-longevity-interfaccia/progress.md new file mode 100644 index 0000000..85c7450 --- /dev/null +++ b/.superpowers/sdd/2026-08-22-longevity-interfaccia/progress.md @@ -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. diff --git a/.superpowers/sdd/2026-08-22-longevity-interfaccia/review-2ea822a..f0629db.diff b/.superpowers/sdd/2026-08-22-longevity-interfaccia/review-2ea822a..f0629db.diff new file mode 100644 index 0000000..fcf3a76 --- /dev/null +++ b/.superpowers/sdd/2026-08-22-longevity-interfaccia/review-2ea822a..f0629db.diff @@ -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>({}); ++ const [liberi, setLiberi] = useState>({}); ++ const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>(''); ++ const [eta, setEta] = useState(''); ++ const [inviando, setInviando] = useState(false); ++ const [esito, setEsito] = useState(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 = {}; ++ 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 ( ++
++

Questionario salvato, grazie.

++
++ ); ++ } ++ ++ return ( ++
++

Passo {passo + 1} di {totalePassi}

++ ++ {passo < gruppi.length && ( ++
++

{gruppi[passo].titolo}

++ {gruppi[passo].campi.map((c) => ( ++ ++ ))} ++
++ )} ++ ++ {ultimo && ( ++
++

Note e consenso

++ ++ {CAMPI_LIBERI.map((c) => ( ++