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