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
This commit is contained in:
AdrianoDev
2026-09-03 11:05:54 +00:00
parent c03edccd8e
commit 510d7ca4ec
551 changed files with 4 additions and 4 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,405 @@
# 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.
@@ -0,0 +1,766 @@
# Longevity — motore di calcolo: 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:** portare in TypeScript il motore di punteggio del cliente, con un verificatore numerico che dimostri che i due producono gli stessi numeri.
**Architecture:** le curve di normalizzazione e la cascata a quattro livelli diventano funzioni pure in `src/lib/longevity/motore/`. Curve e pesi si leggono dal registro nel database, non da costanti nel codice. Il tipo di ritorno rende impossibile leggere il valore di un punteggio dichiarato insufficiente. Un oracolo Python versionato genera i casi di riferimento contro cui il porting si confronta.
**Tech Stack:** TypeScript, vitest, `python3` (solo per generare il riferimento). Nessuna dipendenza npm nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
**Piano precedente, già eseguito:** `docs/plans/2026-08-21-longevity-strato-dati.md` — lo strato dati è costruito (ramo `feat/longevity`, 20 commit).
## 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`, che usano `':memory:'`.
- Codice e commenti in italiano.
- Soglia di copertura: **0.40**, la costante `COPERTURA_MINIMA` già esportata da `src/lib/longevity/db.ts`. Mai un numero scritto a mano nei rami.
- `MODEL_VERSION` iniziale: **`v1.0`**.
- **Nessun dato reale di persone** nelle fixture: i casi di prova sono sintetici.
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` fallisce da prima (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
## Il principio che regge tutto il piano
Il porting delle curve è la parte più pericolosa dell'intera piattaforma, e il pericolo non è il codice che non compila: è **un coefficiente trascritto male**. Produce un punteggio sanitario sbagliato che sembra plausibile, che nessun test funzionale intercetta, e che diventa il valore "atteso" di tutto ciò che gli sta sopra.
Quindi la fedeltà **non è garantita dalla trascrizione**, ma dal confronto numerico con l'originale. L'oracolo (`tests/longevity/riferimento/isl_scoring_engine.py`, scritto dal cliente) genera i valori attesi su una griglia fitta di ingressi; il test TypeScript li rilegge e confronta. Un implementatore che sbagli una cifra se ne accorge subito, non fra sei mesi.
⚠️ **Corollario:** nessun task di questo piano è "finito" perché i suoi test passano. È finito quando il **confronto con l'oracolo** passa sulle funzioni che tocca.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `tests/longevity/riferimento/isl_scoring_engine.py` | l'oracolo (già presente, del cliente, non si modifica) |
| `tests/longevity/riferimento/genera-riferimento.py` | esegue l'oracolo su una griglia e scrive `riferimento.json` |
| `src/lib/longevity/motore/curve.ts` | le cinque curve del questionario + le utility |
| `src/lib/longevity/motore/test-fisici.ts` | le curve dei test misurati in sala e dagli strumenti |
| `src/lib/longevity/motore/cascata.ts` | aggregazione, assi, macro-score, Fitness Age |
| `src/lib/longevity/motore/index.ts` | la funzione che calcola una sessione leggendo dal registro |
---
### Task 1: L'oracolo genera il riferimento
Prima di portare una sola curva, serve il metro di paragone.
**Files:**
- Create: `tests/longevity/riferimento/genera-riferimento.py`
- Create (generato): `tests/longevity/riferimento/riferimento.json`
- Test: nessuno — è uno strumento; lo verificano i task seguenti
**Interfaces:**
- Consumes: `tests/longevity/riferimento/isl_scoring_engine.py`
- Produces: `riferimento.json`, con questa forma esatta:
```json
{
"generato_da": "isl_scoring_engine.py",
"casi": [
{ "fn": "score_plank", "args": [110, "M"], "atteso": 62.5 },
{ "fn": "score_bell_curve", "args": [7.5, 4, 7, 9, 12], "atteso": 100 }
]
}
```
- [ ] **Step 1: Scrivi il generatore**
⚠️ **Questo passo è già stato eseguito.** La griglia definitiva vive in
`tests/longevity/riferimento/genera-riferimento.py`, committata e verificata: rieseguirla
riproduce `riferimento.json` byte per byte (4018 casi, 25 funzioni, nessuna eccezione).
Qui **non** ne teniamo una seconda copia, e la ragione è un incidente vero: quando il piano
conteneva anche il codice, piano e script sono divergiti — il file di riferimento è rimasto
per un momento non riproducibile dal proprio generatore, che per un oracolo è il difetto
peggiore possibile. Due copie della stessa cosa si separano sempre; il codice sta nel repo,
il piano dice cosa deve fare e perché.
**Cosa la griglia copre**, che è ciò che va verificato se un domani si tocca:
- le curve del questionario su **tutto** il dominio, non su un campione, comprese le due
coppie di parametri di `score_decreasing` — quella normale e quella dell'alcol (7→14);
- `score_increasing_plateau` con `worst` **diverso da zero**: zero è il caso degenere in
cui un errore sull'offset resterebbe invisibile;
- `vo2max_from_2km_walk` variando **un parametro alla volta** dal caso base, così una
divergenza dice anche quale dei cinque coefficienti è sbagliato;
- i confini di banda **esatti** delle funzioni a bande (composizione, pressione, plank,
flamingo, SpO2), che una griglia a passo regolare salterebbe;
- le tre tabelle dei sollevamenti sul rapporto col peso corporeo, per entrambi i sessi.
- [ ] **Step 2: Eseguilo e guarda l'esito**
Run: `python3 tests/longevity/riferimento/genera-riferimento.py`
Expected: stampa il numero di casi (nell'ordine delle migliaia) e scrive il file.
⚠️ Se una chiamata solleva un'eccezione, **non aggirarla**: significa che l'oracolo ha un dominio più stretto di quanto la griglia assume. Restringi la griglia per quella funzione e **annota nel rapporto quale dominio hai dovuto escludere** — è un'informazione che serve a chi userà il motore.
- [ ] **Step 3: Verifica che il file sia sensato**
Run: `python3 -c "import json;d=json.load(open('tests/longevity/riferimento/riferimento.json'));print(len(d['casi']),'casi');print(sorted({c['fn'] for c in d['casi']}))"`
Expected: l'elenco delle funzioni coperte, senza buchi rispetto alla griglia sopra.
- [ ] **Step 4: Commit**
```bash
git add tests/longevity/riferimento/
git commit -m "longevity: l'oracolo del motore e la griglia di riferimento"
```
---
### Task 2: Le cinque curve del questionario
**Files:**
- Create: `src/lib/longevity/motore/curve.ts`
- Test: `tests/longevity/motore-curve.test.ts`
**Interfaces:**
- Consumes: `riferimento.json` (Task 1)
- Produces:
- `clamp(x: number, lo?: number, hi?: number): number`
- `lerp(x: number, x0: number, x1: number, y0: number, y1: number): number`
- `curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number`
- `curvaDecrescente(v: number, best: number, worst: number): number`
- `curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number`
- `curvaDirettaX10(v: number): number`
- `curvaDirettaX10Invertita(v: number): number`
- `curvaGradini(v: number, steps: [number, number][], zeroVal: number | undefined, decrescente: boolean): number`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-curve.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
describe('curve del questionario, confrontate con l oracolo', () => {
it('la campana combacia su tutto il dominio', () => {
const casi = casiDi('score_bell_curve');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, low, pl, ph, high] = c.args as number[];
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
}
});
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
const casi = casiDi('score_decreasing');
expect(casi.length).toBeGreaterThan(40);
for (const c of casi) {
const [v, best, worst] = c.args as number[];
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
}
});
it('la crescente con plateau combacia', () => {
for (const c of casiDi('score_increasing_plateau')) {
const [v, worst, plateau] = c.args as number[];
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
}
});
it('la diretta per dieci combacia', () => {
for (const c of casiDi('score_direct_x10')) {
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
}
});
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
it('la diretta invertita e il complemento della diretta', () => {
for (const v of [0, 2.5, 5, 7.5, 10]) {
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
}
expect(curvaDirettaX10Invertita(0)).toBe(100);
expect(curvaDirettaX10Invertita(10)).toBe(0);
});
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
});
it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => {
const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
expect(curvaGradini(0, steps, 100, true)).toBe(100);
expect(curvaGradini(15, steps, 100, true)).toBe(85);
});
it('clamp e lerp si comportano come nell oracolo', () => {
expect(clamp(150)).toBe(100);
expect(clamp(-5)).toBe(0);
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: FAIL — modulo `motore/curve` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo `tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario: curve generiche» più `clamp` e `_lerp` in cima al file. Nomi italiani come da interfacce sopra.
Due avvertenze che l'oracolo non dichiara e che i test qui sopra pretendono:
- **`_lerp` limita `t` all'intervallo [0,1]**, quindi le curve non escono mai oltre gli estremi: è il motivo per cui `curvaDecrescente(0, 7, 14)` vale 100 e non di più. È esattamente ciò che rende voluta la soglia dell'alcol.
- **`curvaGradini` non è nell'oracolo**: è la logica `decstep`/`incstep` del prototipo HTML del cliente. Se il valore è zero ed esiste `zeroVal`, vince quello; altrimenti si interpola fra i gradini, partendo da `(0, 100)` per le decrescenti e `(0, 20)` per le crescenti.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: PASS, 8 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/curve.ts tests/longevity/motore-curve.test.ts
git commit -m "longevity: le cinque curve del questionario, verificate contro l oracolo"
```
---
### Task 3: Le curve dei test fisici
**Files:**
- Create: `src/lib/longevity/motore/test-fisici.ts`
- Test: `tests/longevity/motore-fisici.test.ts`
**Interfaces:**
- Consumes: `clamp`, `lerp` da `motore/curve` (Task 2); `riferimento.json` (Task 1)
- Produces, con questi nomi esatti (l'ordine dei parametri è quello dell'oracolo):
- `scoreGrassoPercento(fatPct: number, sesso: Sesso): number`
- `scoreMuscoloPercento(musclePct: number, sesso: Sesso): number`
- `scoreWhr(whr: number, sesso: Sesso): number`
- `scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number`
- `scoreSpo2(spo2: number): number`
- `scorePressione(sistolica: number, diastolica: number): number`
- `scoreRecuperoCardiaco(caloBpm1min: number): number`
- `scoreHandgrip(kg: number, eta: number, sesso: Sesso): number`
- `scorePushup(reps: number, eta: number, sesso: Sesso): number`
- `scoreSollevamentoSuPeso(caricoKg, pesoKg, rip, tiersM, tiersF, sesso): number`
- `scoreTrazioneIsometrica(sec: number, sesso: Sesso): number`
- `scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi?: number): number`
- `scorePlank(sec: number, sesso: Sesso): number`
- `scoreBackScratch(cm: number, eta: number, sesso: Sesso): number`
- `scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number`
- `scoreFlamingo(cadute: number): number`
- `scoreSitAndReach(cm: number, sesso: Sesso): number`
- `vo2maxDaStepTest`, `vo2maxDa2kmWalk`, `vo2maxDaMutt`, `vo2maxDaMilfit`
- `type Sesso = 'M' | 'F'`
- le sei tabelle: `TIERS_BENCH_M`, `TIERS_BENCH_F`, `TIERS_SQUAT_M`, `TIERS_SQUAT_F`, `TIERS_ROW_M`, `TIERS_ROW_F`
⚠️ **Due funzioni dell'oracolo NON vanno portate**, e non è una dimenticanza:
- `score_agility_ms` — l'agilità è stata **rimossa dallo score** dal cliente il 13/08: 548 ms reali contro i 250 attesi davano 20/100, falsi, per una scala non comparabile fra protocolli. Nel motore la funzione è rimasta ma nessun peso la richiama.
- `score_generic_range_local` — definita in fondo al file e mai chiamata.
Portarle significherebbe riportare in vita una misura che il cliente ha escluso.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-fisici.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as F from '../../src/lib/longevity/motore/test-fisici';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
const COPPIE: [string, (...a: never[]) => number][] = [
['score_fat_percent', F.scoreGrassoPercento as never],
['score_muscle_percent', F.scoreMuscoloPercento as never],
['score_whr', F.scoreWhr as never],
['score_vo2max', F.scoreVo2max as never],
['score_spo2', F.scoreSpo2 as never],
['score_blood_pressure', F.scorePressione as never],
['score_hrr', F.scoreRecuperoCardiaco as never],
['score_handgrip', F.scoreHandgrip as never],
['score_pushup', F.scorePushup as never],
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
['score_sit_to_stand_1min', F.scoreSitToStand as never],
['score_plank', F.scorePlank as never],
['score_back_scratch', F.scoreBackScratch as never],
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
['score_flamingo', F.scoreFlamingo as never],
['score_sit_and_reach', F.scoreSitAndReach as never],
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
['vo2max_from_mutt', F.vo2maxDaMutt as never],
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
];
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
for (const [nomePython, fnTs] of COPPIE) {
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
const casi = RIF.casi.filter((c) => c.fn === nomePython);
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
const divergenti: string[] = [];
for (const c of casi) {
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
if (Math.abs(ottenuto - c.atteso) > 0.05) {
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
}
}
expect(divergenti.slice(0, 5).join('\n')).toBe('');
});
}
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle', () => {
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
expect(casi.length).toBeGreaterThan(50);
const perTabella = (nome: string) =>
({ bench: [F.TIERS_BENCH_M, F.TIERS_BENCH_F], squat: [F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
row: [F.TIERS_ROW_M, F.TIERS_ROW_F] } as Record<string, unknown[]>)[nome];
for (const c of casi) {
const [carico, peso, rip, tiersM, tiersF, sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
const ottenuto = F.scoreSollevamentoSuPeso(
carico, peso, rip,
tiersM as [number, number][], tiersF as [number, number][], sesso
);
expect(ottenuto, `carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
}
expect(perTabella('bench')).toBeTruthy();
});
it('l agilita NON e stata portata: il cliente l ha esclusa dallo score', () => {
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: FAIL — modulo `motore/test-fisici` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo, una per una, mantenendo **identici** i coefficienti, le tabelle di ancoraggio e l'ordine dei confronti. Le trovi nelle sezioni «Composizione Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza & Struttura» e «Stabilità & Mobilità».
⚠️ **Non "sistemare" nulla mentre porti**, nemmeno ciò che sembra un difetto: se una funzione ha un ramo irraggiungibile o un valore di ripiego che pare arbitrario, va riprodotto tale e quale. L'oracolo è la definizione, non una proposta — e il confronto numerico ti dirà subito se hai cambiato qualcosa.
⚠️ **Attenzione a `score_handgrip`**: usa un ciclo con `break` e un ramo `else` del `for` (costrutto che in TypeScript non esiste). Riproduci il comportamento: se l'età non cade in nessun intervallo fra le ancore, vale il valore dell'ultima.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: PASS, 22 test
Se un test elenca divergenze, il messaggio ti dà ingressi, atteso e ottenuto: **correggi la tua funzione**, non il riferimento.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/test-fisici.ts tests/longevity/motore-fisici.test.ts
git commit -m "longevity: le curve dei test fisici, verificate contro l oracolo"
```
---
### Task 4: La cascata, e il tipo che impedisce di mostrare un numero che non c'è
**Files:**
- Create: `src/lib/longevity/motore/cascata.ts`
- Test: `tests/longevity/motore-cascata.test.ts`
**Interfaces:**
- Consumes: `COPERTURA_MINIMA` da `src/lib/longevity/db.ts`
- Produces:
- `type Punteggio = { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number }`
- `type VocePesata = { punteggio: number | null; peso: number }`
- `aggrega(voci: VocePesata[]): Punteggio`
- `calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio`
- `calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio`
- `calcolaFitnessAge(etaAnagrafica: number, pesi: Record<string, number>, voci: Record<string, number | null>): { fitnessAge: number | null; composito: Punteggio }`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-cascata.test.ts
import { describe, it, expect } from 'vitest';
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
describe('aggregazione e rinormalizzazione', () => {
it('con tutti i dati fa la media pesata', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
});
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
expect(r.stato).toBe('ok');
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
});
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.2, 2);
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
expect((r as { valore?: number }).valore).toBeUndefined();
});
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
expect(pocoSotto.stato).toBe('insufficiente');
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
});
it('senza nessun dato e insufficiente con copertura zero', () => {
const r = aggrega([{ punteggio: null, peso: 1 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBe(0);
});
});
describe('assi, macro e Fitness Age', () => {
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
it('un asse si calcola sui suoi sotto-domini', () => {
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
expect(r.stato).toBe('ok');
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.25, 1);
});
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
const assi = {
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
B: { stato: 'insufficiente', copertura: 0.1 } as const,
};
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
expect(r.stato).toBe('ok');
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
});
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
// 40 - (75 - 50) * 0.4 = 30
expect(r.fitnessAge).toBeCloseTo(30, 1);
});
it('senza dati sufficienti la Fitness Age non esiste', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
expect(r.composito.stato).toBe('insufficiente');
expect(r.fitnessAge).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: FAIL — modulo `motore/cascata` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta `aggregate`, `compute_axis`, `compute_macro_scores` e `compute_fitness_age` dall'oracolo, **con una differenza deliberata**:
⚠️ Nell'oracolo `aggregate()` restituisce il punteggio pieno **anche quando lo dichiara insufficiente** — la docstring dice il contrario di ciò che il codice fa. È una trappola: chi consuma deve ricordarsi di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo `Punteggio` la chiude: nel ramo `insufficiente` **il campo `valore` non esiste**, quindi la regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa impossibile da violare per distrazione.
Il resto va riprodotto fedelmente: la soglia è **inclusiva** (`copertura < MINIMA` è insufficiente, quindi esattamente 0.40 è ok), i pesi si rinormalizzano su quelli disponibili, la Fitness Age è `eta - (composito - 50) * 0.4`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: PASS, 10 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/cascata.ts tests/longevity/motore-cascata.test.ts
git commit -m "longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente"
```
---
### Task 5: Il motore legge dal registro, non da costanti
È il task che collega il motore allo strato dati: curve, parametri e pesi vengono dal database, così aggiungere o togliere un test resta una modifica ai dati.
**Files:**
- Create: `src/lib/longevity/motore/index.ts`
- Modify: `src/lib/longevity/registro.ts` — aggiungere `MODEL_VERSION` e `pesiDi`
- Test: `tests/longevity/motore.test.ts`
**Interfaces:**
- Consumes: tutto quanto sopra, più `createLongevityDb`, `seedRegistro`, `seedPesi`, `apriSessione`, `registraMisure`
- Produces:
- `MODEL_VERSION: string` (`'v1.0'`, esportata da `registro.ts`)
- `pesiDi(db, modelVersion, livello, contenitore): Record<string, number>` in `registro.ts`
- `applicaCurva(voce: VoceRegistro, valore: number): number | null` — normalizza un valore grezzo secondo la curva dichiarata nel registro
- `calcolaSessione(db, sessioneId): { assi, macro, fitnessAge, modelVersion, questVersion }`
- `salvaScore(db, sessioneId, risultato): void` — congela i punteggi nella tabella `score`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
describe('il motore legge le curve dal registro', () => {
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
});
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 0)).toBe(100);
expect(applicaCurva(v as never, 7)).toBe(100);
expect(applicaCurva(v as never, 14)).toBe(0);
});
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
.run(JSON.stringify({ best: 0, worst: 7 }));
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
});
it('un test disattivato non entra nel calcolo', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0 },
});
const prima = calcolaSessione(db, 1);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_ore_sonno'`).run();
const dopo = calcolaSessione(db, 1);
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
});
});
describe('calcolo e congelamento di una sessione', () => {
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
});
const r = calcolaSessione(db, 1);
expect(Object.keys(r.assi).length).toBe(7);
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
});
it('congela i punteggi con le DUE versioni', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 7 },
});
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
expect(righe.length).toBeGreaterThan(0);
for (const r of righe) {
expect(r.model_version).toBe(MODEL_VERSION);
expect(r.quest_version).toBe('v1.0');
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
}
});
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
const db = dbPronto();
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
expect(pesi.handgrip).toBe(0.25);
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore.test.ts`
Expected: FAIL — modulo `motore` non trovato
- [ ] **Step 3: Write minimal implementation**
In `registro.ts` aggiungi:
```ts
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
export const MODEL_VERSION = 'v1.0';
export function pesiDi(
db: Database.Database, modelVersion: string,
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
): Record<string, number> {
const righe = db.prepare(
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
}
```
In `motore/index.ts`: `applicaCurva` smista sulla curva dichiarata nel registro e passa i parametri letti da lì; `calcolaSessione` legge le misure attive della sessione, le normalizza, raggruppa per sotto-dominio (facendo la **media** dei test dello stesso sotto-dominio), calcola i sette assi, i tre macro e la Fitness Age; `salvaScore` scrive in `score` una riga per asse, macro e Fitness Age, con `valore` a `null` quando lo stato è insufficiente.
⚠️ **La copertura si calcola sui pesi, non sul numero di test.** Un sotto-dominio senza nessuna misura è `null` e il suo peso si ridistribuisce; è la regola dell'oracolo e vale a ogni livello.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS su tutta la suite nuova; atteso il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/index.ts src/lib/longevity/registro.ts tests/longevity/motore.test.ts
git commit -m "longevity: il motore legge curve e pesi dal registro, e congela i punteggi"
```
---
### Task 6: La prova sul caso reale documentato
L'ultimo controllo non è sulle funzioni: è sul fatto che l'insieme produca i numeri che il cliente ha già visto.
**Files:**
- Test: `tests/longevity/motore-caso-reale.test.ts`
**Interfaces:**
- Consumes: tutto il motore
⚠️ **I dati di Donata e Nicola non entrano nelle fixture** — sono dati sanitari di due persone identificabili, e le fixture stanno in git. Il caso qui sotto è **sintetico**, costruito per esercitare le stesse condizioni descritte nella documentazione: un profilo con composizione corporea completa e tutto il resto mancante.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-caso-reale.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione } from '../../src/lib/longevity/motore';
/**
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
* Dati sintetici: nessuna persona reale.
*/
describe('un profilo con una sola area misurata', () => {
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
expect(insufficienti).toBe(7);
expect(r.fitnessAge).toBeNull();
});
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
for (const [nome, asse] of Object.entries(r.assi)) {
if (asse.stato === 'insufficiente') {
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
}
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-caso-reale.test.ts`
Expected: FAIL se qualcosa nella catena non regge il caso senza misure.
- [ ] **Step 3: Correggi ciò che il caso reale smonta**
Non c'è codice nuovo da scrivere: se questo test fallisce, il difetto è in un task precedente. Correggilo lì e **annota nel rapporto quale task ha dovuto essere corretto** — è l'informazione che dice se il porting regge davvero o solo sui casi comodi.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS, con il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add tests/longevity/motore-caso-reale.test.ts
git commit -m "longevity: la prova sul profilo con una sola area misurata"
```
---
## Cosa esiste alla fine di questo piano
Il motore completo: le curve verificate una per una contro l'oracolo del cliente su migliaia di casi, la cascata a quattro livelli, la lettura di curve e pesi dal registro, il congelamento dei punteggi con le due versioni distinte.
**Non esiste ancora nessuna pagina.** È il piano successivo, e da lì in poi il radar ha numeri veri da mostrare.
## Nota per il piano dell'interfaccia
I sette assi che il motore restituisce sono `Punteggio`, cioè o `{stato:'ok', valore}` o `{stato:'insufficiente'}` senza valore. La dashboard **non può** stampare un numero dove non c'è: è il tipo a impedirlo, ed è il motivo per cui il tratteggio del radar non dipende dalla disciplina di chi scrive la vista.
⚠️ Con i dati di oggi — solo questionario, nessun test fisico — **sei assi su sette risultano insufficienti**. È corretto e atteso: i test fisici entrano col piano degli import. Chi guarderà la prima dashboard non deve scambiarlo per un difetto.