Files
InsanityLab-web/docs/plans/2026-08-22-longevity-interfaccia.md
T

406 lines
21 KiB
Markdown

# 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**
```ts
// 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:
```css
--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**
```bash
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**
```ts
// 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**
```bash
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**
```ts
// 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**
```bash
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.