longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo

Una sessione per punto (solo quelle con righe in score), i punteggi congelati
da leggiScore, e cambioVersione quando model_version o quest_version (non
nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con
quest_version NULL, non innesca né nasconde un cambio.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:02:50 +00:00
parent af8afebba8
commit 4cd911098d
2 changed files with 277 additions and 0 deletions
@@ -0,0 +1,129 @@
//
// L'andamento nel tempo di un cliente: una riga per sessione, i punteggi congelati che
// quella sessione ha davvero, e dove segnare che qualcosa nel modo di calcolare è cambiato.
//
// Stessa regola non negoziabile di `refertoDi` (vista.ts): i valori vengono da `leggiScore`
// (punteggi congelati in `score`), MAI da un ricalcolo — un peso cambiato dopo il fatto non
// deve alterare un punto già emesso, tanto quanto non altera un referto già emesso. Qui in
// più c'è la STORIA di più sessioni, quindi la domanda "quale sessione entra nel grafico" ha
// una risposta precisa: solo quelle che hanno almeno una riga in `score`. Una sessione aperta
// (`apriSessione`) ma mai calcolata — capita quando il consenso non è dato, o quando qualcosa
// si è fermato a metà prima del congelamento — non ha punteggi da mostrare, e mostrarla come
// un punto vuoto sarebbe un buco silenzioso nel grafico, non un dato mancante dichiarato.
import type Database from 'better-sqlite3';
import { leggiScore } from './motore';
/**
* Un punto della progressione: una sessione con i suoi punteggi congelati.
* `questVersion` è `null` per un check-up (le sessioni `checkup` non passano dal
* questionario, quindi `sessioni.quest_version` resta NULL — vedi db.ts).
* `assi`/`macro` sono `Record<nome, number|null>`: la chiave è il nome del contenitore
* (es. 'Forza & Struttura', 'PERFORMANCE') così com'è scritto in `score.elemento`, il
* valore è `null` quando lo stato congelato era 'insufficiente' — mai un numero inventato.
* `cambioVersione` è il testo del cambio rispetto al PUNTO PRECEDENTE (non rispetto
* all'ultimo `quest_version` in assoluto, tranne per il caso descritto sotto), `null` sul
* primo punto per costruzione (non c'è un "prima").
*/
export type PuntoProgressione = {
sessioneId: number;
data: string;
tipo: 'checkup' | 'questionario';
questVersion: string | null;
modelVersion: string;
assi: Record<string, number | null>;
macro: Record<string, number | null>;
fitnessAge: number | null;
cambioVersione: string | null;
};
/**
* Le sessioni di un cliente, in ordine cronologico, che hanno almeno una riga in `score`
* — le uniche che `progressioneDi` può mostrare. Stesso criterio d'ordine di
* `misureValideAlla`/`leggiScore` (data ASC, poi id ASC: qui l'ordine è cronologico in
* avanti, l'opposto di quelle due funzioni che cercano "il più recente", ma il criterio
* di spareggio a parità di data è lo stesso — l'id di sessione, non l'ordine con cui
* SQLite restituisce le righe).
*/
function sessioniConScore(db: Database.Database, clientCode: string) {
return db.prepare(
`SELECT id AS sessioneId, data, tipo
FROM sessioni
WHERE client_code = ?
AND EXISTS (SELECT 1 FROM score WHERE score.sessione_id = sessioni.id)
ORDER BY data ASC, id ASC`
).all(clientCode) as { sessioneId: number; data: string; tipo: 'checkup' | 'questionario' }[];
}
/**
* `quest_version`/`model_version` della sessione, letti da `score` (non da `sessioni`:
* `sessioni.quest_version` è quello che il cliente ha compilato, `score.quest_version` è
* quello con cui l'ultimo calcolo congelato ha davvero girato — nel funzionamento normale
* coincidono sempre, ma è `score` la fonte che questo modulo legge per coerenza con
* `leggiScore`, non un'altra tabella). Una sessione può avere più calcoli nel tempo (vedi
* il commento di `leggiScore` su `salvaScore` senza vincolo di unicità): si prende la riga
* più recente per id, lo stesso criterio con cui `leggiScore` sceglie il valore di ciascun
* elemento.
*/
function versioniDi(db: Database.Database, sessioneId: number): { questVersion: string | null; modelVersion: string } {
const riga = db.prepare(
`SELECT quest_version, model_version FROM score WHERE sessione_id = ? ORDER BY id DESC LIMIT 1`
).get(sessioneId) as { quest_version: string | null; model_version: string } | undefined;
// Non dovrebbe mai mancare: sessioniConScore filtra con lo stesso EXISTS. Se manca è un
// bug di questa funzione (query diversa da quella del filtro), non un caso di prodotto —
// meglio un errore esplicito che un punto silenziosamente senza versione.
if (!riga) throw new Error(`sessione ${sessioneId} senza righe in score, nonostante il filtro`);
return { questVersion: riga.quest_version, modelVersion: riga.model_version };
}
/**
* L'andamento nel tempo di un cliente: un punto per sessione calcolata, in ordine
* cronologico. `cambioVersione` marca dove il modo di calcolare è cambiato:
* - il `modelVersion` è diverso da quello del punto precedente (un cambio di curve o pesi
* cambia il significato del numero, non solo il suo valore);
* - oppure il `questVersion` (quando non è `null`) è diverso dall'ultimo `questVersion`
* NON NULLO visto prima — non dal punto immediatamente precedente: un check-up in mezzo
* a due questionari ha `questVersion` null e non deve né innescare un cambio (non ha una
* versione da confrontare) né "nascondere" un cambio vero fra i due questionari che lo
* circondano (il confronto salta il check-up, non si ferma su di lui).
*/
export function progressioneDi(db: Database.Database, clientCode: string): PuntoProgressione[] {
const sessioni = sessioniConScore(db, clientCode);
const punti: PuntoProgressione[] = [];
let modelVersionPrecedente: string | null = null;
let questVersionNonNullaPrecedente: string | null = null;
for (const s of sessioni) {
const punteggi = leggiScore(db, s.sessioneId);
const { questVersion, modelVersion } = versioniDi(db, s.sessioneId);
const assi: Record<string, number | null> = {};
for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null;
const macro: Record<string, number | null> = {};
for (const [nome, p] of Object.entries(punteggi.macro)) macro[nome] = p.stato === 'ok' ? p.valore : null;
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
const cambi: string[] = [];
if (punti.length > 0) {
if (modelVersionPrecedente !== null && modelVersion !== modelVersionPrecedente) {
cambi.push(`modello ${modelVersionPrecedente} → ${modelVersion}`);
}
if (questVersion !== null && questVersionNonNullaPrecedente !== null && questVersion !== questVersionNonNullaPrecedente) {
cambi.push(`questionario ${questVersionNonNullaPrecedente} → ${questVersion}`);
}
}
punti.push({
sessioneId: s.sessioneId, data: s.data, tipo: s.tipo,
questVersion, modelVersion, assi, macro, fitnessAge,
cambioVersione: cambi.length > 0 ? cambi.join('; ') : null,
});
modelVersionPrecedente = modelVersion;
if (questVersion !== null) questVersionNonNullaPrecedente = questVersion;
}
return punti;
}
@@ -0,0 +1,148 @@
// tests/longevity/progressione.test.ts
// L'andamento nel tempo: progressioneDi deve leggere SOLO ciò che è congelato in `score`
// (mai un ricalcolo, stessa regola di refertoDi) e marcare correttamente dove il modo di
// calcolare è cambiato (quest_version/model_version), senza farsi confondere da un
// check-up in mezzo a due questionari (quest_version NULL per un check-up).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaDalForm } from '../../src/lib/longevity/vista';
import { apriSessione } from '../../src/lib/longevity/misure';
import { calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
import { progressioneDi } from '../../src/lib/longevity/progressione';
const RISPOSTE_COMPLETE = {
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
};
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
/** Una sessione di check-up, calcolata e congelata come farebbe l'operatore in sala:
* nessuna domanda del questionario, quindi `quest_version` resta NULL (vedi db.ts). */
function aggiungiCheckup(db: ReturnType<typeof dbPronto>, data: string) {
const sessioneId = apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 35 });
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
return sessioneId;
}
/** Un questionario compilato per intero, via salvaDalForm — la stessa funzione che
* chiama l'endpoint reale (vedi salvataggio-e2e.test.ts): calcola e congela da sola. */
function aggiungiQuestionario(db: ReturnType<typeof dbPronto>, data: string) {
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data, eta: 35,
risposte: RISPOSTE_COMPLETE, consensoSanitario: 'Si', liberi: {},
});
if (!esito.ok) throw new Error(esito.errore);
return esito.sessioneId;
}
describe('progressioneDi: due questionari e un check-up', () => {
it('produce tre punti, in ordine cronologico', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const s2 = aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(3);
expect(punti.map((p) => p.sessioneId)).toEqual([s1, s2, s3]);
expect(punti.map((p) => p.data)).toEqual(['2026-01-10', '2026-02-10', '2026-03-10']);
expect(punti[0].tipo).toBe('questionario');
expect(punti[1].tipo).toBe('checkup');
});
it('i valori coincidono con leggiScore, mai un ricalcolo', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
const punteggi = leggiScore(db, s1);
for (const [nome, p] of Object.entries(punteggi.assi)) {
expect(punti[0].assi[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
for (const [nome, p] of Object.entries(punteggi.macro)) {
expect(punti[0].macro[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
expect(punti[0].fitnessAge).toBe(punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null);
});
it('un asse senza dati sufficienti resta null, mai un numero inventato', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
// 'Forza & Struttura' non ha nessuna misura fisica in questa sessione (solo questionario).
expect(punti[0].assi['Forza & Struttura']).toBeNull();
});
it('quest_version NULL per il check-up', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[1].questVersion).toBeNull();
});
it('nessun cambio di versione: cambioVersione resta null su ogni punto dopo il primo', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[0].cambioVersione).toBeNull(); // primo punto: mai un "prima" da confrontare
expect(punti[1].cambioVersione).toBeNull(); // il check-up in mezzo
expect(punti[2].cambioVersione).toBeNull(); // il secondo questionario, stessa versione
});
it('un check-up fra due questionari alla stessa versione non produce un cambio', () => {
// Stesso scenario del test sopra, isolato: qui si verifica in particolare che il
// check-up (quest_version NULL) non "nasconda" né inventi un cambio fra i due
// questionari v1.0 che lo circondano.
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
const puntoCheckup = punti.find((p) => p.tipo === 'checkup')!;
const puntoSecondoQuest = punti.find((p) => p.sessioneId === s3)!;
expect(puntoCheckup.cambioVersione).toBeNull();
expect(puntoSecondoQuest.cambioVersione).toBeNull();
});
it('un cambio di quest_version produce il testo del cambio', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
// Nessuna seconda versione delle domande esiste davvero oggi (QUEST_VERSION è una sola
// costante): si simula il cambio scrivendo direttamente la riga congelata, come farebbe
// un domani un vero v1.1 del questionario.
db.prepare(`UPDATE score SET quest_version = 'v1.1' WHERE sessione_id = ?`).run(s3);
const punti = progressioneDi(db, 'ISL-0001');
const punto = punti.find((p) => p.sessioneId === s3)!;
expect(punto.cambioVersione).toMatch(/questionario v1\.0 → v1\.1/);
});
it('una sessione senza righe in score resta esclusa dalla progressione', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
// Sessione aperta ma mai calcolata: nessuna riga in score (consenso non dato, o un
// calcolo mai lanciato — non un caso teorico, vedi il commento in salvaDalForm).
apriSessione(db, { client_code: 'ISL-0001', data: '2026-02-01', tipo: 'checkup', eta_alla_data: 35 });
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(1);
});
});