diff --git a/apps/platforms/src/components/longevity/Progressione.tsx b/apps/platforms/src/components/longevity/Progressione.tsx new file mode 100644 index 0000000..3c9922f --- /dev/null +++ b/apps/platforms/src/components/longevity/Progressione.tsx @@ -0,0 +1,139 @@ +// L'andamento nel tempo: una linea per macro-score, isola React come Radar (recharts ha +// bisogno del DOM). Stesso linguaggio visivo del radar, non uno nuovo: +// - `connectNulls={false}` su ogni linea: un valore mancante (sessione insufficiente per +// quel macro-score) è un BUCO nella linea, non un segmento che la attraversa come se il +// dato ci fosse. Un buco onesto vale più di una linea continua che mente. +// - le tre serie (PERFORMANCE/ENERGY/RECOVERY) si distinguono per tratteggio e spessore, +// MAI per colore: qui non c'è semaforo, come nel radar (vedi longevity.css). Un colore +// diverso per macro-score si leggerebbe come un giudizio ("questo è il buono, quello è il +// cattivo"), che il servizio non fa. +// - la riga verticale tratteggiata su un cambio di versione usa lo stesso colore del +// perimetro "non lo sappiamo ancora" del radar (TRATTEGGIO): un confine dichiarato, non +// un errore. +import { + ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ReferenceLine, +} from 'recharts'; +import type { PuntoProgressione } from '../../lib/longevity/progressione'; +import { formattaNumero } from '../../lib/longevity/formato'; + +interface Props { + punti: PuntoProgressione[]; +} + +// Stessi colori di Radar.tsx (esadecimali, non var(--...): recharts vuole attributi SVG, +// non proprietà CSS). Non sono colori nuovi, sono una copia — vedi il commento in Radar.tsx. +const ACCENTO = '#9c8b70'; +const TRATTEGGIO = '#d8d2c8'; +const LINEA = '#e4dfd7'; +const TESTO_CHIARO = '#8a8a8a'; + +/** 'PERFORMANCE' -> 'Performance': stessa trasformazione di MacroScore.astro, non un + * dizionario duplicato da tenere allineato — il NOME resta quello di prodotto (formato.ts), + * solo la resa grafica cambia maiuscola/minuscola. */ +const etichetta = (nome: string) => nome.charAt(0) + nome.slice(1).toLowerCase(); + +// Tratteggio e spessore che distinguono le tre serie senza un colore nuovo per ciascuna: +// PERFORMANCE piena e più spessa (il macro-score principale), le altre due via via più +// leggere. L'ordine qui non impone un ordine di lettura: è solo il fallback per un +// macro-score che un domani non fosse fra questi tre (mai successo finora, ma +// `progressioneDi` non hardcoda i nomi — vedi il commento lì — quindi nemmeno qui). +const STILI_SERIE: { dash?: string; width: number }[] = [ + { dash: undefined, width: 2.5 }, + { dash: '6 3', width: 2 }, + { dash: '2 2', width: 1.5 }, +]; + +export default function Progressione({ punti }: Props) { + const nomiMacro = Array.from(new Set(punti.flatMap((p) => Object.keys(p.macro)))); + const nomiAssi = Array.from(new Set(punti.flatMap((p) => Object.keys(p.assi)))); + + const dati = punti.map((p) => { + const riga: Record = { data: p.data }; + for (const nome of nomiMacro) riga[nome] = p.macro[nome]; + return riga; + }); + + const cambi = punti.filter((p) => p.cambioVersione !== null); + + return ( +
+

+ Una linea per macro-score (Performance, Energy, Recovery): dove manca un tratto, quella + sessione non aveva dati sufficienti per quel numero — non è uno zero, è un buco. La + riga verticale tratteggiata segna dove è cambiato il questionario o il modo di + calcolare: i numeri prima e dopo non si confrontano allo stesso modo. +

+ +
+ + + + + + + + {cambi.map((p) => ( + + ))} + {nomiMacro.map((nome, i) => { + const stile = STILI_SERIE[i % STILI_SERIE.length]; + return ( + + ); + })} + + +
+ + {/* La tabella per asse: la stessa granularità del radar (sette assi), qui nel tempo + invece che in un solo istante. «—» dove il dato non basta, mai uno zero. */} +
+ + + + + {punti.map((p) => ( + + ))} + + + + {nomiAssi.map((nome) => ( + + + {punti.map((p) => { + const v = p.assi[nome]; + return ( + + ); + })} + + ))} + +
{p.data}
{nome} + {v !== null && v !== undefined ? formattaNumero(v) : '—'} +
+
+
+ ); +} diff --git a/apps/platforms/src/lib/longevity/progressione.ts b/apps/platforms/src/lib/longevity/progressione.ts new file mode 100644 index 0000000..dc10d09 --- /dev/null +++ b/apps/platforms/src/lib/longevity/progressione.ts @@ -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`: 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; + macro: Record; + 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 = {}; + for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null; + + const macro: Record = {}; + 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; +} diff --git a/apps/platforms/src/pages/longevity/io.astro b/apps/platforms/src/pages/longevity/io.astro index 7adb7d4..e03e8c0 100644 --- a/apps/platforms/src/pages/longevity/io.astro +++ b/apps/platforms/src/pages/longevity/io.astro @@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;

InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni raccontano come cambi, non solo come stai oggi. - Compila di nuovo il questionario. + Guarda il tuo andamento nel tempo o + compila di nuovo il questionario.

diff --git a/apps/platforms/src/pages/longevity/progressione.astro b/apps/platforms/src/pages/longevity/progressione.astro new file mode 100644 index 0000000..b95d4b2 --- /dev/null +++ b/apps/platforms/src/pages/longevity/progressione.astro @@ -0,0 +1,47 @@ +--- +// L'andamento nel tempo del cliente: una sessione per punto, i punteggi congelati che +// quella sessione ha davvero. Stesso modello di io.astro: la pagina non interroga il +// database da sola (c'è un test che lo verifica, referto.test.ts, esteso a tutte le pagine +// sotto src/pages/longevity/), il codice cliente arriva da codicePerUtenteLoggato +// (anagrafica.ts, l'unico modulo autorizzato a toccare identity), i punti da progressioneDi +// (progressione.ts, che legge SOLO score — mai un ricalcolo). +import Longevity from '../../layouts/Longevity.astro'; +import Progressione from '../../components/longevity/Progressione'; +import { getLongevityDb } from '../../lib/longevity/db'; +import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica'; +import { progressioneDi } from '../../lib/longevity/progressione'; + +export const prerender = false; + +const user = Astro.locals.user!; +const codice = codicePerUtenteLoggato(user.id); +const db = getLongevityDb(); +const punti = codice ? progressioneDi(db, codice) : []; +--- + + {!codice && ( +
+

Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.

+
+ )} + + {codice && punti.length < 2 && ( +
+

Serve almeno un secondo check-up o questionario per vedere un andamento.

+

Torna al tuo referto.

+
+ )} + + {codice && punti.length >= 2 && ( +
+
+

Il tuo andamento

+ +
+ +
+

Torna al tuo referto.

+
+
+ )} +
diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index 6375e52..d3e4470 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -100,6 +100,22 @@ .lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } .lg-radar__lista li { margin-bottom: 2px; } +/* --- l'andamento nel tempo: il grafico vive nell'isola React (Progressione.tsx), + qui solo il testo introduttivo e la tabella per asse sotto al grafico. --- */ +.lg-progressione { display: flex; flex-direction: column; gap: 20px; } +.lg-progressione__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); } +.lg-progressione__grafico { width: 100%; } +.lg-progressione__tabella { overflow-x: auto; } +.lg-progressione__table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.lg-progressione__table th, .lg-progressione__table td { + padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--lg-line); + white-space: nowrap; +} +.lg-progressione__table th:first-child, .lg-progressione__table td:first-child { + text-align: left; color: var(--c-heading); font-weight: 500; +} +.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; } + /* --- le carte dei macro-score --- */ .lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; } .lg-macro__nota { margin: 8px 0 0; font-size: .78rem; } diff --git a/apps/platforms/tests/longevity/pagine.test.ts b/apps/platforms/tests/longevity/pagine.test.ts index badca69..c558e98 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -93,6 +93,21 @@ describe('il guscio della piattaforma', () => { const src = readFileSync(p, 'utf8'); expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); }); + + it('la pagina dell andamento non interroga il database e usa codicePerUtenteLoggato', () => { + // Rete secondaria alla stessa prova generica di referto.test.ts ("nessuna pagina + // interroga il database da sola", che scandisce TUTTE le .astro sotto longevity/): + // qui si controlla in piu' che il codice cliente arrivi dalla via autorizzata, non da + // un'altra query o da un parametro della richiesta. + const p = join(process.cwd(), 'src/pages/longevity/progressione.astro'); + expect(existsSync(p)).toBe(true); + const src = readFileSync(p, 'utf8'); + expect(src).not.toMatch(/\.prepare\(/); + expect(src).toMatch(/codicePerUtenteLoggato/); + // Stessa rete secondaria di anagrafica.test.ts: nessuna giunzione identity/longevity + // in un file fuori dai due moduli autorizzati. + expect(src).not.toContain('Identity'); + }); }); describe('il gestionale del trainer (Unità 2b)', () => { diff --git a/apps/platforms/tests/longevity/progressione.test.ts b/apps/platforms/tests/longevity/progressione.test.ts new file mode 100644 index 0000000..cd9ad76 --- /dev/null +++ b/apps/platforms/tests/longevity/progressione.test.ts @@ -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, 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, 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); + }); +});