diff --git a/docs/plans/2026-08-21-longevity-strato-dati.md b/docs/plans/2026-08-21-longevity-strato-dati.md new file mode 100644 index 0000000..07e3c18 --- /dev/null +++ b/docs/plans/2026-08-21-longevity-strato-dati.md @@ -0,0 +1,1294 @@ +# Longevity — strato dati: 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:** costruire lo strato dati del fascicolo cliente — i due database, il registro dei test, la registrazione delle misure, l'anagrafica pseudonimizzata e il salvataggio del questionario — testato e senza interfaccia. + +**Architecture:** due file SQLite affiancati a quello del sito. `identity.db` tiene il legame fra codice cliente e persona; `longevity.db` tiene misure, questionari e punteggi e non contiene mai un nome. Le misure sono un registro (una riga per valore, da qualunque sorgente) validato contro una tabella di test che è dato e non codice. Un solo modulo apre entrambe le connessioni. + +**Tech Stack:** TypeScript, better-sqlite3, vitest. Nessuna dipendenza nuova. + +**Spec:** `docs/specs/2026-08-21-longevity-design.md` + +## Global Constraints + +- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda. +- Nessuna dipendenza npm nuova: better-sqlite3 e vitest ci sono già. +- Si segue il pattern di `src/lib/db.ts`: funzione `createXDb(path?)`, `journal_mode = WAL`, `foreign_keys = ON`, schema idempotente eseguito all'apertura. +- Test in `tests/longevity/`, eseguiti con `npm test` (vitest). I test usano `':memory:'`. +- **Un solo modulo** (`src/lib/longevity/anagrafica.ts`) può importare insieme le due connessioni. Un test lo verifica. +- **Nessun dato reale di persone nelle fixture.** I casi di prova sono sintetici. +- Soglia di copertura: **0.40**, dichiarata come costante esportata, mai scritta a mano nei rami. +- `model_version` iniziale: **`v1.0`**. `quest_version` iniziale: **`v1.0`** (è quella che Nicola ha già messo nel prototipo). + +## Perimetro di questo piano + +Questo piano costruisce **lo strato dati e basta**: alla fine esiste una libreria testata che sa registrare un cliente, registrare misure da qualunque sorgente, salvare una compilazione di questionario ed esportare i dati pseudonimizzati. + +**Fuori da questo piano**, e ognuno avrà il suo: il motore di calcolo degli score (porting delle curve dal Python, con verificatore), le pagine web del questionario e della dashboard, gli import da Wellness Tower/VALD/Calibre. + +Il motivo dell'ordine è che tutto il resto legge o scrive questi dati: costruirlo per primo evita che tre pezzi inventino tre modelli diversi. + +## Struttura dei file + +| File | Responsabilità | +|---|---| +| `src/lib/longevity/db.ts` | apertura e schema dei due database | +| `src/lib/longevity/registro.ts` | registro dei test e tabella pesi: seed e query | +| `src/lib/longevity/misure.ts` | `registraMisure`, l'unico varco di scrittura | +| `src/lib/longevity/anagrafica.ts` | **l'unico** modulo che unisce identity e longevity | +| `src/lib/longevity/questionario-def.ts` | i 20 campi punteggiati come dato | +| `src/lib/longevity/questionario.ts` | salvataggio di una compilazione | +| `src/lib/longevity/export.ts` | export pseudonimizzato | +| `src/lib/auth.ts` | *(modifica)* ruoli `cliente` e `trainer` | + +--- + +### Task 1: Schema dei due database + +**Files:** +- Create: `src/lib/longevity/db.ts` +- Test: `tests/longevity/db.test.ts` + +**Interfaces:** +- Consumes: niente +- Produces: `createLongevityDb(path?: string): Database.Database`, `createIdentityDb(path?: string): Database.Database`, `COPERTURA_MINIMA: number` (0.40) + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/db.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db'; + +describe('schema longevity', () => { + it('crea le tabelle previste dalla spec', () => { + const db = createLongevityDb(':memory:'); + const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) + .map((r) => r.name); + expect(nomi).toEqual( + expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score']) + ); + }); + + it('rifiuta un sesso non previsto', () => { + const db = createLongevityDb(':memory:'); + expect(() => + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run() + ).toThrow(); + }); + + it('rifiuta una misura con test_id non nel registro', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + expect(() => + db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte) + VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')` + ).run() + ).toThrow(); + }); + + it('uno score insufficiente puo non avere valore', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, nome, valore, copertura, stato, model_version) + VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')` + ).run(); + const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string }; + expect(row.valore).toBeNull(); + expect(row.stato).toBe('insufficiente'); + }); + + it('identity tiene la persona, longevity non la conosce', () => { + const id = createIdentityDb(':memory:'); + const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name); + expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita'])); + + const lg = createLongevityDb(':memory:'); + const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) + .flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name)); + expect(tutte).not.toContain('nome'); + expect(tutte).not.toContain('cognome'); + expect(tutte).not.toContain('data_nascita'); + }); + + it('la soglia di copertura e 0.40', () => { + expect(COPERTURA_MINIMA).toBe(0.4); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/db.test.ts` +Expected: FAIL — "Cannot find module '../../src/lib/longevity/db'" + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/db.ts +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. */ +export const COPERTURA_MINIMA = 0.4; + +const SCHEMA_LONGEVITY = ` +CREATE TABLE IF NOT EXISTS soggetti ( + client_code TEXT PRIMARY KEY, + sesso TEXT NOT NULL CHECK (sesso IN ('M','F')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS registro_test ( + test_id TEXT PRIMARY KEY, + etichetta TEXT NOT NULL, + unita TEXT, + tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), + curva TEXT, + params TEXT, + asse TEXT, + sotto_dominio TEXT, + range_min REAL, + range_max REAL, + attivo_da TEXT NOT NULL, + attivo_a TEXT, + note TEXT +); +CREATE TABLE IF NOT EXISTS sessioni ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + data TEXT NOT NULL, + tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')), + eta_alla_data INTEGER, + operatore TEXT, + note TEXT, + quest_version TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC); +CREATE TABLE IF NOT EXISTS misure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + test_id TEXT NOT NULL REFERENCES registro_test(test_id), + valore_num REAL, + valore_txt TEXT, + unita TEXT, + fonte TEXT NOT NULL CHECK (fonte IN + ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), + fuori_range INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); +CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id); +CREATE TABLE IF NOT EXISTS pesi ( + model_version TEXT NOT NULL, + livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')), + contenitore TEXT NOT NULL, + elemento TEXT NOT NULL, + peso REAL NOT NULL, + PRIMARY KEY (model_version, livello, contenitore, elemento) +); +CREATE TABLE IF NOT EXISTS profilo_note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER REFERENCES sessioni(id), + campo_id TEXT NOT NULL, + testo TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS score ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER NOT NULL REFERENCES sessioni(id), + tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), + nome TEXT NOT NULL, + valore REAL, + copertura REAL NOT NULL, + stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), + quest_version TEXT, + model_version TEXT NOT NULL, + calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); +`; + +const SCHEMA_IDENTITY = ` +CREATE TABLE IF NOT EXISTS clienti ( + client_code TEXT PRIMARY KEY, + user_id INTEGER, + nome TEXT NOT NULL, + cognome TEXT NOT NULL, + data_nascita TEXT, + email TEXT, + telefono TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id); +`; + +function apri(p: string, schema: string): Database.Database { + if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true }); + const db = new Database(p); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(schema); + return db; +} + +export function createLongevityDb(path?: string): Database.Database { + return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); +} + +export function createIdentityDb(path?: string): Database.Database { + return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/db.test.ts` +Expected: PASS, 6 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/db.ts tests/longevity/db.test.ts +git commit -m "longevity: schema dei due database, identity separato da longevity" +``` + +--- + +### Task 2: Registro dei test e pesi — seed e query + +**Files:** +- Create: `src/lib/longevity/registro.ts` +- Test: `tests/longevity/registro.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` da Task 1 +- Produces: + - `type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'` + - `type VoceRegistro = { test_id: string; etichetta: string; unita?: string; tipo_valore: 'num'|'txt'; curva?: Curva; params?: unknown; asse?: string; sotto_dominio?: string; range_min?: number; range_max?: number; attivo_da: string; attivo_a?: string }` + - `seedRegistro(db: Database.Database): void` + - `seedPesi(db: Database.Database, modelVersion: string): void` + - `testAttivi(db: Database.Database, alla: string): VoceRegistro[]` + - `esisteTest(db: Database.Database, testId: string): boolean` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/registro.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro'; + +describe('registro dei test', () => { + it('carica i 20 campi punteggiati del questionario', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number }; + expect(q.n).toBe(20); + }); + + it('alcol_life porta la soglia 7-14 voluta da Nicola', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`) + .get() as { curva: string; params: string }; + expect(row.curva).toBe('lin_dec'); + expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 }); + }); + + it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`) + .get() as { curva: string }; + expect(row.curva).toBe('x10_inv'); + }); + + it('un test disattivato non e piu attivo dopo la sua data', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run(); + const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id); + expect(attivi).not.toContain('q_sigarette'); + expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette'); + }); + + it('esisteTest distingue un test vero da uno inventato', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + expect(esisteTest(db, 'q_ore_sonno')).toBe(true); + expect(esisteTest(db, 'q_inventato')).toBe(false); + }); + + it('i pesi di ogni asse sommano a 1', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi + WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore` + ).all() as { contenitore: string; tot: number }[]; + expect(righe.length).toBe(7); + for (const r of righe) expect(r.tot).toBe(1); + }); + + it('i pesi sono legati alla versione del modello', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + seedPesi(db, 'v2.0'); + const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('il plank pesa su due assi: va visto, non nascosto', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')` + ).all() as { contenitore: string }[]; + expect(righe.map((r) => r.contenitore).sort()) + .toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/registro.test.ts` +Expected: FAIL — modulo `registro` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/registro.ts +import type Database from 'better-sqlite3'; + +export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; + +export type VoceRegistro = { + test_id: string; + etichetta: string; + unita?: string; + tipo_valore: 'num' | 'txt'; + curva?: Curva; + params?: unknown; + asse?: string; + sotto_dominio?: string; + range_min?: number; + range_max?: number; + attivo_da: string; + attivo_a?: string; +}; + +const DA = '2026-01-01'; + +// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html). +// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma +// dichiarata per ciascuna curva, non nell'array posizionale del prototipo. +const QUESTIONARIO: VoceRegistro[] = [ + { test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num', + curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + + { test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio', + tipo_valore: 'num', curva: 'x10_inv', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + + { test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali', + tipo_valore: 'num', curva: 'x10', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA }, + + { test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA }, + { test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA }, + // Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO), + // confermata il 21/08. Non e' un refuso per 0=100 -> 7=0. + { test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 7, worst: 14 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA }, + { test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, +]; + +export function seedRegistro(db: Database.Database): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO registro_test + (test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio, + range_min, range_max, attivo_da, attivo_a, note) + VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio, + @range_min, @range_max, @attivo_da, @attivo_a, @note)` + ); + const tx = db.transaction((voci: VoceRegistro[]) => { + for (const v of voci) { + ins.run({ + test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null, + tipo_valore: v.tipo_valore, curva: v.curva ?? null, + params: v.params === undefined ? null : JSON.stringify(v.params), + asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null, + range_min: v.range_min ?? null, range_max: v.range_max ?? null, + attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null, + }); + } + }); + tx(QUESTIONARIO); +} + +// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, +// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono +// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge. +const PESI_ASSE: Record> = { + 'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 }, + 'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 }, + 'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 }, + 'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 }, + 'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 }, + 'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 }, + 'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 }, +}; + +const PESI_MACRO: Record> = { + PERFORMANCE: { + 'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15, + 'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10, + 'Energia & Regolazione Stress': 0.05, + }, + ENERGY: { + 'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20, + 'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10, + }, + RECOVERY: { + 'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15, + 'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05, + }, +}; + +const PESI_FITNESS_AGE: Record = { + cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20, + forza_resto: 0.15, composizione: 0.10, stabilita: 0.05, +}; + +export function seedPesi(db: Database.Database, modelVersion: string): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso) + VALUES (?, ?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [asse, sd] of Object.entries(PESI_ASSE)) + for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p); + for (const [macro, assi] of Object.entries(PESI_MACRO)) + for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p); + for (const [el, p] of Object.entries(PESI_FITNESS_AGE)) + ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p); + }); + tx(); +} + +export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] { + const righe = db.prepare( + `SELECT * FROM registro_test + WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?) + ORDER BY test_id` + ).all(alla, alla) as Record[]; + return righe.map((r) => ({ + ...r, + params: r.params ? JSON.parse(r.params as string) : undefined, + })) as VoceRegistro[]; +} + +export function esisteTest(db: Database.Database, testId: string): boolean { + const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); + return r !== undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/registro.test.ts` +Expected: PASS, 8 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/registro.ts tests/longevity/registro.test.ts +git commit -m "longevity: registro dei test e pesi versionati come dato" +``` + +--- + +### Task 3: registraMisure — l'unico varco di scrittura + +**Files:** +- Create: `src/lib/longevity/misure.ts` +- Test: `tests/longevity/misure.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` (Task 1), `esisteTest`, `seedRegistro` (Task 2) +- Produces: + - `type Fonte = 'manuale'|'questionario'|'wellness_tower'|'vald'|'calibre'|'stress_index'` + - `type MisuraIn = { test_id: string; valore_num?: number; valore_txt?: string; unita?: string }` + - `apriSessione(db, s: { client_code: string; data: string; tipo: 'checkup'|'questionario'; eta_alla_data?: number; operatore?: string; quest_version?: string }): number` + - `registraMisure(db, sessioneId: number, fonte: Fonte, misure: MisuraIn[]): { scritte: number; fuoriRange: string[] }` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/misure.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('registrazione delle misure', () => { + it('scrive le misure di una sessione', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const esito = registraMisure(db, s, 'questionario', [ + { test_id: 'q_ore_sonno', valore_num: 7.5 }, + { test_id: 'q_riposato', valore_num: 8 }, + ]); + expect(esito.scritte).toBe(2); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number }; + expect(n.n).toBe(2); + }); + + it('rifiuta un test sconosciuto e non scrive niente del lotto', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' }); + expect(() => + registraMisure(db, s, 'manuale', [ + { test_id: 'q_ore_sonno', valore_num: 7 }, + { test_id: 'test_inventato', valore_num: 1 }, + ]) + ).toThrow(/test_inventato/); + const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; + expect(n.n).toBe(0); + }); + + it('un valore fuori dal range atteso si scrive e si marca', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' }); + const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); + expect(esito.fuoriRange).toEqual(['q_ore_sonno']); + const row = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { fuori_range: number }; + expect(row.fuori_range).toBe(1); + }); + + it('la sessione porta la versione del questionario', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string }; + expect(row.quest_version).toBe('v1.0'); + }); + + it('il client_code della misura viene dalla sessione, non da chi chiama', () => { + const db = dbPronto(); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run(); + const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' }); + registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]); + const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string }; + expect(row.client_code).toBe('ISL-0002'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/misure.test.ts` +Expected: FAIL — modulo `misure` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/misure.ts +import type Database from 'better-sqlite3'; +import { esisteTest } from './registro'; + +export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; + +export type MisuraIn = { + test_id: string; + valore_num?: number; + valore_txt?: string; + unita?: string; +}; + +export function apriSessione( + db: Database.Database, + s: { + client_code: string; data: string; tipo: 'checkup' | 'questionario'; + eta_alla_data?: number; operatore?: string; quest_version?: string; + } +): number { + const r = db.prepare( + `INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null); + return Number(r.lastInsertRowid); +} + +/** + * L'unico punto in cui si scrive nella tabella `misure`. + * Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una + * sessione scritta a meta'. Un valore fuori dal range atteso invece si scrive e si + * marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe. + */ +export function registraMisure( + db: Database.Database, + sessioneId: number, + fonte: Fonte, + misure: MisuraIn[] +): { scritte: number; fuoriRange: string[] } { + const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as + { client_code: string } | undefined; + if (!sess) throw new Error(`sessione ${sessioneId} inesistente`); + + for (const m of misure) { + if (!esisteTest(db, m.test_id)) { + throw new Error(`test_id non nel registro: ${m.test_id}`); + } + } + + const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`); + const ins = db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + const fuoriRange: string[] = []; + + const tx = db.transaction(() => { + for (const m of misure) { + const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null }; + let fuori = 0; + if (m.valore_num !== undefined) { + if ((r.range_min !== null && m.valore_num < r.range_min) || + (r.range_max !== null && m.valore_num > r.range_max)) { + fuori = 1; + fuoriRange.push(m.test_id); + } + } + ins.run(sessioneId, sess.client_code, m.test_id, + m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori); + } + }); + tx(); + + return { scritte: misure.length, fuoriRange }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/misure.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/misure.ts tests/longevity/misure.test.ts +git commit -m "longevity: registraMisure, unico varco con validazione sul registro" +``` + +--- + +### Task 4: Anagrafica — l'unico modulo che unisce identity e longevity + +**Files:** +- Create: `src/lib/longevity/anagrafica.ts` +- Test: `tests/longevity/anagrafica.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb`, `createIdentityDb` (Task 1) +- Produces: + - `creaCliente(identity, longevity, dati: { nome: string; cognome: string; sesso: 'M'|'F'; data_nascita?: string; email?: string; telefono?: string; user_id?: number }): string` — restituisce il `client_code` + - `codicePerUtente(identity, userId: number): string | null` + - `etaAllaData(dataNascita: string, alla: string): number` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/anagrafica.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; +import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica'; + +describe('anagrafica pseudonimizzata', () => { + it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' }); + + const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as + { nome: string; cognome: string }; + expect(inIdentity.nome).toBe('Mario'); + + const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string }; + expect(inLongevity.sesso).toBe('M'); + + // in longevity non deve esistere nessuna colonna che contenga il nome + const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all()); + expect(dump).not.toContain('Mario'); + expect(dump).not.toContain('Rossi'); + expect(dump).not.toContain('1988-03-04'); + }); + + it('assegna codici progressivi e non riusa i vecchi', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' }); + const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); + expect(a).toMatch(/^ISL-\d{4}$/); + expect(b).not.toBe(a); + }); + + it('ritrova il codice a partire dall utente del sito', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 }); + expect(codicePerUtente(id, 42)).toBe(code); + expect(codicePerUtente(id, 99)).toBeNull(); + }); + + it('calcola l eta alla data della sessione', () => { + expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38); + expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato + }); + + it('nessun altro modulo apre entrambe le connessioni', () => { + const dir = join(process.cwd(), 'src/lib/longevity'); + const colpevoli: string[] = []; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.ts') || f === 'anagrafica.ts' || f === 'db.ts') continue; + const src = readFileSync(join(dir, f), 'utf8'); + if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) colpevoli.push(f); + } + expect(colpevoli).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/anagrafica.test.ts` +Expected: FAIL — modulo `anagrafica` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/anagrafica.ts +// +// QUESTO e' l'unico modulo autorizzato ad aprire insieme identity e longevity. +// La separazione dei due database e' la garanzia che chi legge i dati clinici non +// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione +// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. +import type Database from 'better-sqlite3'; + +export function creaCliente( + identity: Database.Database, + longevity: Database.Database, + dati: { + nome: string; cognome: string; sesso: 'M' | 'F'; + data_nascita?: string; email?: string; telefono?: string; user_id?: number; + } +): string { + const ultimo = identity.prepare( + `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1; + const code = `ISL-${String(n).padStart(4, '0')}`; + + identity.prepare( + `INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(code, dati.user_id ?? null, dati.nome, dati.cognome, + dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); + + longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + + return code; +} + +export function codicePerUtente(identity: Database.Database, userId: number): string | null { + const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as + { client_code: string } | undefined; + return r?.client_code ?? null; +} + +/** L'eta' al momento della misura: e' cio' che serve al motore, e non e' un quasi-identificatore. */ +export function etaAllaData(dataNascita: string, alla: string): number { + const n = new Date(dataNascita); + const d = new Date(alla); + let eta = d.getFullYear() - n.getFullYear(); + const m = d.getMonth() - n.getMonth(); + if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; + return eta; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/anagrafica.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/anagrafica.ts tests/longevity/anagrafica.test.ts +git commit -m "longevity: anagrafica, unico punto di giunzione fra identita e clinica" +``` + +--- + +### Task 5: Ruoli `cliente` e `trainer` + +**Files:** +- Modify: `src/lib/auth.ts` (type `Role` alla riga 8, array `RULES` alla riga ~56, `canAccessAdminPath`) +- Test: `tests/longevity/ruoli.test.ts` + +**Interfaces:** +- Consumes: `canAccessAdminPath` esistente +- Produces: `Role` esteso con `'cliente' | 'trainer'` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/ruoli.test.ts +import { describe, it, expect } from 'vitest'; +import { canAccessAdminPath } from '../../src/lib/auth'; + +describe('accesso alle rotte longevity', () => { + it('il cliente entra nel proprio spazio', () => { + expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true); + }); + + it('il cliente NON entra nel gestionale', () => { + expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false); + }); + + it('il trainer entra in entrambi', () => { + expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true); + expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true); + }); + + it('il cliente non entra nelle altre piattaforme ne nel blog', () => { + expect(canAccessAdminPath('cliente', '/campus')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false); + }); + + it('il ruolo piattaforme non eredita longevity', () => { + expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false); + }); + + it('admin passa sempre', () => { + expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/ruoli.test.ts` +Expected: FAIL — `canAccessAdminPath('cliente', '/longevity/gestionale')` restituisce `true` (la regola finale lascia passare i loggati) + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/auth.ts`, riga 8, estendere il tipo: + +```ts +export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer'; +``` + +Nell'array `RULES`, aggiungere in fondo (prima della chiusura `];`): + +```ts + // Longevity: il cliente vede solo il proprio spazio, il gestionale e' del trainer. + [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], + [/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], +``` + +In `canAccessAdminPath`, prima del `return true` finale, aggiungere la stessa clausola che esiste per `piattaforme`: + +```ts + // cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano. + if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — i 6 test nuovi e **tutti quelli esistenti**, in particolare `tests/auth.test.ts` e `tests/auth-piattaforme.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth.ts tests/longevity/ruoli.test.ts +git commit -m "longevity: ruoli cliente e trainer con le rispettive rotte" +``` + +--- + +### Task 6: Salvataggio di una compilazione del questionario + +**Files:** +- Create: `src/lib/longevity/questionario.ts` +- Test: `tests/longevity/questionario.test.ts` + +**Interfaces:** +- Consumes: `apriSessione`, `registraMisure` (Task 3), `seedRegistro` (Task 2) +- Produces: + - `QUEST_VERSION: string` (`'v1.0'`) + - `CAMPI_LIBERI: string[]` (`['farmaci','problematiche_attuali','problematiche_pregresse','obiettivi']`) + - `salvaCompilazione(db, input: { client_code: string; data: string; eta?: number; risposte: Record; liberi?: Record }): number` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/questionario.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('compilazione del questionario', () => { + it('salva le risposte punteggiate come misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 }, + }); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`) + .get(s) as { n: number }; + expect(n.n).toBe(3); + }); + + it('marchia la compilazione con la versione del questionario', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as + { tipo: string; quest_version: string }; + expect(row.tipo).toBe('questionario'); + expect(row.quest_version).toBe(QUEST_VERSION); + }); + + it('i campi liberi vanno in profilo_note, non fra le misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6 }, + liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' }, + }); + const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`) + .all(s) as { campo_id: string; testo: string }[]; + expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']); + const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[]; + expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']); + }); + + it('ogni compilazione e un record nuovo, non un aggiornamento', () => { + const db = dbPronto(); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } }); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('una risposta con id sconosciuto fa fallire la compilazione intera', () => { + const db = dbPronto(); + expect(() => + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6, q_domanda_nuova: 3 }, + }) + ).toThrow(/q_domanda_nuova/); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; + expect(n.n).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/questionario.test.ts` +Expected: FAIL — modulo `questionario` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/questionario.ts +import type Database from 'better-sqlite3'; +import { apriSessione, registraMisure } from './misure'; +import { esisteTest } from './registro'; + +/** Versione del set di domande. Si incrementa quando cambia una domanda. */ +export const QUEST_VERSION = 'v1.0'; + +/** + * Campi testuali che si salvano ma non entrano MAI in una formula + * (indicazione esplicita del cliente, punto 7 della specifica). + */ +export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi']; + +export function salvaCompilazione( + db: Database.Database, + input: { + client_code: string; data: string; eta?: number; + risposte: Record; + liberi?: Record; + } +): number { + // Validare PRIMA di aprire la sessione: altrimenti un id sbagliato lascia + // in giro una sessione vuota. + for (const id of Object.keys(input.risposte)) { + if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`); + } + + const sessioneId = apriSessione(db, { + client_code: input.client_code, + data: input.data, + tipo: 'questionario', + eta_alla_data: input.eta, + quest_version: QUEST_VERSION, + }); + + registraMisure( + db, sessioneId, 'questionario', + Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num })) + ); + + if (input.liberi) { + const ins = db.prepare( + `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [campo, testo] of Object.entries(input.liberi!)) { + if (testo.trim() === '') continue; + ins.run(input.client_code, sessioneId, campo, testo); + } + }); + tx(); + } + + return sessioneId; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/questionario.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/questionario.ts tests/longevity/questionario.test.ts +git commit -m "longevity: salvataggio compilazione questionario, versionata" +``` + +--- + +### Task 7: Export pseudonimizzato + +**Files:** +- Create: `src/lib/longevity/export.ts` +- Test: `tests/longevity/export.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` (Task 1) +- Produces: `esportaMisure(db): { intestazioni: string[]; righe: (string|number|null)[][] }`, `esportaCsv(db): string` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/export.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export'; + +function dbConDati() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', eta: 35, + risposte: { q_ore_sonno: 7, q_riposato: 8 }, + liberi: { farmaci: 'nessuno' }, + }); + return db; +} + +describe('export per le statistiche', () => { + it('esporta una riga per misura con codice, non con nome', () => { + const db = dbConDati(); + const out = esportaMisure(db); + expect(out.intestazioni).toEqual( + ['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range'] + ); + expect(out.righe.length).toBe(2); + expect(out.righe[0][0]).toBe('ISL-0001'); + }); + + it('l export non contiene campi identificativi', () => { + const db = dbConDati(); + const testo = JSON.stringify(esportaMisure(db)); + for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) { + expect(testo).not.toContain(vietato); + } + }); + + it('i campi liberi restano fuori dall export statistico', () => { + const db = dbConDati(); + expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno'); + }); + + it('produce un CSV con intestazione e una riga per misura', () => { + const db = dbConDati(); + const righe = esportaCsv(db).trim().split('\n'); + expect(righe.length).toBe(3); // intestazione + 2 misure + expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/export.test.ts` +Expected: FAIL — modulo `export` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/export.ts +// +// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene +// nomi: non serve nessuna funzione di anonimizzazione, e non c'e' niente da ricordarsi. +// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi. +import type Database from 'better-sqlite3'; + +const INTESTAZIONI = [ + 'client_code', 'sesso', 'eta', 'data', 'tipo_sessione', + 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range', +]; + +export function esportaMisure(db: Database.Database): { + intestazioni: string[]; + righe: (string | number | null)[][]; +} { + const righe = db.prepare( + `SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version, + m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + JOIN soggetti sg ON sg.client_code = m.client_code + ORDER BY s.data, m.id` + ).raw().all() as (string | number | null)[][]; + + return { intestazioni: INTESTAZIONI, righe }; +} + +export function esportaCsv(db: Database.Database): string { + const { intestazioni, righe } = esportaMisure(db); + const cella = (v: string | number | null) => { + if (v === null) return ''; + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — tutta la suite, vecchia e nuova + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/export.ts tests/longevity/export.test.ts +git commit -m "longevity: export pseudonimizzato per costruzione" +``` + +--- + +## Cosa esiste alla fine di questo piano + +Una libreria testata che sa: aprire i due database, tenere il registro dei test e i pesi versionati, registrare misure da qualunque sorgente validandole, creare un cliente tenendo separata l'identità dalla clinica, salvare una compilazione di questionario con la sua versione, ed esportare i dati già pseudonimizzati. + +**Non esiste ancora**: nessuna pagina, nessun punteggio calcolato. Sono i due piani successivi — prima il motore (porting delle curve dal Python con il verificatore), poi le pagine. + +## Nota per chi eseguirà il piano successivo + +Il registro creato al Task 2 contiene `curva` e `params` per tutti e 20 i campi del questionario, nella forma dichiarata per tipo di curva (`bell`, `lin_dec`, `decstep`, `incstep`, `x10`, `x10_inv`). Il motore leggerà di lì, non da costanti nel codice. + +⚠️ `x10_inv` è **la quinta curva che manca al motore Python**: serve a `q_calo_pomeridiano` e nel prototipo è `scale10_inv`, cioè `(10 - valore) × 10`. Il porting deve implementarla, non cercarla nel file del cliente.