Files
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

477 lines
20 KiB
Markdown
Raw Permalink Blame History

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