Files
InsanityLab-web/apps/sito/docs/plans/2026-08-22-longevity-interfaccia.md
AdrianoDev 510d7ca4ec Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

Qui non cambia niente del codice: solo la radice. I pattern del .gitignore
che avevano una barra erano ancorati alla radice e dopo lo spostamento non
avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a
qualunque profondita'.

Suite: 45 file, 347 test verdi prima e dopo lo spostamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:05:54 +00:00

21 KiB

Longevity — l'interfaccia: piano di implementazione

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: dare una faccia al fascicolo del cliente — il questionario che si compila in pagina e il referto che il cliente legge, col radar a sette assi.

Architecture: pagine Astro dentro il sito esistente, sotto /longevity/, con un layout dedicato e un foglio di stile prefissato. Il contenuto statico è Astro; React solo dove serve interattività vera — il questionario a blocchi e il radar. I dati arrivano dal motore già costruito, mai da query scritte nelle pagine.

Tech Stack: Astro, React, recharts (già in casa), better-sqlite3, vitest. Nessuna dipendenza nuova.

Spec: docs/specs/2026-08-21-longevity-design.md

Piani precedenti, già eseguiti: 2026-08-21-longevity-strato-dati.md e 2026-08-22-longevity-motore.md. Lo strato dati e il motore esistono, sono testati, e nessuna pagina li usa ancora.

Global Constraints

  • Branch feat/longevity. main non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
  • Nessuna dipendenza npm nuova.
  • Test in tests/longevity/, eseguiti con npm test. Codice e commenti in italiano.
  • ⚠️ La suite parte con un rosso che non è nostro: tests/modifiche-agosto.test.ts (test del sito disallineato su main, fuori perimetro). L'atteso è 1 fallito pre-esistente, il resto verde. Non ripararlo.
  • ⚠️ npx tsc --noEmit deve restare pulito. In questo lavoro il tipo è il meccanismo di sicurezza: un typecheck rosso è un cancello che nessuno guarda più.
  • Nessun dato reale di persone nelle fixture.

Le due regole di prodotto che l'interfaccia non può violare

1. Un punteggio insufficiente non si mostra come numero. Il motore restituisce un tipo che nel ramo insufficiente non ha il campo valore: la pagina non ha da dove prenderlo. Ma la barriera finisce lì — la tabella dei punteggi è letta da una funzione tipata (leggiScore), e le pagine devono usare quella, mai una query propria. Una SELECT valore FROM score scritta in una pagina riapre il buco.

2. Il colore non giudica il corpo. Niente semaforo verde/giallo/rosso sui punteggi: il servizio è premium ma non clinico, e un rosso su «Composizione Corporea» detto a una persona è un giudizio, non un'informazione. I punteggi si esprimono con l'intensità del colore d'accento del sito. L'unico colore di segnale — il mattone #b05a4e già presente nel foglio di Stress Index — è riservato ai valori fuori range, che segnalano un problema della misura, non della persona.

Il gesto che questa interfaccia deve avere

Il radar mostra quello che sappiamo come area piena, e dove la misura non basta lascia un perimetro tratteggiato con, sotto, cosa manca per completarlo. Non un buco, non un errore: un invito.

Regge tre cose insieme — la regola di prodotto («tratteggiato, mai un numero pieno fasullo»), la leva commerciale che il cliente voleva per il livello avanzato, e una posizione onesta: non ti diciamo un numero che non sappiamo.

⚠️ Con i dati di oggi sei assi su sette saranno tratteggiati, perché nel registro c'è solo il questionario e i test fisici arrivano col piano degli import. È atteso: chi guarda la prima dashboard non deve scambiarlo per un guasto.

Struttura dei file

File Responsabilità
src/layouts/Longevity.astro layout della piattaforma: intestazione, niente header pubblico
src/styles/longevity.css stile prefissato .lg, globale (deve raggiungere le isole React)
src/pages/longevity/questionario.astro la pagina del questionario
src/components/longevity/Questionario.tsx isola React: quattro blocchi, validazione, invio
src/pages/api/longevity/questionario.ts endpoint che salva una compilazione
src/pages/longevity/io.astro il referto del cliente
src/components/longevity/Radar.tsx isola React: il radar a sette assi col tratteggio
src/components/longevity/MacroScore.astro le tre carte dei macro-score
src/lib/longevity/vista.ts ciò che serve alle pagine, letto dal motore

Task 1: Il guscio — layout, stile, e una rotta che risponde

Files:

  • Create: src/layouts/Longevity.astro, src/styles/longevity.css, src/pages/longevity/index.astro
  • Test: tests/longevity/pagine.test.ts

Interfaces:

  • Consumes: isProtectedPath, canAccessAdminPath da src/lib/auth.ts
  • Produces: il layout Longevity.astro con title e crumbs, e le classi .lg-*

Il modello da seguire: src/layouts/StressIndex.astro e src/styles/stress-index.css. Leggili prima di scrivere. Il foglio di stile va globale e prefissato, non in <style> scoped: le stesse classi servono ai componenti Astro e alle isole React, che lo scope di Astro non raggiunge — la ragione è scritta in testa al foglio di Stress Index.

La differenza voluta rispetto a Stress Index. Quella piattaforma ha una barra laterale con sette voci, giusta per chi ci lavora dentro. Qui i pubblici sono due e uno non è staff: il cliente entra tre o quattro volte l'anno per guardare il proprio referto, e una barra di navigazione è l'arredamento di uno strumento di lavoro. Quindi il layout prevede due forme: variante="referto" (una colonna che scorre, nessuna navigazione) e variante="gestionale" (barra laterale come Stress Index). Questo task realizza la prima; la seconda arriva col piano del gestionale.

  • Step 1: Write the failing test
// tests/longevity/pagine.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');

describe('il guscio della piattaforma', () => {
  it('il layout esiste e non monta header e footer pubblici', () => {
    const l = leggi('src/layouts/Longevity.astro');
    expect(l).not.toMatch(/import\s+Header/);
    expect(l).not.toMatch(/import\s+Footer/);
    expect(l).toMatch(/noindex/);          // area riservata: mai indicizzata
  });

  it('il foglio di stile e globale e prefissato, non scoped', () => {
    expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
    const css = leggi('src/styles/longevity.css');
    const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
    expect(classi.length).toBeGreaterThan(3);
    expect(classi.every((c) => c.startsWith('lg'))).toBe(true);
  });

  it('usa i token del sito invece di inventare colori', () => {
    const css = leggi('src/styles/longevity.css');
    expect(css).toMatch(/var\(--c-/);
  });

  it('nessun semaforo sui punteggi: il colore non giudica il corpo', () => {
    const css = leggi('src/styles/longevity.css').toLowerCase();
    // il mattone del fuori range e ammesso; un verde "tutto bene" no
    expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
  });

  it('la rotta del cliente e sotto /longevity, dove le regole la proteggono', () => {
    expect(existsSync(join(process.cwd(), 'src/pages/longevity/index.astro'))).toBe(true);
  });
});
  • Step 2: Run test to verify it fails

Run: npm test -- tests/longevity/pagine.test.ts Expected: FAIL — i file non esistono

  • Step 3: Write minimal implementation

Il layout prende title, crumbs e variante, importa i font e i due fogli di stile (global.css e longevity.css), e dichiara noindex. Nessun Header/Footer pubblico: la navigazione è interna.

Il foglio longevity.css definisce, tutte prefissate lg: il guscio del referto (una colonna centrata, larghezza leggibile), l'intestazione, la carta di un punteggio, e i token locali. Le variabili locali riprendono quelle del sito (--c-accent, --c-dark, --c-bg-alt) e ne aggiungono due sole:

--lg-tratteggio: #d8d2c8;   /* il perimetro di cio che non sappiamo */
--lg-fuori-range: #b05a4e;  /* SOLO per una misura sospetta, mai per un punteggio */

⚠️ Non definire un colore «buono» o «cattivo» per i punteggi: l'intensità si ottiene variando l'opacità dell'accento. È la regola di prodotto, e il test la controlla.

src/pages/longevity/index.astro reindirizza a /longevity/io.

  • Step 4: Run test to verify it passes

Run: npm test -- tests/longevity/pagine.test.ts → PASS (5 test) Poi: npm test per intero e npx tsc --noEmit.

  • Step 5: Commit
git add src/layouts/Longevity.astro src/styles/longevity.css src/pages/longevity/index.astro tests/longevity/pagine.test.ts
git commit -m "longevity: il guscio della piattaforma, senza semaforo sui punteggi"

Task 2: Il questionario in pagina

Sostituisce il prototipo del cliente, che salva nel browser e che il documento stesso dichiara da buttare in produzione.

Files:

  • Create: src/components/longevity/Questionario.tsx, src/pages/longevity/questionario.astro, src/pages/api/longevity/questionario.ts
  • Test: tests/longevity/questionario-pagina.test.ts

Interfaces:

  • Consumes: testAttivi da registro.ts; salvaCompilazione, QUEST_VERSION, CAMPI_LIBERI da questionario.ts; codicePerUtente da anagrafica.ts
  • Produces: POST /api/longevity/questionario{ sessioneId } o errore

Le domande vengono dal registro, non dal codice. La pagina legge testAttivi e costruisce i campi da lì: etichetta, tipo, minimo e massimo. Aggiungere una domanda resta una modifica ai dati — è la promessa della spec, e qui va mantenuta.

⚠️ Tre regole che l'endpoint deve rispettare, e sono di sostanza:

  1. Il codice cliente si ricava dalla sessione, mai dalla richiesta. Un codice che arriva dal browser va ignorato. È la regola §8 della spec, e questo è il primo endpoint su cui si può finalmente provare.
  2. Il consenso al trattamento dei dati sanitari non è «compilato», è «dato». Il prototipo del cliente blocca il salvataggio solo se il campo è vuoto: rispondendo «No» i dati vengono salvati lo stesso. È l'esatto contrario di ciò per cui il campo esiste. Qui: se il consenso non è affermativo, la compilazione non si salva, e la risposta lo dice chiaramente.
  3. Il consenso va registrato, non solo controllato. Un consenso è un fatto che un domani va dimostrato: chi, quando, a cosa. Salvalo fra le note di profilo con la sua data e la versione del questionario, anche quando la risposta è no — il rifiuto è precisamente ciò di cui bisogna poter provare il rispetto.
  • Step 1: Write the failing test
// tests/longevity/questionario-pagina.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 } from '../../src/lib/longevity/registro';
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';

function dbPronto() {
  const db = createLongevityDb(':memory:');
  seedRegistro(db);
  db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
  return db;
}

describe('i campi del questionario vengono dal registro', () => {
  it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
    const db = dbPronto();
    const campi = campiDelQuestionario(db, '2026-08-22');
    expect(campi.length).toBe(20);
    const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
    expect(alcol.etichetta).toMatch(/alcoliche/i);
    expect(alcol.max).toBe(30);
  });

  it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
    const db = dbPronto();
    db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
    const campi = campiDelQuestionario(db, '2026-08-22');
    expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
    expect(campi.length).toBe(19);
  });
});

describe('il salvataggio, e il consenso', () => {
  const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };

  it('salva una compilazione quando il consenso e dato', () => {
    const db = dbPronto();
    const esito = salvaDalForm(db, {
      client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
      risposte, consensoSanitario: 'Si', liberi: {},
    });
    expect(esito.ok).toBe(true);
    const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
    expect(n.n).toBe(1);
  });

  it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
    const db = dbPronto();
    const esito = salvaDalForm(db, {
      client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
      risposte, consensoSanitario: 'No', liberi: {},
    });
    expect(esito.ok).toBe(false);
    const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
    expect(n.n).toBe(0);   // niente sessione, niente misure
  });

  it('NON salva se il consenso manca del tutto', () => {
    const db = dbPronto();
    const esito = salvaDalForm(db, {
      client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
      risposte, consensoSanitario: '', liberi: {},
    });
    expect(esito.ok).toBe(false);
  });

  it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
    const db = dbPronto();
    salvaDalForm(db, {
      client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
      risposte, consensoSanitario: 'No', liberi: {},
    });
    const note = db.prepare(
      `SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
    ).all() as { campo_id: string; testo: string }[];
    expect(note.length).toBeGreaterThan(0);
    expect(note[0].testo).toMatch(/No/);
  });

  it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
    const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
    // il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
    expect(src).toMatch(/codicePerUtente/);
    expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
  });
});
  • Step 2: Run test to verify it fails

Run: npm test -- tests/longevity/questionario-pagina.test.ts Expected: FAIL — src/lib/longevity/vista.ts non esiste

  • Step 3: Write minimal implementation

vista.ts espone campiDelQuestionario(db, alla) — che legge testAttivi e tiene i soli test_id che iniziano per q_ — e salvaDalForm(db, input), che controlla il consenso prima di scrivere qualsiasi cosa, registra il consenso fra le note col suo esito e la versione del questionario, e delega a salvaCompilazione.

L'isola React mostra i quattro blocchi uno per schermata, con avanti e indietro, e invia in fondo. L'endpoint ricava il codice cliente dalla sessione con codicePerUtente e passa a salvaDalForm.

⚠️ Il consenso rifiutato va scritto comunque fra le note: apri la sessione solo se il consenso è affermativo, ma la nota del consenso si scrive in entrambi i casi, legata al cliente.

  • Step 4: Run test to verify it passes

Run: npm test — atteso 1 fallito pre-esistente, il resto verde. Poi npx tsc --noEmit pulito.

  • Step 5: Commit
git add src/lib/longevity/vista.ts src/components/longevity/Questionario.tsx \
        src/pages/longevity/questionario.astro src/pages/api/longevity/questionario.ts \
        tests/longevity/questionario-pagina.test.ts
git commit -m "longevity: il questionario in pagina, e il consenso che si puo negare davvero"

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.