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

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 — aggiungere refertoDi
  • Test: tests/longevity/referto.test.ts

Interfaces:

  • Consumes: calcolaSessione, leggiScore dal motore; codicePerUtente da anagrafica.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.