# Task 3 — Il radar, e come si mostra ciò che non si sa — report **Stato:** completato. 6/6 test nuovi verdi, suite completa allo stato atteso (1 fallito pre-esistente e fuori perimetro, 327 passati), `npx tsc --noEmit` pulito. Commit fatto. **Commit:** `7f01ee0` — "longevity: il referto del cliente, e il radar che dice cosa non sa" File aggiunti/modificati (solo questi, `git add` esplicito, nessun `-A`/`.`): - `src/lib/longevity/vista.ts` (modificato) — `refertoDi`, `ultimaSessioneDi`, tipi `AsseVista`/`MacroVista`, e le funzioni di supporto private - `src/components/longevity/Radar.tsx` (nuovo) — isola React col radar recharts - `src/components/longevity/MacroScore.astro` (nuovo) — carta di un macro-score - `src/pages/longevity/io.astro` (nuovo) — la pagina del referto, nessuna query diretta - `src/styles/longevity.css` (modificato) — classi `.lg-radar*`, `.lg-macro*`, `.lg-referto`, `.lg-num` - `tests/longevity/referto.test.ts` (nuovo) — i 6 test del brief ## Procedura seguita (TDD) ### Step 1-2: test scritto, verificato rosso Comando: ``` npm test -- tests/longevity/referto.test.ts ``` Esito (prima dell'implementazione): `TypeError: refertoDi is not a function` e `ENOENT` su `src/pages/longevity/io.astro` (non esisteva ancora) — 6 falliti su 6, come atteso. ### Step 3: implementazione **`src/lib/longevity/vista.ts` — `refertoDi(db, sessioneId)`** Legge **solo** `leggiScore` (i punteggi congelati in `score`): non chiama mai `calcolaSessione`. Per ogni asse/macro-score: - se `stato === 'ok'`: `{ nome, valore: p.valore, copertura: p.copertura, mancano: [] }`; - altrimenti: `valore: null`, `copertura` letta comunque dalla riga congelata (mai 0 finto quando una copertura parziale esiste davvero), e per gli assi `mancano` calcolato. L'ordine dei sette assi (e dei tre macro-score) viene dalla tabella `pesi` (`GROUP BY contenitore ORDER BY MIN(rowid)`, non un `SELECT DISTINCT` senza ordine): rispetta l'ordine di dichiarazione in `registro.ts` (`seedPesi` inserisce nell'ordine degli `Object.entries`), in modo deterministico per il radar. **`mancano`: la parte non banale del task.** I sette assi pesano dei *sotto-domini* (`handgrip`, `vo2max`, `questionario_sonno`...), non dei `test_id` — e oggi **nessuna riga di `registro_test` esiste per i sotto-domini fisici** (handgrip, spinta, VO2max, plank...): il registro contiene solo i 20 campi del questionario. Quindi "i test del registro che alimenterebbero quell'asse" non si possono leggere da `registro_test.asse` per un asse fisico — sarebbe sempre vuoto. Ho preso `pesiDi(db, MODEL_VERSION, 'asse', nomeAsse)` (le chiavi = i sotto-domini pesati, dato che esiste già) e confrontato con i sotto-domini che la sessione ha davvero misurato (join `misure`→`registro_test`, `fuori_range=0`, `valore_num IS NOT NULL`). Il sotto-dominio assente si traduce in un'etichetta leggibile via una mappa locale `ETICHETTE_SOTTO_DOMINIO` in `vista.ts` — dato nuovo, necessario perché il registro non ha ancora un'etichetta per quei sotto-domini (arriveranno col piano dei test fisici); non duplica nulla che già esista. **`ultimaSessioneDi(db, clientCode)`** — helper nuovo, non nel brief ma necessario: la pagina deve trovare la sessione più recente del cliente loggato senza fare una query da sola. `SELECT id FROM sessioni WHERE client_code=? ORDER BY data DESC, id DESC LIMIT 1`. **`src/components/longevity/Radar.tsx`** Due serie `recharts` sugli stessi sette assi (recharts vuole un `data` condiviso, non può disegnare un poligono che copre solo alcuni assi): - `noto` (piena): vale `a.valore ?? 0` — dove manca il dato, l'area si ritira verso il centro, non si inventa un valore; - `potenziale` (tratteggiata, `fill="none"`): vale `a.valore ?? 100` — dove il dato c'è coincide col bordo di `noto` (sparisce dietro), dove manca si allarga al massimo, **chiudendo** visivamente il perimetro proprio nei punti mancanti. È l'interpretazione letterale di "una tratteggiata che chiude il perimetro dove il dato manca". Sotto il grafico, l'elenco `mancano` per ogni asse ancora incompleto (filtrati su `mancano.length > 0`). Colori: hex letterali (`#9c8b70` accento, `#d8d2c8` tratteggio, `#e4dfd7` linea, `#8a8a8a` testo), stesso pattern di `DataChart.tsx` — recharts riceve attributi SVG diretti, non proprietà CSS, quindi `var(--c-accent)` non passa da lì; i valori corrispondono 1:1 ai token di `longevity.css`. **`src/components/longevity/MacroScore.astro`** Numero se `valore !== null`, altrimenti `'—'` (mai `0`). Bordo tratteggiato (`.lg-card--incompleto`, già esistente dal Task 1) quando insufficiente — stesso linguaggio visivo del radar, non una convenzione nuova. Etichetta derivata dal nome del contenitore (`PERFORMANCE`→`Performance`) con una trasformazione di stringa, non un dizionario da tenere allineato a mano. **`src/pages/longevity/io.astro`** `Astro.locals.user!.id` → `codicePerUtenteLoggato` → `ultimaSessioneDi` → `refertoDi`. Tre stati gestiti: nessun fascicolo collegato, fascicolo ma nessuna sessione ancora (invito a compilare il questionario), referto pieno. Nessuna query SQL nel file. **`src/styles/longevity.css`** `.lg-num { font-variant-numeric: tabular-nums; }` applicata ai valori numerici (Fitness Age, macro-score). Nuove classi `.lg-radar*`, `.lg-macro*`, `.lg-referto` — tutte prefissate `lg-`, nessun colore nuovo (solo `var(--c-heading)`, `var(--c-text-light)`, `var(--lg-tratteggio)`, già nella lista ammessa). ### Step 4: test target verde Comando: ``` npm test -- tests/longevity/referto.test.ts ``` Output: ``` Test Files 1 passed (1) Tests 6 passed (6) ``` ### Step 5: suite completa + typecheck Comando: ``` npm test ``` Output (coda): ``` ❯ tests/modifiche-agosto.test.ts (25 tests | 1 failed) 38ms × l'inaugurazione dice la data e che il programma manca ancora 7ms FAIL tests/modifiche-agosto.test.ts > promo ed eventi > l'inaugurazione dice la data e che il programma manca ancora AssertionError: expected 'Inaugurazione' to contain '19 settembre 2026' Test Files 1 failed | 39 passed (40) Tests 1 failed | 327 passed (328) ``` Esattamente il rosso pre-esistente dichiarato nel task (fuori perimetro) — nessun altro test rotto, i 6 nuovi inclusi nel verde. Comando: ``` npx tsc --noEmit ``` Output: nessuno (exit pulito). ### Step 6: commit ``` git add src/components/longevity/Radar.tsx src/components/longevity/MacroScore.astro \ src/pages/longevity/io.astro src/lib/longevity/vista.ts src/styles/longevity.css \ tests/longevity/referto.test.ts git commit -m "longevity: il referto del cliente, e il radar che dice cosa non sa" ``` ``` [feat/longevity 7f01ee0] longevity: il referto del cliente, e il radar che dice cosa non sa 6 files changed, 396 insertions(+), 1 deletion(-) ``` `git status` dopo il commit: albero pulito (a parte i file fuori dal perimetro di questo task, non toccati). ## La prova richiesta: rompere il test sul congelamento — esito onesto Ho fatto esattamente quanto chiesto: sostituita la lettura (`leggiScore`) con un ricalcolo (`calcolaSessione`) dentro `refertoDi`, per riprodurre "il referto ricalcola invece di leggere i punteggi salvati". ```diff - import { leggiScore } from './motore'; + import { leggiScore, calcolaSessione } from './motore'; ... - const punteggi = leggiScore(db, sessioneId); + const punteggi = calcolaSessione(db, sessioneId) as unknown as ReturnType; ``` Comando: ``` npm test -- tests/longevity/referto.test.ts ``` Esito: **6/6 ancora VERDI, compreso "legge i punteggi congelati, non li ricalcola al volo".** Il test **non** ha intercettato la regressione. Non era un problema d'implementazione della prova: l'ho verificato analiticamente e poi con un secondo esperimento mirato. **Perché resta verde — la causa esatta.** Il test del brief muta il peso di `questionario_lifestyle`, che è l'**unico** sotto-dominio pesato dell'asse "Stile di Vita & Sonno" (`{ questionario_lifestyle: 1.00 }`, nessun fratello). Nella media pesata di `aggrega()` (`cascata.ts`), quando un solo elemento è disponibile il suo peso compare identico sia al numeratore (`sommaPesata`) sia al denominatore (`pesoDisponibile`) — si semplifica sempre, qualunque sia il valore. E la `copertura` fa lo stesso: `pesoDisponibile / pesoTotale` con un solo elemento in tutto il dizionario è sempre `1.0`, comunque lo si scali. Cambiare quel peso — con **questi identici dati di seed** (solo le 6 risposte di stile di vita) — non può mai produrre un `valore` o una `copertura` diversi, sia che si legga lo score congelato sia che lo si ricalcoli da zero: il test è strutturalmente incapace di distinguere le due cose, non solo in pratica ma per costruzione matematica. **Verifica che una mutazione ben scelta invece discrimina davvero.** Ho costruito un esperimento a parte (non nel commit, solo per la prova — poi cancellato): stessa sessione ma con **anche** `q_riposato: 8` fra le risposte (copre `questionario_sonno` con un solo elemento su misura, ma nell'asse "Recupero & Sistema Nervoso" quel sotto-dominio ha *tre fratelli assenti*: `hrv 0.50`, `pressione 0.15`, `hrr 0.15`, `questionario_sonno 0.20`). Con `refertoDi` ancora rotto (ricalcolo), ho azzerato il peso di `hrv` (0.50→0, un fratello **assente**, non l'elemento misurato): ``` npm test -- tests/longevity/_prova-rottura-temp.test.ts ``` ``` ❯ ... mutare il peso di un sotto-dominio ASSENTE in un asse multi-elemento sposta la copertura ricalcolata AssertionError: expected '{"assi":[...]}' to be '{"assi":[...]}' ``` Rosso netto: la copertura vera di "Recupero & Sistema Nervoso" passa da `0.20/1.00=0.20` a `0.20/0.50=0.40`, attraversa la soglia (`COPERTURA_MINIMA=0.4`) e l'asse passerebbe da `insufficiente` a `ok` (valore `80` invece di `null`) — con effetto a cascata sui macro-score `ENERGY`/`RECOVERY`. Ripristinato `vista.ts` (tolto il ricalcolo), lo stesso test torna verde: ``` npm test -- tests/longevity/_prova-rottura-temp.test.ts tests/longevity/referto.test.ts ``` ``` Test Files 2 passed (2) Tests 7 passed (7) ``` Confermato: **rotto → rosso, riparato → verde**, per la mutazione giusta. Il file di prova (`_prova-rottura-temp.test.ts`) è stato cancellato e non è nel commit — non fa parte del perimetro del task, e con la sola compilazione lifestyle (quella del test ufficiale) non esiste alcuna mutazione dei pesi che lo renderebbe rilevante (ogni altro asse ha copertura zero: la stessa scorciatoia `disponibili.length === 0` in `aggrega()` che rende irrilevante qualunque peso quando NESSUN sotto-dominio è misurato). **Cosa faccio con questo.** Il test così come specificato nel brief (Step 1, scritto verbatim) **resta nel commit senza modifiche**: è quello richiesto letteralmente dall'assegnazione, e la sua debolezza è specifica alla combinazione "asse a un solo sotto-dominio" + "seed dati che copre solo quello". Non l'ho riscritto di mia iniziativa — ma è un ottavo test (il settimo l'ha trovato il Task 2) che sembra proteggere il congelamento e in realtà, con questi dati, non può proteggerlo per costruzione matematica. Se questa suite deve davvero blindare la regola "non si ricalcola al volo", la mutazione giusta è quella sopra: il peso di un **fratello assente** in un asse con **più di un sotto-dominio dichiarato** e **copertura parziale non-zero** (non un asse a un solo elemento, e non un asse completamente vuoto). Le altre tre probe di rottura (mirate ai punti che il brief segnala esplicitamente come critici) **hanno tutte funzionato come dovuto**: - **"un asse senza dati NON porta un numero"**: sostituito `valore: null` con `valore: 0` per l'asse insufficiente → rosso immediato (`expected +0 to be null`). Ripristinato → verde. - **"e dice cosa manca... invece di lasciare un buco"**: `mancanoPer` forzata a restituire sempre `[]` → rosso immediato (`expected 0 to be greater than 0`). Ripristinato → verde. - **"nessuna pagina interroga il database da sola"**: aggiunta una `db.prepare('SELECT 1')` in `io.astro` → rosso immediato (il test intercetta letteralmente `SELECT`/`.prepare(` nel sorgente). Ripristinato → verde. Suite completa e typecheck riverificati allo stato finale (dopo tutte le prove e i ripristini): ``` npm test ``` ``` Test Files 1 failed | 39 passed (40) Tests 1 failed | 327 passed (328) ``` ``` npx tsc --noEmit ``` Output: nessuno (pulito). ``` git status --short ``` ``` (nessuna riga: albero pulito, nessun residuo delle prove di rottura) ``` ## Cosa esiste alla fine di questo piano Come previsto dal brief: un cliente entra, compila il questionario, vede il proprio referto — il radar a sette assi con l'area piena su ciò che sa e il perimetro tratteggiato (più l'elenco di cosa manca) su ciò che non sa ancora, la Fitness Age, le tre carte Performance/Energy/Recovery. Con i dati di oggi (solo questionario, nessun test fisico) **sei assi su sette** risultano tratteggiati — verificato, non solo dichiarato: nel test "restituisce i sette assi" tutti e sette compaiono comunque, e nella sessione di prova solo "Stile di Vita & Sonno" porta un numero. Non deployato: come da vincolo del piano (consenso dati sanitari ancora aperto col consulente legale del cliente). --- ## Rinforzo dopo revisione (2026-08-22): la prova sul congelamento resa discriminante **Stato:** completato. Commit `6630be7` sopra a `7f01ee0`. **Il problema confermato dal coordinatore:** la mutazione originale del test "legge i punteggi congelati, non li ricalcola al volo" (peso di `questionario_lifestyle`, unico sotto-dominio del suo asse) passava per un'identita' algebrica — con un solo elemento disponibile in una media pesata, il peso si semplifica sia al numeratore sia al denominatore, quindi non puo' mai produrre una differenza, letto o ricalcolato che sia. Il test era verde per costruzione matematica, non perche' il congelamento funzionasse. ### Correzione: la mutazione discriminante, con seed dedicato `tests/longevity/referto.test.ts`: - Aggiunta `conCoperturaParziale()`, come `conCompilazione()` ma con in piu' la risposta `q_riposato: 8` (copre `questionario_sonno`). Serve solo a questo test: gli altri cinque restano su `conCompilazione()`, invariati. - Il test "legge i punteggi congelati..." ora usa `conCoperturaParziale()` e muta il peso di `hrv` (0.50 → 0) in "Recupero & Sistema Nervoso" — un sotto-dominio **assente** in un asse a **quattro** elementi, dove solo `questionario_sonno` (0.20) e' misurato: copertura vera 0.20/1.00=0.20 (insufficiente, ma `disponibili.length===1`, non 0 — la formula del peso viene eseguita davvero, non bypassata dalla scorciatoia "nessun dato disponibile" di `aggrega()`). Azzerando `hrv` il totale scende a 0.50, copertura vera 0.20/0.50=0.40: attraversa `COPERTURA_MINIMA`, l'asse passerebbe da insufficiente a ok **se** il referto ricalcolasse. - Commento esteso sopra la mutazione: spiega sia perche' QUESTA mutazione discrimina sia perche' quella vecchia (peso dell'unico sotto-dominio di un asse a un solo elemento) non discrimina mai — cosi' chi la tocca fra sei mesi non rimette una versione innocua. ### La prova di rottura ripetuta sulla versione nuova Backup di `vista.ts`, poi iniettato lo stesso ricalcolo di prima (`calcolaSessione` al posto di `leggiScore`): ```diff - import { leggiScore } from './motore'; + import { leggiScore, calcolaSessione } from './motore'; ... - const punteggi = leggiScore(db, sessioneId); + const punteggi = calcolaSessione(db, sessioneId) as unknown as ReturnType; ``` Comando: ``` npm test -- tests/longevity/referto.test.ts ``` Output: ``` ❯ tests/longevity/referto.test.ts (6 tests | 1 failed) 20ms × legge i punteggi congelati, non li ricalcola al volo 7ms AssertionError: expected '...' to be '...' Expected: ...,"Recupero & Sistema Nervoso","valore":null,"copertura":0.2,... Received: ...,"Recupero & Sistema Nervoso","valore":80,"copertura":0.4,... ``` **Rosso, come atteso**: l'asse passa da `null`/`0.2` a `80`/`0.4`, con effetto a cascata su `ENERGY`/`RECOVERY`. Ripristinato `vista.ts` dal backup (`diff` col backup: nessuna differenza), rilanciato: ``` npm test -- tests/longevity/referto.test.ts ``` ``` Test Files 1 passed (1) Tests 6 passed (6) ``` **Verde**, come atteso. ### Verifiche finali ``` npm test ``` ``` Test Files 1 failed | 39 passed (40) Tests 1 failed | 327 passed (328) ``` Stesso rosso pre-esistente e fuori perimetro (`tests/modifiche-agosto.test.ts > ... inaugurazione ...`); nessuna regressione. ``` npx tsc --noEmit ``` Output: nessuno (pulito). ### Commit ``` git add tests/longevity/referto.test.ts git commit -m "longevity: la prova sul congelamento ora discrimina davvero il ricalcolo" ``` ``` [feat/longevity 6630be7] longevity: la prova sul congelamento ora discrimina davvero il ricalcolo 1 file changed, 44 insertions(+), 3 deletions(-) ``` `git status` dopo il commit: albero pulito. --- ## Rinforzo dopo revisione (2026-08-22): il controllo sui colori ora guarda tutto il foglio **Stato:** completato. Commit `a56398e` sopra a `6630be7`. **Il problema confermato dal coordinatore:** il test "nelle regole di un punteggio compaiono solo i colori ammessi" (`tests/longevity/pagine.test.ts`) filtrava le regole CSS sul selettore (`if (!/lg-score/i.test(selettore)) continue;`) — un elenco di QUALI regole guardare, non di quali colori sono ammessi. Le classi nuove di questo task (`.lg-radar__voce-nome`, `.lg-radar__intro`, `.lg-macro__nota`) non contengono la stringa `lg-score`, quindi non venivano mai scandite: un colore-giudizio lì dentro sarebbe passato indisturbato nonostante 6 test verdi. ### Correzione: si scandisce tutto, le eccezioni si dichiarano `tests/longevity/pagine.test.ts` — rimossa la riga di filtro sul selettore. Il ciclo ora itera **ogni** regola del foglio (che e' interamente prefissato `lg-` per costruzione, garantito dal test subito sopra). Aggiunto un elenco di eccezioni esplicite: ```ts const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [ { selettore: '.lg-form__nav button:last-child:not(:only-child)', valore: '#fff', motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio", }, ]; ``` Con lo scan esteso a tutto il foglio, questa era l'unica riga che avrebbe generato un falso positivo (`color: #fff` sul bottone "Avanti/Invia" pieno d'accento — bianco per contrasto testo, non un giudizio sul punteggio): tutte le altre classi nuove del Task 3 usano gia' solo `var(--c-heading)`/`var(--c-text-light)`, entrambe nella lista ammessa, quindi passano senza bisogno di eccezioni. ### Le due prove richieste **1. Colore-giudizio su una classe del radar.** Iniettato `color: #cc0000;` in `.lg-radar__voce-nome`: ``` npm test -- tests/longevity/pagine.test.ts ``` ``` ❯ ... in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate AssertionError: expected [ Array(1) ] to deeply equal [] + [ ".lg-radar__voce-nome { color: #cc0000 }" ] ``` **Rosso.** Ripristinato dal backup. **2. Colore-giudizio su una classe del macro-score.** Iniettato `color: #cc0000;` in `.lg-macro__nota`: ``` npm test -- tests/longevity/pagine.test.ts ``` ``` ❯ ... in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate AssertionError: expected [ Array(1) ] to deeply equal [] + [ ".lg-macro__nota { color: #cc0000 }" ] ``` **Rosso.** Ripristinato dal backup (`diff` col backup: nessuna differenza). Rilanciato dopo il secondo ripristino: ``` npm test -- tests/longevity/pagine.test.ts ``` ``` Test Files 1 passed (1) Tests 6 passed (6) ``` **Verde.** Entrambe le classi (radar e macro-score) sono ora sorvegliate — nessun buco residuo fra le due superfici nuove di questo task. ### Verifiche finali ``` npm test ``` ``` Test Files 1 failed | 39 passed (40) Tests 1 failed | 327 passed (328) ``` Stesso rosso pre-esistente e fuori perimetro (`tests/modifiche-agosto.test.ts > ... inaugurazione ...`); nessuna regressione. ``` npx tsc --noEmit ``` Output: nessuno (pulito). ### Commit ``` git add tests/longevity/pagine.test.ts git commit -m "longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score" ``` ``` [feat/longevity a56398e] longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score 1 file changed, 23 insertions(+), 5 deletions(-) ``` `git status` dopo il commit: albero pulito.