Files
InsanityLab-web/docs/plans/2026-08-21-longevity-strato-dati.md
Adriano 534833c767 docs: piano di implementazione dello strato dati longevity
Sette task in TDD: schema dei due database, registro test e pesi versionati,
registraMisure come unico varco, anagrafica che tiene separata identita' e
clinica, ruoli cliente/trainer, salvataggio del questionario, export.

Alla fine esiste una libreria testata e nessuna pagina: il motore di calcolo
e le schermate sono i due piani successivi.
2026-08-21 19:05:07 +02:00

53 KiB
Raw Permalink Blame History

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

// 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: 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

// 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 }): number
    • registraMisure(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 il client_code
    • codicePerUtente(identity, userId: number): string | null
    • etaAllaData(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 (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

// 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.