Compare commits

..

10 Commits

Author SHA1 Message Date
Adriano 1d6bdd0b3d longevity: revisione finale — il motore ora si calcola, il consenso è atomico, il codice cliente è tipato fuori dal corpo
Sei correzioni dalla revisione finale del 22/08, ognuna con test scritto prima:

1. salvaDalForm calcolava mai i punteggi (calcolaSessione/salvaScore) dopo il
   salvataggio: un cliente compilava venti domande e vedeva un referto vuoto.
   Ora avviene nella stessa transazione del salvataggio.
2. Il corpo della richiesta dell'endpoint questionario è tipato
   (CorpoQuestionario): client_code non esiste in quel tipo, non è più
   prendibile per errore. Il vecchio test a lista di nomi vietati era vacuo
   (cercava body./corpo., il file usa data.) — sostituito con un test
   comportamentale end-to-end sull'endpoint vero.
3. Il consenso: aggiunto il test sul ramo affermativo (non coperto, vacuo
   quanto il punto 2); la nota di consenso ora è nella stessa transazione dei
   dati sanitari; ha una versione propria (CONSENT_VERSION) invece di quella
   del questionario.
4. La prima apertura del referto ora spiega invece di sembrare rotta: Fitness
   Age con nota quando manca, spiegazione del radar prima del grafico,
   copertura sempre visibile, invito a fare i test in sala col trainer,
   numeri formattati all'italiana.
5. Aggiunto il link per ricompilare il questionario nella pagina del referto
   (prima spariva appena esisteva una compilazione).
6. Il test anti-query ora scandisce tutte le pagine sotto src/pages/longevity/,
   non due nomi a mano. Il test anti-giunzione identity/longevity resta con lo
   stesso limite di sempre (createIdentityDb è esportata per creaCliente) —
   corretto solo il commento perché lo dichiari onestamente.

Report con comandi e output letterali:
.superpowers/sdd/2026-08-22-longevity-interfaccia/review-finale-report.md
2026-08-22 15:17:47 +02:00
Adriano a56398ea02 longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score
Filtrava le regole sul selettore ("solo quelle con lg-score nel nome"): un elenco
di quali regole guardare invecchia appena nasce una classe nuova, ed e' successo
subito con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3), mai
controllate perche' non contengono quella stringa. Tolto il filtro sul selettore:
ora si scandisce ogni regola del foglio (che e' interamente prefissato lg- per
costruzione), con un elenco di eccezioni dichiarate (selettore + valore + motivo)
per gli usi legittimi di un colore fuori lista (il #fff del testo sul bottone
pieno d'accento). Verificato rosso con un colore-giudizio iniettato sia su una
classe del radar sia su una del macro-score, verde ripristinato in entrambi i casi.
2026-08-22 14:43:57 +02:00
Adriano 6630be7a02 longevity: la prova sul congelamento ora discrimina davvero il ricalcolo
Il peso mutato prima era l'unico sotto-dominio del suo asse (questionario_lifestyle,
1.00 in Stile di Vita & Sonno): in una media pesata a un solo elemento disponibile
il peso si semplifica sia al numeratore sia al denominatore, quindi il test restava
verde anche se refertoDi avesse ricalcolato invece di leggere gli score congelati.
Sostituita con il peso di un sotto-dominio ASSENTE in un asse a piu' elementi con
copertura parziale non nulla (hrv in Recupero & Sistema Nervoso): li' un ricalcolo
sposta davvero la copertura oltre soglia. Verificato rosso col ricalcolo forzato,
verde ripristinato.
2026-08-22 14:32:43 +02:00
Adriano 7f01ee0b45 longevity: il referto del cliente, e il radar che dice cosa non sa 2026-08-22 14:28:29 +02:00
Adriano 8c980750c0 longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts
Il revisore ha dimostrato che il test anti-giunzione si aggira senza malizia:
tre file (uno riesporta la connessione a identity, uno quella a longevity, un
terzo le importa da entrambi e le usa insieme) e nessuno dei tre contiene
singolarmente le due parole cercate. Un controllo testuale file-per-file non
puo' vedere una giunzione che attraversa piu' file - quindi la risposta non
e' un test piu' furbo, e' rendere la cosa impossibile invece di sorvegliarla.

- db.ts: rimossa l'esportazione di getIdentityDb. Resta solo getLongevityDb
  (il database senza nomi, puo' usarlo chiunque) e createIdentityDb (serve ai
  test per aprire database in memoria - li' la giunzione e' voluta ed
  evidente, il problema era il singleton condiviso, non la fabbrica).
- anagrafica.ts: getIdentityDb torna dentro, privata (non esportata) e usata
  solo da codicePerUtenteLoggato. Verificato che il bypass a tre file del
  revisore ora fallisce gia' a tsc --noEmit ('has no exported member named
  getIdentityDb'), non solo a un test che lo sorveglia.
- tests/longevity/anagrafica.test.ts: il test anti-giunzione resta come rete
  secondaria, con un commento che dichiara onestamente cosa non vede (una
  giunzione via re-export su piu' file) invece di promettere una garanzia che
  non mantiene.

Due correzioni minori dalla stessa revisione:
- api/longevity/questionario.ts: salvaDalForm (via salvaCompilazione) lancia
  se una risposta porta un test_id sconosciuto al registro; ora e' avvolta in
  try/catch e risponde 400 pulito invece di un 500 generico, come gia' fa
  src/pages/api/contact.ts per l'invio email.
- components/longevity/Questionario.tsx: un campo con tipo_valore='txt' nel
  registro ora rende un input testuale (non piu' sempre "number"), e in
  invio i campi testuali non vengono convertiti in NaN e scartati in
  silenzio - restano fuori esplicitamente, con un commento che dice perche'.
2026-08-22 14:12:43 +02:00
Adriano f0629db3c4 longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
L'endpoint /api/longevity/questionario apriva anche identity (getIdentityDb)
per risolvere il client_code, violando la garanzia della spec (§4): un solo
modulo, anagrafica.ts, tiene insieme le due connessioni. La logica di
giunzione era gia' al posto giusto (chiamava codicePerUtente, che sta in
anagrafica.ts) ma l'APERTURA delle connessioni no.

- anagrafica.ts: nuova codicePerUtenteLoggato(userId), apre lei stessa
  identity via getIdentityDb() (e' il modulo autorizzato) e delega a
  codicePerUtente. L'endpoint chiama solo questa: apre esclusivamente
  longevity, per salvare.
- tests/longevity/anagrafica.test.ts: il test anti-giunzione cercava due nomi
  letterali (createIdentityDb/createLongevityDb) e non vedeva getIdentityDb/
  getLongevityDb, nati dopo di lui - la stessa lezione del semaforo nel Task 1
  (proteggere il nome invece della cosa). Ora e' strutturale: segnala
  qualunque file (esclusi anagrafica.ts e db.ts) che contenga sia "Identity"
  sia "Longevity", in qualunque forma. Verificato con un file finto sotto
  src/ che apriva entrambe con i nomi nuovi: il test diventa rosso, poi
  torna verde dopo la cancellazione.
2026-08-22 14:01:33 +02:00
Adriano f5ca841d92 longevity: il questionario in pagina, e il consenso che si puo negare davvero
Sostituisce il prototipo del cliente (salvava anche con "No" al consenso
sanitario, perche' controllava solo che il campo non fosse vuoto). Qui il
consenso si verifica PRIMA di scrivere qualunque dato: se non e' affermativo
non si apre nessuna sessione, e la risposta lo dice. Il rifiuto si registra
comunque fra le note di profilo, con esito e versione del questionario: va
dimostrato tanto quanto un assenso.

- src/lib/longevity/vista.ts: campiDelQuestionario (dal registro, non da una
  lista nel codice) e salvaDalForm (consenso prima di tutto, poi delega a
  salvaCompilazione).
- src/pages/api/longevity/questionario.ts: il client_code arriva SEMPRE da
  codicePerUtente sulla sessione, mai dal corpo della richiesta.
- src/lib/longevity/db.ts: getLongevityDb/getIdentityDb, connessioni singole
  per processo (come getDb() del sito), cosi' l'endpoint non apre una nuova
  connessione a ogni richiesta.
- src/components/longevity/Questionario.tsx + pages/longevity/questionario.astro:
  isola React a quattro blocchi (le tre aree di domande + note/consenso), la
  pagina non interroga mai il database da sola.
2026-08-22 13:55:16 +02:00
Adriano 2ea822a84d longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto 2026-08-22 13:43:09 +02:00
Adriano 34c66ca438 longevity: il guscio della piattaforma, senza semaforo sui punteggi 2026-08-22 13:33:21 +02:00
Adriano 779aef32e6 docs: piano dell'interfaccia — questionario e referto del cliente 2026-08-22 13:29:54 +02:00
23 changed files with 2043 additions and 2 deletions
@@ -0,0 +1,405 @@
# Longevity — l'interfaccia: piano di implementazione
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** dare una faccia al fascicolo del cliente — il questionario che si compila in pagina e il referto che il cliente legge, col radar a sette assi.
**Architecture:** pagine Astro dentro il sito esistente, sotto `/longevity/`, con un layout dedicato e un foglio di stile prefissato. Il contenuto statico è Astro; React solo dove serve interattività vera — il questionario a blocchi e il radar. I dati arrivano dal motore già costruito, mai da query scritte nelle pagine.
**Tech Stack:** Astro, React, recharts (già in casa), better-sqlite3, vitest. Nessuna dipendenza nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
**Piani precedenti, già eseguiti:** `2026-08-21-longevity-strato-dati.md` e `2026-08-22-longevity-motore.md`. Lo strato dati e il motore esistono, sono testati, e nessuna pagina li usa ancora.
## Global Constraints
- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
- **Nessuna dipendenza npm nuova.**
- Test in `tests/longevity/`, eseguiti con `npm test`. Codice e commenti in italiano.
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
- ⚠️ **`npx tsc --noEmit` deve restare pulito.** In questo lavoro il tipo è il meccanismo di sicurezza: un typecheck rosso è un cancello che nessuno guarda più.
- **Nessun dato reale di persone** nelle fixture.
## Le due regole di prodotto che l'interfaccia non può violare
**1. Un punteggio insufficiente non si mostra come numero.** Il motore restituisce un tipo che nel ramo insufficiente **non ha il campo `valore`**: la pagina non ha da dove prenderlo. Ma la barriera finisce lì — la tabella dei punteggi è letta da una funzione tipata (`leggiScore`), e **le pagine devono usare quella**, mai una query propria. Una `SELECT valore FROM score` scritta in una pagina riapre il buco.
**2. Il colore non giudica il corpo.** Niente semaforo verde/giallo/rosso sui punteggi: il servizio è premium ma **non clinico**, e un rosso su «Composizione Corporea» detto a una persona è un giudizio, non un'informazione. I punteggi si esprimono con l'**intensità** del colore d'accento del sito. L'unico colore di segnale — il mattone `#b05a4e` già presente nel foglio di Stress Index — è riservato ai **valori fuori range**, che segnalano un problema **della misura**, non della persona.
## Il gesto che questa interfaccia deve avere
Il radar mostra **quello che sappiamo** come area piena, e dove la misura non basta lascia un **perimetro tratteggiato** con, sotto, cosa manca per completarlo. Non un buco, non un errore: un invito.
Regge tre cose insieme — la regola di prodotto («tratteggiato, mai un numero pieno fasullo»), la leva commerciale che il cliente voleva per il livello avanzato, e una posizione onesta: *non ti diciamo un numero che non sappiamo*.
⚠️ **Con i dati di oggi sei assi su sette saranno tratteggiati**, perché nel registro c'è solo il questionario e i test fisici arrivano col piano degli import. È atteso: chi guarda la prima dashboard non deve scambiarlo per un guasto.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `src/layouts/Longevity.astro` | layout della piattaforma: intestazione, niente header pubblico |
| `src/styles/longevity.css` | stile prefissato `.lg`, globale (deve raggiungere le isole React) |
| `src/pages/longevity/questionario.astro` | la pagina del questionario |
| `src/components/longevity/Questionario.tsx` | isola React: quattro blocchi, validazione, invio |
| `src/pages/api/longevity/questionario.ts` | endpoint che salva una compilazione |
| `src/pages/longevity/io.astro` | il referto del cliente |
| `src/components/longevity/Radar.tsx` | isola React: il radar a sette assi col tratteggio |
| `src/components/longevity/MacroScore.astro` | le tre carte dei macro-score |
| `src/lib/longevity/vista.ts` | ciò che serve alle pagine, letto dal motore |
---
### 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"
```
---
### 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"
```
---
### 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.
+37
View File
@@ -0,0 +1,37 @@
---
// La carta di un macro-score (Performance/Energy/Recovery). Stesso principio del radar:
// il numero se il dato basta, un trattino se no — MAI uno zero, che si leggerebbe come
// "hai zero" invece di "non lo sappiamo ancora". Il bordo tratteggiato (.lg-card--incompleto,
// dal Task 1) è lo stesso linguaggio visivo del radar: non una convenzione nuova per le carte.
// L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un
// elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery
// con una trasformazione, non con un dizionario da tenere allineato a mano.
import { formattaNumero } from '../../lib/longevity/formato';
interface Props {
nome: string;
valore: number | null;
copertura: number;
}
const { nome, valore, copertura } = Astro.props;
const etichetta = nome.charAt(0) + nome.slice(1).toLowerCase();
// L'intensità dell'accento (mai un colore diverso, Task 1): minimo 0.15 così una carta
// nota ma bassa resta comunque leggibile, non sparisce nello sfondo.
const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0;
---
<div class:list={['lg-card', 'lg-macro__card', { 'lg-card--incompleto': valore === null }]}>
<p class="lg-score__label">{etichetta}</p>
<div class="lg-score">
<span class="lg-score__value lg-num">{valore !== null ? formattaNumero(valore) : '—'}</span>
<span class="lg-score__max">/ 100</span>
</div>
<div class="lg-score__bar">
<span
class="lg-score__bar-fill"
style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`}
></span>
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(copertura * 100)}%{valore === null && ' — non ancora sufficiente per un numero.'}
</p>
</div>
+192
View File
@@ -0,0 +1,192 @@
// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
//
// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
// proteggere e non protegge). La verifica che conta è quella del server
// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
import { useMemo, useState } from 'react';
import type { CampoQuestionario } from '../../lib/longevity/vista';
interface Props {
campi: CampoQuestionario[];
}
const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
{ id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
{ id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
{ id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
{ id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
];
const BLOCCHI = [
{ titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
{ titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
{ titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
] as const;
type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
export default function Questionario({ campi }: Props) {
const [passo, setPasso] = useState(0);
const [risposte, setRisposte] = useState<Record<string, string>>({});
const [liberi, setLiberi] = useState<Record<string, string>>({});
const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
const [eta, setEta] = useState('');
const [inviando, setInviando] = useState(false);
const [esito, setEsito] = useState<Esito | null>(null);
// Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
// ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
const gruppi = useMemo(
() => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
[campi]
);
// Il tipo dichiarato nel registro per ciascun campo: serve sia per scegliere il tipo di
// input (numero o testo) sia, all'invio, per non trattare una risposta testuale come un
// numero da scartare in silenzio.
const tipoDiCampo = useMemo(() => new Map(campi.map((c) => [c.id, c.tipo])), [campi]);
const totalePassi = gruppi.length + 1; // + il blocco note/consenso
const ultimo = passo === totalePassi - 1;
async function invia() {
setInviando(true);
setEsito(null);
const risposteNumeriche: Record<string, number> = {};
for (const [id, v] of Object.entries(risposte)) {
// Le domande testuali (tipo_valore='txt' nel registro) non sono ancora agganciate
// al salvataggio numerico: oggi non esistono nel questionario (i 20 campi attivi
// sono tutti 'num'), ma se un domani ne comparisse una non va convertita in NaN e
// scartata in silenzio — resta fuori esplicitamente.
if (tipoDiCampo.get(id) === 'txt') continue;
const n = Number(v);
if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
}
try {
const res = await fetch('/api/longevity/questionario', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risposte: risposteNumeriche,
liberi,
consensoSanitario: consenso,
eta: eta === '' ? undefined : Number(eta),
}),
});
const corpo = await res.json();
if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
} catch {
setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
} finally {
setInviando(false);
}
}
if (esito?.tipo === 'ok') {
return (
<div className="lg-card">
<p>Questionario salvato, grazie.</p>
</div>
);
}
return (
<div className="lg-form">
<p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
{passo < gruppi.length && (
<section className="lg-form__blocco">
<h2>{gruppi[passo].titolo}</h2>
{gruppi[passo].campi.map((c) => (
<label className="lg-field" key={c.id}>
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
{c.tipo === 'txt' ? (
<input
type="text"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
) : (
<input
type="number"
min={c.min}
max={c.max}
step="any"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
)}
</label>
))}
</section>
)}
{ultimo && (
<section className="lg-form__blocco">
<h2>Note e consenso</h2>
{CAMPI_LIBERI.map((c) => (
<label className="lg-field" key={c.id}>
<span>{c.etichetta}</span>
<textarea
value={liberi[c.id] ?? ''}
onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
/>
</label>
))}
<label className="lg-field">
<span>La tua età</span>
<input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
</label>
<fieldset className="lg-field lg-field--consenso">
<legend>
Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
composizione corporea) per il monitoraggio del percorso?
</legend>
<label>
<input
type="radio" name="consenso" checked={consenso === 'Si'}
onChange={() => setConsenso('Si')}
/>
, acconsento
</label>
<label>
<input
type="radio" name="consenso" checked={consenso === 'No'}
onChange={() => setConsenso('No')}
/>
No, non acconsento
</label>
</fieldset>
{esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
</section>
)}
<div className="lg-form__nav">
<button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
Indietro
</button>
{!ultimo && (
<button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
Avanti
</button>
)}
{ultimo && (
<button type="button" disabled={inviando || consenso === ''} onClick={invia}>
{inviando ? 'Invio…' : 'Invia'}
</button>
)}
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
// Il radar a sette assi del referto: isola React perché recharts ha bisogno del DOM.
// Due serie sovrapposte sugli stessi sette assi (recharts vuole un solo `data` condiviso,
// non può disegnare un poligono che copre solo alcuni assi):
// - "noto": l'area piena, coi valori congelati che la sessione ha davvero. Dove il dato
// manca vale 0 — non si inventa un centro pieno per una misura che non c'è, si lascia
// che l'area si ritiri, onestamente, verso il centro;
// - "potenziale": il perimetro tratteggiato. Dove il dato c'è coincide col bordo di
// "noto" (stesso valore, la linea tratteggiata sparisce dietro quella piena); dove
// manca si allarga fino al massimo (100) — è la parte che CHIUDE il perimetro, il gesto
// su cui si regge l'interfaccia: non un errore, un invito a completare quell'asse.
// Sotto il grafico, l'elenco leggibile di cosa completerebbe ogni asse ancora tratteggiato,
// letto da `mancano` (calcolato in vista.ts, mai qui: questo componente non tocca il db).
import {
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
} from 'recharts';
import type { AsseVista } from '../../lib/longevity/vista';
interface Props {
assi: AsseVista[];
}
// Stessi colori del sito (come in DataChart.tsx, che per lo stesso motivo — attributi SVG,
// non proprietà CSS — non passa var(--...) a recharts): l'accento e il tratteggio di
// longevity.css, non un colore nuovo.
const ACCENTO = '#9c8b70';
const TRATTEGGIO = '#d8d2c8';
const LINEA = '#e4dfd7';
const TESTO_CHIARO = '#8a8a8a';
export default function RadarAssi({ assi }: Props) {
const dati = assi.map((a) => ({
nome: a.nome,
noto: a.valore ?? 0,
potenziale: a.valore ?? 100,
}));
const incompleti = assi.filter((a) => a.mancano.length > 0);
return (
<div className="lg-radar">
{/* La spiegazione del linguaggio visivo viene PRIMA del grafico che spiega, non dopo:
chi legge deve sapere cosa sta guardando prima di guardarlo. */}
<p className="lg-radar__intro">
L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto
dell'asse, dove il dato non c'è ancora.
</p>
<div className="lg-radar__grafico">
<ResponsiveContainer width="100%" height={360}>
<RadarChart data={dati} outerRadius="72%">
<PolarGrid stroke={LINEA} />
<PolarAngleAxis dataKey="nome" tick={{ fill: TESTO_CHIARO, fontSize: 11 }} />
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
{/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */}
<Radar
name="Perimetro completo"
dataKey="potenziale"
stroke={TRATTEGGIO}
strokeDasharray="4 3"
strokeWidth={1.5}
fill="none"
isAnimationActive={false}
/>
<Radar
name="Quello che sappiamo"
dataKey="noto"
stroke={ACCENTO}
fill={ACCENTO}
fillOpacity={0.32}
strokeWidth={2}
isAnimationActive={false}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{incompleti.length > 0 && (
<div className="lg-radar__mancano">
{/* L'invito concreto: l'elenco sotto è nomi di test, da solo non dice cosa fare.
Questi test si fanno in palestra con lo staff, non a casa da soli. */}
<p className="lg-muted">
Questi test si fanno in sala, con il trainer: prenota la prossima sessione per
completare il quadro.
</p>
{incompleti.map((a) => (
<div className="lg-radar__voce" key={a.nome}>
<p className="lg-radar__voce-nome">{a.nome}</p>
<ul className="lg-radar__lista">
{a.mancano.map((m) => <li key={m}>{m}</li>)}
</ul>
</div>
))}
</div>
)}
</div>
);
}
+47
View File
@@ -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>
+23
View File
@@ -4,6 +4,18 @@
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3';
import { createIdentityDb } from './db';
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
// (un controllo testuale file-per-file non lo vedrebbe, ma qui il problema non si pone:
// non esiste un nome da importare). QUESTO è il modo in cui il vincolo si rende vero,
// non solo sorvegliato.
let identitySingleton: Database.Database | null = null;
function getIdentityDb(): Database.Database {
if (!identitySingleton) identitySingleton = createIdentityDb();
return identitySingleton;
}
/**
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
@@ -62,6 +74,17 @@ export function codicePerUtente(identity: Database.Database, userId: number): st
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);
+14
View File
@@ -134,3 +134,17 @@ export function createLongevityDb(path?: string): Database.Database {
export function createIdentityDb(path?: string): Database.Database {
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
}
// Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
// ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton
// analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico
// modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione
// che passa da un re-export su piu' file, quindi la connessione condivisa a identity non
// deve essere raggiungibile da fuori quel modulo, punto.
let longevitySingleton: Database.Database | null = null;
export function getLongevityDb(): Database.Database {
if (!longevitySingleton) longevitySingleton = createLongevityDb();
return longevitySingleton;
}
+11
View File
@@ -0,0 +1,11 @@
// Formattazione dei numeri del referto. Un solo posto, perché prima ognuno stampava il
// numero grezzo di JavaScript (punto, non virgola) direttamente nel template — e un
// referto italiano con "72.5" al posto di "72,5" si legge come un difetto, non come un
// dettaglio.
/** Un punteggio o una Fitness Age, formattati con la virgola decimale italiana.
* Le ETICHETTE dei punteggi (PERFORMANCE/ENERGY/RECOVERY, nomi di prodotto) non si
* toccano: solo i NUMERI si leggono all'italiana in un referto italiano. */
export function formattaNumero(n: number): string {
return n.toLocaleString('it-IT');
}
+6
View File
@@ -5,6 +5,12 @@ import { esisteTest } from './registro';
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
export const QUEST_VERSION = 'v1.0';
/** Versione del TESTO del consenso al trattamento dei dati sanitari — distinta da
* QUEST_VERSION apposta: se cambia la formulazione del consenso senza che cambi una
* domanda del questionario, il record del consenso deve poterlo dire. Prima di questa
* costante il record portava QUEST_VERSION, la versione sbagliata. */
export const CONSENT_VERSION = 'v1.0';
/**
* Campi testuali che si salvano ma non entrano MAI in una formula
* (indicazione esplicita del cliente, punto 7 della specifica).
+250
View File
@@ -0,0 +1,250 @@
//
// La vista che il questionario in pagina usa. Due responsabilità:
// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
// cosa) che un domani va dimostrato tanto quanto un assenso.
import type Database from 'better-sqlite3';
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore } from './motore';
export type CampoQuestionario = {
id: string;
etichetta: string;
tipo: 'num' | 'txt';
unita?: string;
min?: number;
max?: number;
sottoDominio?: string;
};
/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
* `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
return testAttivi(db, alla)
.filter((v) => v.test_id.startsWith('q_'))
.map((v) => ({
id: v.test_id,
etichetta: v.etichetta,
tipo: v.tipo_valore,
unita: v.unita,
min: v.range_min,
max: v.range_max,
sottoDominio: v.sotto_dominio,
}));
}
export type EsitoSalvataggio =
| { ok: true; sessioneId: number }
| { ok: false; errore: string };
const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
function consensoDato(risposta: string): boolean {
return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
}
/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso
* (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che
* cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla
* sessione solo quando il consenso è affermativo e la sessione esiste davvero. */
function registraConsenso(
db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
): void {
const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
const oggi = new Date().toISOString().slice(0, 10);
const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`;
db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
).run(clientCode, sessioneId, 'consenso_sanitario', testo);
}
/**
* Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
* qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
* note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
* scrive comunque, con l'esito letterale ricevuto.
*
* Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il
* salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la
* nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa
* correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava
* calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla
* transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che
* li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint
* quelle interne di `salvaCompilazione` e `salvaScore`.
*/
export function salvaDalForm(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
consensoSanitario: string;
liberi?: Record<string, string>;
}
): EsitoSalvataggio {
if (!consensoDato(input.consensoSanitario)) {
registraConsenso(db, input.client_code, null, input.consensoSanitario);
return {
ok: false,
errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
};
}
const tx = db.transaction(() => {
const sessioneId = salvaCompilazione(db, {
client_code: input.client_code,
data: input.data,
eta: input.eta,
risposte: input.risposte,
liberi: input.liberi,
});
// Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo
// successivo affidato a chi legge: senza questo, una compilazione restava per sempre
// senza i suoi punteggi.
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
return sessioneId;
});
return { ok: true, sessioneId: tx() };
}
//
// Il referto che il cliente legge: il radar a sette assi e le carte dei macro-score.
// Due regole non negoziabili, entrambe già chiuse a monte, non da questo modulo:
// - il valore viene da `leggiScore` (punteggi congelati in `score`), mai da un
// ricalcolo — cambiare i pesi non deve mai cambiare un referto già emesso;
// - un asse/macro insufficiente non porta un `valore` (resta `null`): è il tipo
// `Punteggio` del motore che lo rende impossibile da violare per distrazione.
//
/** Un asse del radar: il valore (assente se il dato non basta), la copertura di peso
* disponibile e — solo quando manca — l'elenco leggibile di cosa lo completerebbe. */
export type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] };
/** Una carta di macro-score: stesso principio degli assi, senza l'elenco di cosa manca
* (il "cosa manca" si legge scendendo ai sette assi, non qui). */
export type MacroVista = { nome: string; valore: number | null; copertura: number };
/**
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
* dove il registro non arriva ancora.
*/
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
handgrip: 'Forza di presa (handgrip)',
spinta: 'Test di spinta (push-up o panca)',
trazione: 'Test di trazione',
arti_inferiori: 'Test arti inferiori',
core: 'Test del core (plank)',
grasso: 'Composizione corporea — massa grassa',
muscolo: 'Composizione corporea — massa muscolare',
whr: 'Rapporto vita-fianchi (WHR)',
vo2max: 'Test cardio (VO2max)',
spirometria: 'Spirometria',
wellness_tower_cardio: 'Wellness Tower — cardio',
hrv: 'Variabilità della frequenza cardiaca (HRV)',
pressione: 'Pressione arteriosa',
hrr: 'Frequenza cardiaca di recupero (HRR)',
questionario_sonno: 'Questionario del sonno',
questionario_energia_stress: 'Questionario energia e stress',
flamingo: 'Test di equilibrio (flamingo)',
sit_and_reach: 'Test di flessibilità (sit and reach)',
plank: 'Plank (tenuta)',
back_scratch: 'Test di mobilità delle spalle (back scratch)',
wellness_tower_shoulder: 'Wellness Tower — spalla',
questionario_lifestyle: 'Questionario di stile di vita',
};
/** I contenitori (assi o macro-score) dichiarati nei pesi per il model version corrente,
* nell'ordine in cui compaiono nella tabella — che è l'ordine di inserimento di `seedPesi`,
* cioè quello dichiarato in registro.ts. Non un elenco duplicato qui: si legge dal dato. */
function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string[] {
return (db.prepare(
`SELECT contenitore FROM pesi WHERE model_version = ? AND livello = ?
GROUP BY contenitore ORDER BY MIN(rowid)`
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
}
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
* test sono stati fatti non è ricalcolare come pesano. */
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
const righe = db.prepare(
`SELECT DISTINCT rt.sotto_dominio AS sd
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND rt.sotto_dominio IS NOT NULL`
).all(sessioneId) as { sd: string }[];
return new Set(righe.map((r) => r.sd));
}
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
* che questa sessione non ha, tradotti nell'etichetta leggibile. */
function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, presenti: Set<string>): string[] {
const pesi = pesiDi(db, MODEL_VERSION, 'asse', nomeAsse);
return Object.keys(pesi)
.filter((sd) => !presenti.has(sd))
.map((sd) => ETICHETTE_SOTTO_DOMINIO[sd] ?? sd);
}
/**
* Il referto di una sessione: i sette assi del radar, i macro-score e la Fitness Age.
* Legge SOLO `leggiScore` per i valori (punteggi congelati): non chiama mai
* `calcolaSessione`, quindi un peso cambiato dopo il fatto non altera un referto già
* emesso. `mancano` guarda invece le misure grezze della sessione (quali test ci sono),
* non il punteggio — è informazione su cosa manca, non un ricalcolo di quanto vale.
*/
export function refertoDi(
db: Database.Database,
sessioneId: number
): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null; fitnessAgeCopertura: number } {
const punteggi = leggiScore(db, sessioneId);
const presenti = sottoDominiPresenti(db, sessioneId);
const assi: AsseVista[] = contenitoriDi(db, 'asse').map((nome) => {
const p = punteggi.assi[nome];
if (p?.stato === 'ok') return { nome, valore: p.valore, copertura: p.copertura, mancano: [] };
return {
nome, valore: null, copertura: p?.copertura ?? 0,
mancano: mancanoPer(db, sessioneId, nome, presenti),
};
});
const macro: MacroVista[] = contenitoriDi(db, 'macro').map((nome) => {
const p = punteggi.macro[nome];
return p?.stato === 'ok'
? { nome, valore: p.valore, copertura: p.copertura }
: { nome, valore: null, copertura: p?.copertura ?? 0 };
});
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
// Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve
// poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca:
// la copertura resta leggibile anche quando lo stato è 'insufficiente'.
const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0;
return { assi, macro, fitnessAge, fitnessAgeCopertura };
}
/** L'ultima sessione di un cliente (la più recente per data, a parità di data la più
* recente per id). `null` se il cliente non ha ancora nessuna sessione — la pagina del
* referto la usa per non dover interrogare `sessioni` da sola. */
export function ultimaSessioneDi(db: Database.Database, clientCode: string): number | null {
const r = db.prepare(
`SELECT id FROM sessioni WHERE client_code = ? ORDER BY data DESC, id DESC LIMIT 1`
).get(clientCode) as { id: number } | undefined;
return r?.id ?? null;
}
+94
View File
@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
import { salvaDalForm } from '../../../lib/longevity/vista';
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
/**
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
*/
type CorpoQuestionario = {
risposte: Record<string, number>;
liberi: Record<string, string>;
consensoSanitario: string;
eta?: number;
};
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
* al chiamante, perché il tipo di ritorno non la contiene. */
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
if (typeof raw !== 'object' || raw === null) return null;
const grezzo = raw as Record<string, unknown>;
const risposteIn = grezzo.risposte;
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
const risposte: Record<string, number> = {};
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
}
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
const liberiIn = grezzo.liberi;
const liberi: Record<string, string> = {};
if (typeof liberiIn === 'object' && liberiIn !== null) {
for (const campo of CAMPI_LIBERI) {
const v = (liberiIn as Record<string, unknown>)[campo];
if (typeof v === 'string') liberi[campo] = v;
}
}
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
return { risposte, liberi, consensoSanitario, eta };
}
export const POST: APIRoute = async ({ request, locals }) => {
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
// prenderlo per errore.
const codice = codicePerUtenteLoggato(locals.user!.id);
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
let raw: unknown;
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
const corpo = corpoDaJson(raw);
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
const { risposte, liberi, consensoSanitario, eta } = corpo;
// La data della compilazione è quella del server, non quella (manomettibile) del client.
const oggi = new Date().toISOString().slice(0, 10);
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
let esito;
try {
esito = salvaDalForm(getLongevityDb(), {
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
});
} catch (err) {
console.error('Salvataggio questionario fallito:', err);
return json(400, {
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
});
}
if (!esito.ok) return json(422, { error: esito.errore });
return json(201, { sessioneId: esito.sessioneId });
};
+7
View File
@@ -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');
---
+72
View File
@@ -0,0 +1,72 @@
---
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
import Longevity from '../../layouts/Longevity.astro';
import Radar from '../../components/longevity/Radar';
import MacroScore from '../../components/longevity/MacroScore.astro';
import { getLongevityDb } from '../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
import { formattaNumero } from '../../lib/longevity/formato';
export const prerender = false;
const user = Astro.locals.user!;
const codice = codicePerUtenteLoggato(user.id);
const db = getLongevityDb();
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
---
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && !referto && (
<div class="lg-card lg-card--incompleto">
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Il tuo radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
<section class="lg-card">
<p>
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
raccontano come cambi, non solo come stai oggi.
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
</p>
</section>
</div>
)}
</Longevity>
+22
View File
@@ -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>
+141
View File
@@ -0,0 +1,141 @@
/* Stile della piattaforma Longevity.
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
ai componenti Astro sia alle isole React (questionario e radar), che lo scope di Astro
non raggiungerebbe. Tutto è prefissato `.lg` e usa i token del sito.
A differenza di Stress Index qui non c'è semaforo verde/ambra/mattone sui punteggi:
il servizio è premium ma non clinico, e un rosso su un punteggio del corpo sarebbe un
giudizio, non un'informazione. L'intensità di un punteggio si legge nell'opacità
dell'accento del sito (var(--lg-intensita), impostata inline dal componente che disegna
il punteggio), mai in un colore diverso. I due soli colori di segnale locali sono:
- --lg-tratteggio: il perimetro di ciò che non sappiamo ancora (un asse senza dati
sufficienti), non un errore;
- --lg-fuori-range: SOLO per una misura sospetta (es. un valore fisiologicamente
improbabile), mai per un punteggio — segnala un problema della misura, non della
persona. Riusa il mattone di Stress Index (--si-bad: #b05a4e). */
.lg {
--lg-line: #e4dfd7;
--lg-surface: #ffffff;
--lg-tratteggio: #d8d2c8;
--lg-fuori-range: #b05a4e;
display: flex;
justify-content: center;
min-height: 100vh;
background: var(--c-bg-alt);
}
/* --- guscio del referto: una colonna che scorre, senza navigazione --- */
.lg-shell {
width: 100%;
max-width: 760px;
min-height: 100vh;
background: var(--lg-surface);
display: flex;
flex-direction: column;
}
/* --- intestazione --- */
.lg-top {
padding: 18px 32px;
border-bottom: 1px solid var(--lg-line);
position: sticky;
top: 0;
z-index: 10;
background: var(--lg-surface);
}
.lg-top p { margin: 0; }
.lg-top__crumbs {
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-text-light);
}
.lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
.lg-main { flex: 1; padding: 34px 32px 70px; }
/* --- intestazione di pagina --- */
.lg-head { margin-bottom: 28px; }
.lg-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
.lg-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
/* --- la carta di un punteggio ---
Nessun colore «buono» o «cattivo»: l'intensità si legge nell'opacità dell'accento
(--lg-intensita, 0..1, impostata inline dal componente in base al punteggio). */
.lg-card { background: var(--lg-surface); border: 1px solid var(--lg-line); padding: 20px 22px; }
.lg-card--incompleto { border-style: dashed; border-color: var(--lg-tratteggio); }
.lg-score { display: flex; align-items: baseline; gap: 8px; }
.lg-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
.lg-score__max { font-size: .82rem; color: var(--c-text-light); }
.lg-score__label {
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
}
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
.lg-muted { color: var(--c-text-light); }
/* i numeri che si confrontano nel tempo non devono ballare a ogni cifra */
.lg-num { font-variant-numeric: tabular-nums; }
/* --- il referto: colonna di sezioni --- */
.lg-referto { display: flex; flex-direction: column; gap: 24px; }
/* --- il radar dei sette assi: area piena + perimetro tratteggiato ---
Il disegno vive nell'isola React (Radar.tsx); qui solo il testo sotto al grafico —
l'elenco di ciò che manca, che è la parte che rende il buco un invito. */
.lg-radar { display: flex; flex-direction: column; gap: 20px; }
.lg-radar__grafico { width: 100%; }
.lg-radar__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__mancano { display: flex; flex-direction: column; gap: 14px; }
.lg-radar__voce { border-top: 1px dashed var(--lg-tratteggio); padding-top: 12px; }
.lg-radar__voce-nome {
margin: 0 0 6px; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
color: var(--c-heading);
}
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__lista li { margin-bottom: 2px; }
/* --- le carte dei macro-score --- */
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
/* --- il questionario: un blocco alla volta, avanti/indietro --- */
.lg-form { display: flex; flex-direction: column; gap: 24px; }
.lg-form__passo {
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--c-text-light); margin: 0;
}
.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
.lg-field > span { color: var(--c-heading); font-weight: 500; }
.lg-field input[type="number"],
.lg-field textarea {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
}
.lg-field textarea { min-height: 68px; resize: vertical; }
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
.lg-form__nav button {
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
background: transparent; color: var(--c-heading); cursor: pointer;
}
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
@media (max-width: 640px) {
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
}
+31 -2
View File
@@ -88,7 +88,36 @@ describe('anagrafica pseudonimizzata', () => {
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
// ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
// esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
// parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
// Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
// tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
// longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
// contiene singolarmente le due parole. Un controllo file-per-file non vede una
// giunzione che attraversa più file, e non c'è formulazione di questo test che lo
// possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
// Questo test resta come rete secondaria — cattura il caso comune (una giunzione
// dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
// esportata per errore — non come prova che la giunzione non possa esistere.
// Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
// come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
// un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
// sono nati dopo la prima versione di questo test e sono passati indisturbati).
// ⚠️ VERIFICATO IL 22/08, UN TERZO BUCO, STAVOLTA IN UN FILE SOLO: `createIdentityDb`
// (a differenza del singleton privato getIdentityDb) È esportata da db.ts — serve a
// creaCliente, che riceve due connessioni già aperte da fuori. Un file che chiama
// `createIdentityDb()` direttamente e apre longevity con una connessione propria (es.
// `new Database(process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')`, mai col nome
// `getLongevityDb`/`createLongevityDb`) contiene "Identity" ma MAI "Longevity" con
// l'iniziale maiuscola — LONGEVITY_DB_PATH è tutto maiuscolo, 'longevity.db' tutto
// minuscolo — e questo test lo lascia passare. Provato con un file del genere in
// src/lib/longevity/: zero test rossi. Non è una falla da chiudere con un controllo
// più furbo (già tentato e aggirato due volte prima di questa): la garanzia resta solo
// quella architetturale sopra, e su `createIdentityDb` non c'è — è pensata per essere
// chiamata da fuori anagrafica.ts (creaCliente la riceve come parametro), quindi non si
// può chiuderla come si è chiusa getIdentityDb senza cambiare quella funzione.
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
@@ -106,7 +135,7 @@ describe('anagrafica pseudonimizzata', () => {
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);
}
}
+52
View File
@@ -0,0 +1,52 @@
// Il consenso veniva scritto DOPO che salvaCompilazione aveva già chiuso la propria
// transazione: se il passo successivo falliva, restavano i dati sanitari SENZA il record
// di consenso che li giustifica — l'esatto contrario dell'invariante ("o entra tutto nel
// fascicolo, o non entra niente"). Questo file forza un fallimento nel calcolo dei
// punteggi (mockando calcolaSessione) DOPO che la sessione e le misure sono già scritte, e
// verifica che l'intera transazione — sessione, misure, punteggi, consenso — vada
// indietro insieme. Il mock vive in un file a parte perché vi.mock è per-file: gli altri
// test su calcolaSessione (referto.test.ts, salvataggio-e2e.test.ts) devono restare reali.
import { describe, it, expect, vi } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
vi.mock('../../src/lib/longevity/motore', async (importOriginal) => {
const reale = await importOriginal<typeof import('../../src/lib/longevity/motore')>();
return {
...reale,
calcolaSessione: () => { throw new Error('rottura simulata nel calcolo dei punteggi'); },
};
});
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
describe('salvaDalForm — atomicità di dati, punteggi e consenso', () => {
it('se il calcolo dei punteggi fallisce DOPO che sessione e misure sono state scritte, tutto torna indietro: niente sessione, niente misure, niente nota di consenso', async () => {
const { salvaDalForm } = await import('../../src/lib/longevity/vista');
const db = dbPronto();
expect(() =>
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 8 }, consensoSanitario: 'Si', liberi: {},
})
).toThrow(/rottura simulata/);
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
const score = db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number };
const consenso = db.prepare(`SELECT COUNT(*) n FROM profilo_note WHERE campo_id = 'consenso_sanitario'`)
.get() as { n: number };
expect(sessioni.n).toBe(0);
expect(misure.n).toBe(0);
expect(score.n).toBe(0);
expect(consenso.n).toBe(0);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { formattaNumero } from '../../src/lib/longevity/formato';
describe('formattaNumero — i numeri del referto in italiano', () => {
it('un decimale usa la virgola, non il punto', () => {
expect(formattaNumero(72.5)).toBe('72,5');
});
it('un intero resta senza decimali spuri', () => {
expect(formattaNumero(80)).toBe('80');
});
it('un negativo (possibile solo per differenze, non per un punteggio 0-100) mantiene il segno', () => {
expect(formattaNumero(-3.2)).toBe('-3,2');
});
});
+96
View File
@@ -0,0 +1,96 @@
// tests/longevity/pagine.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
describe('il guscio della piattaforma', () => {
it('il layout esiste e non monta header e footer pubblici', () => {
const l = leggi('src/layouts/Longevity.astro');
expect(l).not.toMatch(/import\s+Header/);
expect(l).not.toMatch(/import\s+Footer/);
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
});
it('il foglio di stile e globale e prefissato, non scoped', () => {
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
const css = leggi('src/styles/longevity.css');
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
expect(classi.length).toBeGreaterThan(3);
// "lg" da solo o "lg-qualcosa": il solo startsWith('lg') lascerebbe passare anche "lgfoo".
expect(classi.every((c) => c === 'lg' || c.startsWith('lg-'))).toBe(true);
});
it('usa i token del sito invece di inventare colori', () => {
const css = leggi('src/styles/longevity.css');
expect(css).toMatch(/var\(--c-/);
});
it('nessuna variabile porta un nome da semaforo (verde/buono/ok)', () => {
const css = leggi('src/styles/longevity.css').toLowerCase();
// il mattone del fuori range e ammesso; un verde "tutto bene" no
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
});
it('in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate', () => {
// Il test sopra cerca nomi vietati: chi scrivesse un verde in esadecimale, o lo
// chiamasse con un nome innocuo (var(--lg-buona-salute)), passerebbe indisturbato.
// Qui si fa il contrario: si elenca ciò che PUÒ comparire, e qualunque altro colore —
// letterale (esadecimale, rgb(), nome CSS) o var() con un altro nome — è una
// regressione.
//
// PRIMA questo test filtrava le regole sul selettore ("solo quelle con 'lg-score' nel
// nome"): un elenco di QUALI regole guardare invecchia appena nasce una classe nuova —
// è successo con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3): non
// contengono la stringa "lg-score", quindi non venivano MAI controllate, in silenzio.
// Il foglio è interamente prefissato `lg-` per costruzione (verificato dal test sopra):
// non serve scegliere quali regole guardare, si guardano TUTTE, e le eccezioni
// legittime si dichiarano una per una — selettore, valore esatto, motivo — così un
// colore fuori lista entra solo con una decisione visibile, mai con un buco nel filtro.
const css = leggi('src/styles/longevity.css');
const AMMESSI = new Set([
'--c-accent', '--c-accent-dark', // l'accento del sito: l'unica leva per l'intensità
'--c-heading', '--c-text', '--c-text-light', // testo neutro, uguale ovunque nel sito
'--c-bg', '--c-bg-alt', // sfondi neutri
'--lg-line', '--lg-surface', // struttura locale, neutra
'--lg-tratteggio', // il perimetro di ciò che non sappiamo
'--lg-fuori-range', // SOLO una misura sospetta, mai un punteggio
]);
// Eccezioni dichiarate: selettore esatto + valore esatto + perché non è un giudizio sul
// punteggio. Ogni riga qui è una decisione presa a vista, non un buco silenzioso.
const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [
{
selettore: '.lg-form__nav button:last-child:not(:only-child)',
valore: '#fff',
motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio",
},
];
const NON_COLORE = /^(transparent|inherit|initial|unset|currentcolor|none)$/i;
const PROP_COLORE = /^(color|background|background-color|border(-\w+)?-color|outline-color|fill|stroke)$/i;
const violazioni: string[] = [];
for (const regola of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selettore = regola[1].trim();
for (const dich of regola[2].split(';')) {
const due = dich.indexOf(':');
if (due < 0) continue;
const prop = dich.slice(0, due).trim().toLowerCase();
const val = dich.slice(due + 1).trim();
if (!PROP_COLORE.test(prop) || val === '' || NON_COLORE.test(val)) continue;
const varMatch = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,.*)?\)$/i);
if (varMatch && AMMESSI.has(varMatch[1])) continue;
if (ECCEZIONI.some((e) => e.selettore === selettore && e.valore === val)) continue;
violazioni.push(`${selettore} { ${prop}: ${val} }`);
}
}
expect(violazioni).toEqual([]);
});
it('la rotta del cliente e sotto /longevity, e rimanda davvero al referto', () => {
const p = join(process.cwd(), 'src/pages/longevity/index.astro');
expect(existsSync(p)).toBe(true);
const src = readFileSync(p, 'utf8');
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
});
});
+66
View File
@@ -0,0 +1,66 @@
// La prima apertura del referto (revisione del 22/08): con i dati di oggi il cliente vede
// molti assi tratteggiati. È corretto, ma la pagina deve SPIEGARLO, non lasciarlo sembrare
// un guasto. Questi controlli leggono il sorgente delle pagine/componenti — come già fa
// referto.test.ts per "nessuna pagina interroga il database da sola" — perché non c'è un
// harness di rendering per Astro/React in questo repo (nessuna dipendenza nuova ammessa).
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
describe('la Fitness Age, quando manca, spiega perché (come le altre carte)', () => {
it('io.astro mostra una nota di copertura anche per la Fitness Age, non solo un trattino nudo', () => {
const src = leggi('src/pages/longevity/io.astro');
// Deve leggere la copertura dedicata (vista.ts, fitnessAgeCopertura), non inventarsi
// un numero: la sola presenza del trattino '—' non basta più.
expect(src).toMatch(/fitnessAgeCopertura/);
expect(src).toMatch(/Copertura dati/);
});
it('il numero della Fitness Age, quando c\'è, è formattato all\'italiana', () => {
const src = leggi('src/pages/longevity/io.astro');
expect(src).toMatch(/formattaNumero\(referto\.fitnessAge\)/);
});
});
describe('il radar spiega prima di mostrare', () => {
it('la spiegazione del radar precede il grafico, non lo segue', () => {
const src = leggi('src/components/longevity/Radar.tsx');
const idxIntro = src.indexOf("L'area piena");
const idxGrafico = src.indexOf('<ResponsiveContainer');
expect(idxIntro).toBeGreaterThan(-1);
expect(idxGrafico).toBeGreaterThan(-1);
expect(idxIntro).toBeLessThan(idxGrafico);
});
it("l'elenco di ciò che manca invita a fare qualcosa, non solo a leggere nomi di test", () => {
const src = leggi('src/components/longevity/Radar.tsx');
expect(src).toMatch(/trainer/i);
});
});
describe('la copertura si mostra sempre, non solo quando il numero manca', () => {
it('MacroScore.astro non nasconde più la copertura quando il punteggio c\'è', () => {
const src = leggi('src/components/longevity/MacroScore.astro');
// PRIMA: `{valore === null && (<p ...>Copertura dati...`. La nota deve poter comparire
// anche quando valore !== null: la vecchia guardia esatta non deve più esserci.
expect(src).not.toMatch(/\{valore === null && \(\s*<p class="lg-muted lg-macro__nota">/);
expect(src).toMatch(/Copertura dati/);
});
it('il punteggio di MacroScore è formattato all\'italiana', () => {
const src = leggi('src/components/longevity/MacroScore.astro');
expect(src).toMatch(/formattaNumero\(valore\)/);
});
});
describe('chi ha già compilato può ricompilare (monitoraggio nel tempo)', () => {
it('io.astro offre il collegamento al questionario anche quando il referto esiste già', () => {
const src = leggi('src/pages/longevity/io.astro');
const idxReferto = src.indexOf('{referto && (');
expect(idxReferto).toBeGreaterThan(-1);
const blocco = src.slice(idxReferto);
expect(blocco).toMatch(/href=["'`]\/longevity\/questionario["'`]/);
});
});
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
import { creaCliente } from '../../src/lib/longevity/anagrafica';
import { CONSENT_VERSION, QUEST_VERSION } from '../../src/lib/longevity/questionario';
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/);
});
// PRIMA di questa correzione il ramo del consenso DATO non era coperto da nessun test:
// cancellando la riga che registra il consenso accettato (registraConsenso dentro il ramo
// affermativo di salvaDalForm) la suite restava tutta verde — provato. Il consenso dato è
// l'unico documento che un domani si esibisce a chi chiede conto del trattamento, tanto
// quanto il rifiuto (già coperto sopra).
it('registra il consenso anche quando e dato: e il documento che un domani va esibito', () => {
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 note = db.prepare(
`SELECT campo_id, testo, sessione_id FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
).all() as { campo_id: string; testo: string; sessione_id: number | null }[];
expect(note.length).toBe(1);
expect(note[0].testo).toMatch(/Si/);
if (esito.ok) expect(note[0].sessione_id).toBe(esito.sessioneId);
});
// Il record del consenso portava la versione delle DOMANDE (QUEST_VERSION): se cambia il
// testo del consenso senza che cambi una domanda, il record non lo diceva. Ora porta una
// versione propria (CONSENT_VERSION, questionario.ts), dichiarata accanto a QUEST_VERSION.
it('il record del consenso porta CONSENT_VERSION, non QUEST_VERSION', () => {
const db = dbPronto();
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'Si', liberi: {},
});
const nota = db.prepare(
`SELECT testo FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
).get() as { testo: string };
expect(nota.testo).toContain(`consenso ${CONSENT_VERSION}`);
expect(nota.testo).not.toMatch(/questionario\s+v/i);
// Le due costanti oggi coincidono di valore ('v1.0'): la prova che conta è che sono
// DUE COSTANTI DISTINTE (non lo stesso import rietichettato) — non che i valori
// divergano oggi.
expect(CONSENT_VERSION).not.toBe(undefined);
expect(QUEST_VERSION).not.toBe(undefined);
});
it('il codice cliente si risolve dalla sessione (codicePerUtenteLoggato)', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
expect(src).toMatch(/codicePerUtente/);
});
// PRIMA questo test cercava, nel sorgente, le stringhe letterali "body.client_code" e
// "corpo.client_code" — ma il file chiama la variabile del corpo `data`, non `body` né
// `corpo`: la lista di divieti non nominava nessuna variabile che il file usasse
// davvero, quindi era vacua per costruzione. Provato: aggiungendo all'endpoint
// `if (typeof data.client_code === 'string') codice = data.client_code;` (la vulnerabilità
// vera — un cliente scrive nel fascicolo di un altro) la suite restava tutta verde.
//
// Il fix e' strutturale (il tipo del corpo, in questionario.ts, non ha il campo
// client_code — chi lo leggesse da li' non passerebbe tsc --noEmit) e questo e' il test
// di comportamento che lo dimostra end-to-end: chiama l'endpoint VERO con un client_code
// estraneo nel corpo e verifica dove la sessione finisce scritta.
it('un client_code nel corpo della richiesta viene ignorato: la sessione finisce sul cliente vero, mai su quello iniettato', async () => {
const dir = mkdtempSync(join(tmpdir(), 'lg-api-test-'));
const idPath = join(dir, 'identity.db');
const lgPath = join(dir, 'longevity.db');
const primaId = process.env.IDENTITY_DB_PATH;
const primaLg = process.env.LONGEVITY_DB_PATH;
try {
process.env.IDENTITY_DB_PATH = idPath;
process.env.LONGEVITY_DB_PATH = lgPath;
// Setup con connessioni proprie, sullo stesso file che il singleton dell'endpoint
// aprirà (le due variabili d'ambiente sopra), come in anagrafica.test.ts.
const id = createIdentityDb(idPath);
const lg = createLongevityDb(lgPath);
seedRegistro(lg);
const codiceVero = creaCliente(id, lg, { nome: 'Vera', cognome: 'Utente', sesso: 'F', user_id: 1 });
const codiceAltro = creaCliente(id, lg, { nome: 'Altro', cognome: 'Cliente', sesso: 'M', user_id: 2 });
const { POST } = await import('../../src/pages/api/longevity/questionario');
const richiesta = new Request('http://localhost/api/longevity/questionario', {
method: 'POST',
body: JSON.stringify({
client_code: codiceAltro, // tentativo: scrivere nel fascicolo di un altro cliente
risposte: { q_riposato: 8 },
consensoSanitario: 'Si',
}),
});
const res = await POST({ request: richiesta, locals: { user: { id: 1 } } } as any);
expect(res.status).toBe(201);
const corpo = await res.json();
const riga = lg.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(corpo.sessioneId) as
{ client_code: string };
expect(riga.client_code).toBe(codiceVero);
expect(riga.client_code).not.toBe(codiceAltro);
} finally {
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
}
});
});
+124
View File
@@ -0,0 +1,124 @@
// tests/longevity/referto.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } 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('espone la copertura della Fitness Age anche quando è insufficiente (serve alla nota in pagina)', () => {
// conCompilazione() non porta nessuna misura del composito Fitness Age (cardio,
// handgrip, hrv, forza_resto, composizione, stabilita: nessuno di questi sotto-domini
// ha dati in questa sessione, solo il questionario), quindi fitnessAge è null — ma
// PRIMA di questa correzione refertoDi scartava anche la copertura insieme al valore:
// la pagina non aveva modo di dire "41%", solo di dire "manca".
const { db, s } = conCompilazione();
const referto = refertoDi(db, s);
expect(referto.fitnessAge).toBeNull();
expect(referto.fitnessAgeCopertura).toBe(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', () => {
// Scandisce TUTTE le pagine sotto src/pages/longevity/, non un elenco scritto qui:
// un elenco di due nomi non protegge una pagina nuova, che passerebbe indisturbata
// anche con una query dentro (verificato: una prova-query.astro con un
// db.prepare('SELECT 1') non veniva vista dalla versione precedente di questo test).
const dir = join(process.cwd(), 'src/pages/longevity');
const pagine = (readdirSync(dir, { recursive: true }) as string[])
.filter((f) => f.endsWith('.astro'));
expect(pagine.length).toBeGreaterThan(0); // se la cartella si svuota, il test non deve tacere per errore
for (const f of pagine) {
const percorso = `src/pages/longevity/${f}`;
const src = readFileSync(join(dir, f), 'utf8');
expect(src, `${percorso} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
}
});
});
+60
View File
@@ -0,0 +1,60 @@
// Il blocco scoperto nella revisione finale del 22/08: il salvataggio del questionario e la
// lettura dei punteggi esistono entrambi, ma fra i due non calcolava niente nessuno.
// I test esistenti (referto.test.ts) chiamavano calcolaSessione/salvaScore dentro la propria
// fixture — cioè facevano a mano ciò che in produzione non fa nessuno — quindi restavano
// verdi anche con l'endpoint reale del tutto scollegato dal motore.
//
// Questo file parte dal modulo compilato (salvaDalForm, la stessa funzione che chiama
// l'endpoint POST /api/longevity/questionario) e arriva al referto (refertoDi): NON chiama
// mai calcolaSessione o salvaScore a mano. Se un domani qualcuno stacca di nuovo il
// calcolo dal salvataggio, questo è il test che deve diventare rosso.
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
describe('salvaDalForm calcola e congela i punteggi da solo', () => {
it('venti risposte compilate producono un referto con assi e macro-score valorizzati, senza ricalcolo a mano', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: {
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
},
consensoSanitario: 'Si', liberi: {},
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
// Riprova diretta sulla tabella score: prima di questa correzione restava vuota,
// perché nessuno chiamava calcolaSessione/salvaScore fra salvataggio e lettura.
const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as
{ n: number };
expect(righeScore.n).toBeGreaterThan(0);
const referto = refertoDi(db, esito.sessioneId);
// 'Stile di Vita & Sonno' ha UN solo sotto-dominio (questionario_lifestyle, peso 1.00):
// con tutte le risposte compilate la copertura è piena.
const stile = referto.assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
expect(stile.valore).not.toBeNull();
// 'Energia & Regolazione Stress' pesa hrv 0.50 + questionario_energia_stress 0.50: con
// solo il questionario la copertura vera è 0.50, sopra la soglia del 40%.
const energia = referto.assi.find((a) => a.nome === 'Energia & Regolazione Stress')!;
expect(energia.valore).not.toBeNull();
const almenoUnMacro = referto.macro.some((m) => m.valore !== null);
expect(almenoUnMacro).toBe(true);
});
});