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>
This commit is contained in:
2026-10-06 10:36:26 +02:00
parent 9126af28ce
commit b913a2bf90
94 changed files with 40294 additions and 0 deletions
@@ -0,0 +1,476 @@
# 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.