### 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.