Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5.8 KiB
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— aggiungererefertoDi - Test:
tests/longevity/referto.test.ts
Interfaces:
- Consumes:
calcolaSessione,leggiScoredal motore;codicePerUtentedaanagrafica.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
// 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
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.