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
53 KiB
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.mainnon 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: funzionecreateXDb(path?),journal_mode = WAL,foreign_keys = ON, schema idempotente eseguito all'apertura. - Test in
tests/longevity/, eseguiti connpm 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_versioniniziale:v1.0.quest_versioniniziale: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
// 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
// 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
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:
createLongevityDbda 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): voidseedPesi(db: Database.Database, modelVersion: string): voidtestAttivi(db: Database.Database, alla: string): VoceRegistro[]esisteTest(db: Database.Database, testId: string): boolean
-
Step 1: Write the failing test
// 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
// 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<string, Record<string, number>> = {
'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<string, Record<string, number>> = {
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<string, number> = {
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<string, unknown>[];
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
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 }): numberregistraMisure(db, sessioneId: number, fonte: Fonte, misure: MisuraIn[]): { scritte: number; fuoriRange: string[] }
-
Step 1: Write the failing test
// 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
// 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
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 ilclient_codecodicePerUtente(identity, userId: number): string | nulletaAllaData(dataNascita: string, alla: string): number
-
Step 1: Write the failing test
// 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
// 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
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(typeRolealla riga 8, arrayRULESalla riga ~56,canAccessAdminPath) - Test:
tests/longevity/ruoli.test.ts
Interfaces:
-
Consumes:
canAccessAdminPathesistente -
Produces:
Roleesteso con'cliente' | 'trainer' -
Step 1: Write the failing test
// 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:
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer';
Nell'array RULES, aggiungere in fondo (prima della chiusura ];):
// 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:
// 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
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<string, number>; liberi?: Record<string, string> }): number
-
Step 1: Write the failing test
// 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
// 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<string, number>;
liberi?: Record<string, string>;
}
): 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
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
// 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
// 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
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.