Files
InsanityLab-web/.superpowers/sdd/2026-08-22-longevity-interfaccia/task-3-report.md
T
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

20 KiB
Raw Blame History

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

- 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):

- 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:

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.