From 48910d84baed9728960039aed27636cebf679b66 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:38:01 +0000 Subject: [PATCH 01/18] longevity: registro dei test fisici e sottoDominiFisici (porting dell'oracolo) Aggiunge le 24 voci f_* al registro (misure grezze del check-up, senza curva/sotto_dominio: il calcolo non passa da applicaCurva) e motore/fisici.ts (sottoDominiFisici), che chiama le funzioni gia' verificate di test-fisici.ts con eta e sesso per produrre i punteggi di sotto-dominio. Un ingresso mancante lascia il sotto-dominio assente, mai un ripiego. Non ancora collegato a calcolaSessione (prossimo commit). Refs #1 Co-Authored-By: Claude Sonnet 5 --- .../src/lib/longevity/motore/fisici.ts | 139 ++++++++++++++++++ .../src/lib/longevity/motore/test-fisici.ts | 21 ++- apps/platforms/src/lib/longevity/registro.ts | 66 +++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 apps/platforms/src/lib/longevity/motore/fisici.ts diff --git a/apps/platforms/src/lib/longevity/motore/fisici.ts b/apps/platforms/src/lib/longevity/motore/fisici.ts new file mode 100644 index 0000000..f610174 --- /dev/null +++ b/apps/platforms/src/lib/longevity/motore/fisici.ts @@ -0,0 +1,139 @@ +/** + * Dalle misure grezze del check-up (registrate coi test_id `f_*` di `registro.ts`) ai + * punteggi 0-100 di sotto-dominio, con lo stesso raggruppamento che l'oracolo fa in + * `demo()` (`tests/longevity/riferimento/isl_scoring_engine.py`): ogni sotto-dominio è + * una chiamata a una funzione di `test-fisici.ts`, con gli argomenti giusti presi dalla + * mappa delle misure. + * + * A differenza del questionario, queste voci NON passano da `applicaCurva`: le sette curve + * generiche (`bell`, `lin_dec`, ecc.) prendono un solo valore, e quasi tutte le funzioni qui + * sotto ne vogliono almeno due (il valore grezzo più età e/o sesso, a volte anche il peso + * corporeo di un'altra misura). Estendere `applicaCurva` per farci passare età e sesso è + * lavoro del piano degli import (vedi l'intestazione di `test-fisici.ts`), non di questa + * revisione: qui il calcolo resta una funzione dedicata, non una curva dichiarata nel dato. + * + * REGOLA UNICA, la stessa di tutto il motore: un sotto-dominio a cui manca anche un solo + * ingresso (compresa l'età, dove la funzione la richiede) NON compare nel risultato — mai + * un valore di ripiego. È lo stesso principio di `applicaCurva`/`paramsValidi`, qui + * applicato a funzioni con più argomenti invece che a una curva coi parametri incompleti. + * + * FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore + * GLI che non esiste ancora), milfit, il 2km walk, il sit-to-stand, la trazione isometrica. + * L'oracolo li usa nella demo con segnaposto o non li usa affatto nei pesi correnti; + * restano fuori finché non arriva un test_id e una decisione su come raccoglierli. + */ + +import type { Sesso } from './test-fisici'; +import { clamp } from './curve'; +import { + scoreHandgrip, scorePushup, scoreSollevamentoSuPeso, TIERS_ROW_M, TIERS_ROW_F, + TIERS_SQUAT_M, TIERS_SQUAT_F, scorePlank, scoreGrassoPercento, scoreMuscoloPercento, + scoreWhr, scoreVo2max, vo2maxDaMutt, vo2maxDaStepTest, scoreSpo2, scorePressione, + scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla, +} from './test-fisici'; + +export type { Sesso }; + +/** + * `m` è la mappa test_id -> valore grezzo più recente e valido (vedi `misureValideAlla` in + * `motore/index.ts`), `eta` l'età alla data della sessione (nullable come in tutto il + * motore: senza età gli score che la richiedono restano assenti, mai calcolati a zero). + * + * Ordine dei blocchi: uguale a `demo()` nell'oracolo (forza, composizione, cardio, + * recupero, stabilità), così un confronto riga per riga fra questo file e quello resta + * diretto. + */ +export function sottoDominiFisici( + m: Map, eta: number | null, sesso: Sesso +): Record { + const out: Record = {}; + + // --- Forza & Struttura ---------------------------------------------------- + const handgrip = m.get('f_handgrip_kg'); + if (handgrip !== undefined && eta !== null) out.handgrip = scoreHandgrip(handgrip, eta, sesso); + + const pushup = m.get('f_pushup_rip'); + if (pushup !== undefined && eta !== null) out.spinta = scorePushup(pushup, eta, sesso); + + const pesoKg = m.get('f_peso_kg'); + const rowCarico = m.get('f_row_carico_kg'); + const rowRip = m.get('f_row_rip'); + if (rowCarico !== undefined && pesoKg !== undefined && rowRip !== undefined) { + out.trazione = scoreSollevamentoSuPeso(rowCarico, pesoKg, rowRip, TIERS_ROW_M, TIERS_ROW_F, sesso); + } + + const squatCarico = m.get('f_squat_carico_kg'); + const squatRip = m.get('f_squat_rip'); + if (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined) { + out.arti_inferiori = scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso); + } + + // Il plank pesa su DUE assi (core in Forza & Struttura, plank in Stabilità & Mobilità): + // confermato da Nicola il 22/08, vedi il commento su PESI_ASSE in registro.ts. Stessa + // misura, stessa funzione, due chiavi nel risultato — non è una duplicazione per errore. + const plank = m.get('f_plank_sec'); + if (plank !== undefined) { + const punteggio = scorePlank(plank, sesso); + out.core = punteggio; + out.plank = punteggio; + } + + // --- Composizione Corporea ------------------------------------------------- + const grasso = m.get('f_grasso_pct'); + if (grasso !== undefined) out.grasso = scoreGrassoPercento(grasso, sesso); + + const muscolo = m.get('f_muscolo_pct'); + if (muscolo !== undefined) out.muscolo = scoreMuscoloPercento(muscolo, sesso); + + const whr = m.get('f_whr'); + if (whr !== undefined) out.whr = scoreWhr(whr, sesso); + + // --- Cardio-Respiratorio ----------------------------------------------------- + // VO2max: diretto se misurato, altrimenti stimato da MUTT, altrimenti dallo step test — + // il primo presente vince, come nella cascata di fonti dell'oracolo (che nella demo usa + // solo MUTT, ma le tre vie coesistono nel resto del file Python). + const vo2Diretto = m.get('f_vo2max'); + const muttVam = m.get('f_mutt_vam'); + const stepHr = m.get('f_step_hr_recovery'); + let vo2max: number | undefined; + if (vo2Diretto !== undefined) vo2max = vo2Diretto; + else if (muttVam !== undefined) vo2max = vo2maxDaMutt(muttVam); + else if (stepHr !== undefined) vo2max = vo2maxDaStepTest(stepHr, sesso); + if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso); + + const spo2 = m.get('f_spo2_pct'); + if (spo2 !== undefined) out.wellness_tower_cardio = scoreSpo2(spo2); + + // --- Recupero & Sistema Nervoso / Energia & Regolazione Stress --------------- + // hrv è già un punteggio 0-100 (lo calcola lo Stress Index a monte, non una curva di + // questo motore): qui si limita soltanto, non si ricalcola. Pesa su DUE assi (Recupero + // e Energia, vedi PESI_ASSE) con la stessa chiave 'hrv' — un solo valore in `out`, letto + // da entrambi in cascata.ts. + const hrv = m.get('f_hrv_score'); + if (hrv !== undefined) out.hrv = clamp(hrv); + + const sistolica = m.get('f_pa_sistolica'); + const diastolica = m.get('f_pa_diastolica'); + if (sistolica !== undefined && diastolica !== undefined) out.pressione = scorePressione(sistolica, diastolica); + + const hrr = m.get('f_hrr_1min_bpm'); + if (hrr !== undefined) out.hrr = scoreRecuperoCardiaco(hrr); + + // --- Stabilità & Mobilità Funzionale ------------------------------------------ + const flamingo = m.get('f_flamingo_cadute'); + if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo); + + const sitReach = m.get('f_sit_reach_cm'); + if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso); + + const backScratch = m.get('f_back_scratch_cm'); + if (backScratch !== undefined && eta !== null) out.back_scratch = scoreBackScratch(backScratch, eta, sesso); + + const outreach = m.get('f_spalla_outreach_deg'); + const buckling = m.get('f_spalla_buckling_deg'); + if (outreach !== undefined && buckling !== undefined) { + out.wellness_tower_shoulder = scoreMobilitaSpalla(outreach, buckling); + } + + return out; +} diff --git a/apps/platforms/src/lib/longevity/motore/test-fisici.ts b/apps/platforms/src/lib/longevity/motore/test-fisici.ts index 446c33c..4693929 100644 --- a/apps/platforms/src/lib/longevity/motore/test-fisici.ts +++ b/apps/platforms/src/lib/longevity/motore/test-fisici.ts @@ -15,18 +15,15 @@ * comparabili fra protocolli), la seconda perché non è mai chiamata da * nessun peso. * - * ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate - * contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono - * irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo - * sulle sette curve generiche dichiarate nel vocabolario di `registro.ts` - * (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e - * non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`, - * senza modo di passare età e sesso, che quasi tutte queste funzioni - * richiedono. Agganciarle è lavoro del piano degli import, non di questa - * revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far - * arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai - * dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve - * anche codice nuovo nel motore, non solo righe nuove in `registro_test`. + * COLLEGATE AL MOTORE tramite `motore/fisici.ts` (`sottoDominiFisici`), non + * tramite `applicaCurva`: `applicaCurva` smista solo sulle sette curve + * generiche dichiarate nel vocabolario di `registro.ts` (`bell`, `lin_dec`, + * `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e la sua firma prende + * `(voce, valore)`, senza modo di passare età e sesso che quasi tutte queste + * funzioni richiedono — estenderla resta lavoro futuro, non fatto qui. + * `sottoDominiFisici` aggira il problema chiamando queste funzioni + * direttamente, con gli argomenti che servono presi dalla mappa delle misure + * del check-up (`registro.ts`, voci `f_*`). */ import { clamp, lerp, arrotonda1 } from './curve'; diff --git a/apps/platforms/src/lib/longevity/registro.ts b/apps/platforms/src/lib/longevity/registro.ts index 3204c26..e17530f 100644 --- a/apps/platforms/src/lib/longevity/registro.ts +++ b/apps/platforms/src/lib/longevity/registro.ts @@ -93,6 +93,71 @@ const QUESTIONARIO: VoceRegistro[] = [ asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, ]; +// I test fisici del check-up: misure GREZZE, non punteggi. A differenza del questionario +// non passano da `applicaCurva` (nessuna `curva`/`params` qui: quelle sette curve generiche +// non sanno cosa farsene di un'età o di un sesso) — il calcolo è in `motore/fisici.ts` +// (`sottoDominiFisici`), che è il porting dell'oracolo per queste ventiquattro voci. Per +// questo `sotto_dominio` resta assente: il sotto-dominio non è un dato del registro come +// per il questionario, è il NOME della chiave che `sottoDominiFisici` restituisce. +// `asse` invece è valorizzato: serve solo all'interfaccia (raggruppare il modulo del +// check-up per sezione), nessun codice lo legge oggi. Due voci alimentano DUE +// sotto-domini dalla stessa misura (f_plank_sec -> core E plank, f_hrv_score -> hrv sia +// in Recupero che in Energia): l'asse riportato qui è quello principale, l'altro si vede +// in `sottoDominiFisici`. +// `range_min`/`range_max` sono limiti di PLAUSIBILITÀ FISIOLOGICA, larghi apposta: non sono +// soglie cliniche (quelle vivono nelle funzioni di `test-fisici.ts`), servono solo a +// intercettare un errore di battitura in fase di inserimento (es. 480 kg di squat). +const FISICI: VoceRegistro[] = [ + { test_id: 'f_peso_kg', etichetta: 'Peso corporeo', unita: 'kg', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 30, range_max: 250, attivo_da: DA }, + { test_id: 'f_handgrip_kg', etichetta: 'Forza di presa (handgrip, dinamometro)', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 150, attivo_da: DA }, + { test_id: 'f_row_carico_kg', etichetta: 'Trazione/rematore, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA }, + { test_id: 'f_row_rip', etichetta: 'Trazione/rematore, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_squat_carico_kg', etichetta: 'Squat, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 400, attivo_da: DA }, + { test_id: 'f_squat_rip', etichetta: 'Squat, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_plank_sec', etichetta: 'Plank, tenuta', unita: 'sec', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 1200, attivo_da: DA }, + { test_id: 'f_grasso_pct', etichetta: 'Massa grassa', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 2, range_max: 60, attivo_da: DA }, + { test_id: 'f_muscolo_pct', etichetta: 'Massa muscolare', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 20, range_max: 70, attivo_da: DA }, + { test_id: 'f_whr', etichetta: 'Rapporto vita-fianchi (WHR)', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 0.5, range_max: 1.5, attivo_da: DA }, + { test_id: 'f_vo2max', etichetta: 'VO2max (misura diretta)', unita: 'ml/kg/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 10, range_max: 90, attivo_da: DA }, + { test_id: 'f_mutt_vam', etichetta: 'VAM al test MUTT/tapis (per stimare il VO2max)', unita: 'm/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 400, attivo_da: DA }, + { test_id: 'f_step_hr_recovery', etichetta: 'Frequenza cardiaca di recupero, Queen\'s College Step Test', + unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 220, attivo_da: DA }, + { test_id: 'f_spo2_pct', etichetta: 'Saturazione O2 (SpO2)', unita: '%', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 100, attivo_da: DA }, + { test_id: 'f_hrv_score', etichetta: 'HRV, punteggio (Stress Index)', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pa_sistolica', etichetta: 'Pressione arteriosa sistolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 70, range_max: 250, attivo_da: DA }, + { test_id: 'f_pa_diastolica', etichetta: 'Pressione arteriosa diastolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 40, range_max: 150, attivo_da: DA }, + { test_id: 'f_hrr_1min_bpm', etichetta: 'Recupero cardiaco a 1 minuto post-sforzo', unita: 'bpm', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_flamingo_cadute', etichetta: 'Test del fenicottero, numero di cadute', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA }, + { test_id: 'f_sit_reach_cm', etichetta: 'Sit and reach', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -30, range_max: 50, attivo_da: DA }, + { test_id: 'f_back_scratch_cm', etichetta: 'Back scratch (mobilità spalle)', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA }, + { test_id: 'f_spalla_outreach_deg', etichetta: 'Wellness Tower spalla, outreach', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'f_spalla_buckling_deg', etichetta: 'Wellness Tower spalla, buckling', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, +]; + export function seedRegistro(db: Database.Database): void { const ins = db.prepare( `INSERT OR REPLACE INTO registro_test @@ -114,6 +179,7 @@ export function seedRegistro(db: Database.Database): void { } }); tx(QUESTIONARIO); + tx(FISICI); } // Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, From 4968b754724911e8f7702ef8dd04c86c5f268083 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:38:34 +0000 Subject: [PATCH 02/18] longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un check-up e un questionario sono sessioni separate: prima di questo commit calcolaSessione leggeva solo le misure della sessione corrente, quindi l'una non vedeva mai i dati dell'altra. misureValideAlla (nuova, motore/index.ts) legge invece, per ogni test_id, la misura valida piu' recente del cliente fra tutte le sue sessioni fino a quella data. sottoDominiDellaSessione unisce sotto-domini da curva (questionario) e sotto-domini fisici (sottoDominiFisici) in un'unica mappa, usata sia da calcolaSessione (livello 1) sia da vista.ts (sottoDominiPresenti, per "cosa manca" nel referto — non piu' una query separata sulla tabella misure). Aggiornati i commenti di seed-demo.ts (il registro ora contiene anche le voci f_*, anche se questa demo non le compila). Test nuovi in motore-fisici-sessione.test.ts: il caso demo() dell'oracolo end-to-end, un ingresso mancante o l'eta assente che lascia il sotto-dominio fuori, la combinazione fra sessioni (check-up dopo il questionario, sessione precedente che non vede il futuro, misura fuori range sostituita dalla precedente valida). 202 test esistenti restano verdi, 9 nuovi: 211 totali. tsc --noEmit: 0 errori (0 anche su 13d8cdd). Refs #1 Co-Authored-By: Claude Sonnet 5 --- apps/platforms/scripts/seed-demo.ts | 15 +- .../src/lib/longevity/motore/index.ts | 167 ++++++++++--- apps/platforms/src/lib/longevity/vista.ts | 32 +-- .../longevity/motore-fisici-sessione.test.ts | 228 ++++++++++++++++++ 4 files changed, 379 insertions(+), 63 deletions(-) create mode 100644 apps/platforms/tests/longevity/motore-fisici-sessione.test.ts diff --git a/apps/platforms/scripts/seed-demo.ts b/apps/platforms/scripts/seed-demo.ts index 1379da8..5224a22 100644 --- a/apps/platforms/scripts/seed-demo.ts +++ b/apps/platforms/scripts/seed-demo.ts @@ -26,13 +26,14 @@ * ⚠️ I dati sono INVENTATI e vanno detti tali a chi guarda: è una persona che non esiste. * Il referto che ne esce non è un esempio clinico, è un esempio di interfaccia. * - * ⚠️ Cosa mostrerà davvero il radar: il registro contiene oggi SOLO le 20 domande del - * questionario — i test fisici (handgrip, VO2max, plank, composizione corporea...) non - * hanno ancora una riga, arrivano col gestionale trainer. Quindi escono con un valore gli - * assi che il questionario copre almeno al 40%, e gli altri restano dichiarati - * insufficienti con l'elenco di cosa manca. Non è un difetto della demo: è lo stato del - * prodotto, ed è anche la regola di Nicola che si vede all'opera (annullare invece di - * stimare). + * ⚠️ Cosa mostrerà davvero il radar: questo script compila SOLO il questionario (le 20 + * domande `q_*`) tramite `salvaDalForm` — non registra nessuna misura fisica (`f_*`), che + * arriva dal check-up col gestionale trainer, non da qui. Il registro le contiene entrambe + * (vedi `registro.ts`), ma senza `registraMisure` per le `f_*` questa sessione non ne vede + * nessuna. Quindi escono con un valore gli assi che il questionario copre almeno al 40%, e + * gli altri restano dichiarati insufficienti con l'elenco di cosa manca. Non è un difetto + * della demo: è lo stato di UNA sessione di solo questionario, ed è anche la regola di + * Nicola che si vede all'opera (annullare invece di stimare). * * DIPENDE DA: tsx (devDependency) — il codice del prodotto è TypeScript e questo script * lo usa davvero invece di riscriverne una copia in SQL. diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index a8004f1..c3a1d99 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -6,15 +6,25 @@ * Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel * registro (`registro_test.curva`), coi parametri dichiarati lì * (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un - * test senza curva dichiarata (es. un campo testuale) non contribuisce al - * calcolo. + * test senza curva dichiarata (es. un campo testuale, o una misura fisica — + * vedi sotto) non contribuisce a QUESTO livello. * - * Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione - * per sotto-dominio (media dei test dello stesso sotto-dominio — più test - * possono contribuire allo stesso sotto-dominio, es. spinta via push-up o - * panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`, - * `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette - * assi, i tre macro-score e la Fitness Age. + * Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola + * `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una + * curva generica (età, sesso, a volte un'altra misura come il peso + * corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini + * da curva (questionario) e sotto-domini fisici — in un'unica mappa. + * + * Un check-up e un questionario sono sessioni SEPARATE: senza altro, una + * sessione vedrebbe solo le proprie misure e lascerebbe vuoti gli assi + * dell'altra sessione. `misureValideAlla` risolve questo leggendo, per ogni + * test_id, la misura valida più recente DEL CLIENTE fra tutte le sue + * sessioni fino a quella data — non solo quelle della sessione corrente. + * + * Livelli 2-4 (`calcolaSessione`): usa `sottoDominiDellaSessione`, poi + * `cascata.ts` (`calcolaAsse`, `calcolaMacro`, `calcolaFitnessAge`) coi pesi + * letti da `pesi` (via `pesiDi`) per i sette assi, i tre macro-score e la + * Fitness Age. * * `salvaScore` congela il risultato nella tabella `score`, una riga per * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è @@ -41,6 +51,7 @@ import { } from './curve'; import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata'; import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro'; +import { sottoDominiFisici, type Sesso } from './fisici'; /** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */ type ParamsCurva = { @@ -114,6 +125,109 @@ export function applicaCurva(voce: VoceRegistro, valore: number): number | null } } +/** + * Sotto questa età (in giorni) una misura smette di contare per una sessione successiva. + * `null` = nessun limite: oggi una misura resta valida per sempre, finché non ne arriva + * una più recente dello stesso test_id. Decisione aperta per Nicola — quanto "invecchia" + * un dato fisico prima di non poter più coprire il questionario di un mese dopo è una + * scelta di prodotto, non tecnica; finché non arriva si tiene la più permissiva delle due, + * perché la direzione di guasto opposta (scartare una misura buona) è quella che di nuovo + * produce assi vuoti che potrebbero non esserlo. + */ +export const FINESTRA_GIORNI: number | null = null; + +/** + * Per ogni test_id, il valore valido più recente DI QUESTO CLIENTE fra tutte le sue + * sessioni fino a quella data compresa — non solo le misure della sessione `sessioneId`. + * Risolve il problema del check-up e del questionario come sessioni separate (vedi + * l'intestazione del file): un check-up del giorno D+10 vede ancora le risposte del + * questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò + * che accadrà dopo di lei. + * + * "Più recente" = data della sessione DESC, poi id DESC (a parità di data, l'inserimento + * più tardo vince) — stesso criterio di `leggiScore`. A parità di sessione (una misura + * ri-registrata più volte nella stessa sessione, oggi non capita ma non è vietato) vince + * comunque la più recente per `id` di riga in `misure`, perché la query è ordinata anche + * su quello implicitamente tramite l'ordine della sessione. + * + * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della + * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da + * una precedente. + */ +export function misureValideAlla(db: Database.Database, sessioneId: number): Map { + const sessione = db.prepare( + `SELECT client_code, data, id FROM sessioni WHERE id = ?` + ).get(sessioneId) as { client_code: string; data: string; id: number } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const righe = db.prepare( + `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data + FROM misure m + JOIN sessioni s2 ON s2.id = m.sessione_id + WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL + AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) + ORDER BY s2.data DESC, s2.id DESC` + ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as + { test_id: string; valore_num: number; data: string }[]; + + const mappa = new Map(); + for (const r of righe) { + if (mappa.has(r.test_id)) continue; // gia' trovato un valore piu' recente per questo test_id + if (FINESTRA_GIORNI !== null) { + const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000; + if (giorni > FINESTRA_GIORNI) continue; + } + mappa.set(r.test_id, r.valore_num); + } + return mappa; +} + +/** + * I sotto-domini di una sessione, dalle due fonti che oggi esistono: il questionario + * (livello 1, `applicaCurva` sulle misure di `misureValideAlla`) e i test fisici + * (`sottoDominiFisici`, sull'età alla data della sessione e il sesso del cliente). In + * caso di chiave in conflitto vince il fisico — oggi non capita mai (i sotto-domini da + * curva sono tutti `questionario_*`), ma se domani un test_id q_* condividesse la chiave + * di un sotto-dominio fisico, un dato misurato in un check-up deve prevalere su + * un'autovalutazione. + */ +export function sottoDominiDellaSessione(db: Database.Database, sessioneId: number): Record { + const sessione = db.prepare( + `SELECT data, eta_alla_data, client_code FROM sessioni WHERE id = ?` + ).get(sessioneId) as { data: string; eta_alla_data: number | null; client_code: string } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const misurate = misureValideAlla(db, sessioneId); + + // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per + // sotto-dominio — più test nello stesso sotto-dominio contano come media. + const attivi = testAttivi(db, sessione.data); + const perSottoDominio = new Map(); + for (const voce of attivi) { + if (!voce.sotto_dominio) continue; + const grezzo = misurate.get(voce.test_id); + if (grezzo === undefined) continue; + const punteggio = applicaCurva(voce, grezzo); + if (punteggio === null) continue; + const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; + lista.push(punteggio); + perSottoDominio.set(voce.sotto_dominio, lista); + } + const daCurva: Record = {}; + for (const [nome, valori] of perSottoDominio) { + daCurva[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); + } + + const soggetto = db.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(sessione.client_code) as + { sesso: Sesso } | undefined; + // soggetto dovrebbe sempre esistere (sessioni.client_code referenzia soggetti, FK attive + // in db.ts): il controllo resta comunque, perche' l'assenza di dato fisico e' un {} + // sicuro, mai un sesso indovinato. + const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso) : {}; + + return { ...daCurva, ...daFisici }; +} + /** * Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. * `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché @@ -134,43 +248,16 @@ export type RisultatoSessione = { /** * Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da * costanti: `testAttivi` alla data della sessione, i valori grezzi da - * `misure`, i pesi da `pesi` (tramite `pesiDi`). + * `misure` (tramite `sottoDominiDellaSessione`), i pesi da `pesi` (tramite `pesiDi`). */ export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione { const sessione = db.prepare( - `SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?` - ).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined; + `SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?` + ).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined; if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); - const attivi = testAttivi(db, sessione.data); - // fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno - // non le conferma". La colonna della conferma non esiste ancora (punto aperto in - // §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar - // passare le misure confermate, non tolto del tutto. - const misurate = new Map( - (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as - { test_id: string; valore_num: number | null }[]) - .filter((m) => m.valore_num !== null) - .map((m) => [m.test_id, m.valore_num as number]) - ); - - // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per - // sotto-dominio — più test nello stesso sotto-dominio contano come media. - const perSottoDominio = new Map(); - for (const voce of attivi) { - if (!voce.sotto_dominio) continue; - const grezzo = misurate.get(voce.test_id); - if (grezzo === undefined) continue; - const punteggio = applicaCurva(voce, grezzo); - if (punteggio === null) continue; - const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; - lista.push(punteggio); - perSottoDominio.set(voce.sotto_dominio, lista); - } - const sottoDominio: Record = {}; - for (const [nome, valori] of perSottoDominio) { - sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); - } + // Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione. + const sottoDominio = sottoDominiDellaSessione(db, sessioneId); // Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso. const nomiAssi = (db.prepare( diff --git a/apps/platforms/src/lib/longevity/vista.ts b/apps/platforms/src/lib/longevity/vista.ts index be66d2c..a88092d 100644 --- a/apps/platforms/src/lib/longevity/vista.ts +++ b/apps/platforms/src/lib/longevity/vista.ts @@ -11,7 +11,7 @@ import type Database from 'better-sqlite3'; import { testAttivi, pesiDi, MODEL_VERSION } from './registro'; import { salvaCompilazione, CONSENT_VERSION } from './questionario'; -import { leggiScore, calcolaSessione, salvaScore } from './motore'; +import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore'; export type CampoQuestionario = { id: string; @@ -137,11 +137,13 @@ export type MacroVista = { nome: string; valore: number | null; copertura: numbe /** * Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle - * di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i - * test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il - * questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non - * può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa - * dove il registro non arriva ancora. + * di `PESI_ASSE` in registro.ts. Non sono chiavi che si leggono da `registro_test.sotto_dominio` + * per tutte: quelle del questionario sì, ma quelle fisiche (handgrip, VO2max, plank...) le + * produce `sottoDominiFisici` (`motore/fisici.ts`) come chiave del suo risultato — le voci + * FISICI del registro non portano un `sotto_dominio` proprio (vedi il commento su `FISICI` + * in registro.ts) — quindi la traduzione non può venire da una query sul registro: è dato + * locale a questo modulo. Unica eccezione rimasta davvero scoperta: `spirometria`, fuori + * dal perimetro di `sottoDominiFisici` (nessun calcolatore GLI, vedi quel file). */ const ETICHETTE_SOTTO_DOMINIO: Record = { handgrip: 'Forza di presa (handgrip)', @@ -178,17 +180,15 @@ function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string ).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore); } -/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione: - * l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali - * test sono stati fatti non è ricalcolare come pesano. */ +/** I sotto-domini con un punteggio calcolabile per questa sessione — le chiavi di + * `sottoDominiDellaSessione`, che a differenza di una query sulla sola tabella `misure` + * vede anche le misure valide portate avanti da sessioni precedenti dello stesso cliente + * (`misureValideAlla` in `motore/index.ts`) e i sotto-domini fisici (`sottoDominiFisici`), + * non solo quelli da curva del questionario. Usa lo stesso calcolo che alimenta gli assi, + * non lo duplica: `mancano` si costruisce da qui, mai da un ricalcolo separato che + * potrebbe divergere. */ function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set { - const righe = db.prepare( - `SELECT DISTINCT rt.sotto_dominio AS sd - FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id - WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL - AND rt.sotto_dominio IS NOT NULL` - ).all(sessioneId) as { sd: string }[]; - return new Set(righe.map((r) => r.sd)); + return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId))); } /** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse diff --git a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts new file mode 100644 index 0000000..46115c9 --- /dev/null +++ b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts @@ -0,0 +1,228 @@ +// Il piano degli import collega finalmente i test fisici (verificati contro l'oracolo in +// motore-fisici.test.ts, ma finora IRRAGGIUNGIBILI dal motore) a una sessione vera: la +// misura grezza del check-up (test_id f_*, registro.ts) arriva a un punteggio di +// sotto-dominio tramite sottoDominiFisici (motore/fisici.ts), che calcolaSessione unisce +// a quelli del questionario tramite sottoDominiDellaSessione. Questo file copre tre cose +// che PRIMA di questa revisione non esistevano: il caso end-to-end confrontato con +// l'oracolo, la regola "un ingresso mancante -> sotto-dominio assente" applicata a +// funzioni con piu' argomenti (non solo alle curve del questionario), e il motivo per cui +// serve tutto questo: un check-up e un questionario sono sessioni SEPARATE, e senza +// misureValideAlla l'una non vedrebbe mai i dati dell'altra. +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; +import { calcolaSessione, sottoDominiDellaSessione, misureValideAlla } from '../../src/lib/longevity/motore'; +import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici'; +import { campiDelQuestionario } from '../../src/lib/longevity/vista'; + +function dbPronto(clientCode: string, sesso: 'M' | 'F' = 'M') { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso); + return db; +} + +describe('il caso demo() dell oracolo, calcolato da una sessione vera', () => { + // Stesso cliente fittizio di demo() in isl_scoring_engine.py: M, 38 anni, peso 90.3. + // Un solo check-up con TUTTE le misure fisiche della demo, nessun questionario. + it('Forza & Struttura, Composizione Corporea e Stabilita & Mobilita Funzionale combaciano con l oracolo', () => { + const db = dbPronto('ISL-DEMO', 'M'); + const s = apriSessione(db, { client_code: 'ISL-DEMO', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'manuale', [ + { test_id: 'f_peso_kg', valore_num: 90.3 }, + { test_id: 'f_handgrip_kg', valore_num: 48 }, + { test_id: 'f_pushup_rip', valore_num: 24 }, + { test_id: 'f_row_carico_kg', valore_num: 80 }, { test_id: 'f_row_rip', valore_num: 5 }, + { test_id: 'f_squat_carico_kg', valore_num: 140 }, { test_id: 'f_squat_rip', valore_num: 5 }, + { test_id: 'f_plank_sec', valore_num: 110 }, + { test_id: 'f_grasso_pct', valore_num: 12.8 }, + { test_id: 'f_muscolo_pct', valore_num: 48.8 }, + { test_id: 'f_whr', valore_num: 0.71 }, + { test_id: 'f_mutt_vam', valore_num: 230 }, + { test_id: 'f_spo2_pct', valore_num: 98 }, + { test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 }, + { test_id: 'f_hrr_1min_bpm', valore_num: 15 }, + { test_id: 'f_flamingo_cadute', valore_num: 5 }, + { test_id: 'f_sit_reach_cm', valore_num: 20 }, + { test_id: 'f_back_scratch_cm', valore_num: -5 }, + { test_id: 'f_spalla_outreach_deg', valore_num: 180 }, { test_id: 'f_spalla_buckling_deg', valore_num: 180 }, + ]); + registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]); + + const r = calcolaSessione(db, s); + + // Valori attesi ricavati lanciando l'oracolo, non a mano: + // cd tests/longevity/riferimento && python3 -c " + // from isl_scoring_engine import * + // eta, sex, peso = 38, 'M', 90.3 + // forza = {'handgrip': score_handgrip(48, eta, sex), 'spinta': score_pushup(24, eta, sex), + // 'trazione': score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex), + // 'arti_inferiori': score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex), + // 'core': score_plank(110, sex)} + // composizione = {'grasso': score_fat_percent(12.8, sex), 'muscolo': score_muscle_percent(48.8, sex), + // 'whr': score_whr(0.71, sex)} + // stabilita = {'flamingo': score_flamingo(5), 'sit_and_reach': score_sit_and_reach(20, sex), + // 'plank': score_plank(110, sex), 'back_scratch': score_back_scratch(-5, eta, sex), + // 'wellness_tower_shoulder': score_shoulder_mobility_wt(180, 180)} + // print(compute_axis('Forza & Struttura', forza)) # -> score 70.4, coverage 1.0, ok + // print(compute_axis('Composizione Corporea', composizione)) # -> score 94.8, coverage 1.0, ok + // print(compute_axis('Stabilità & Mobilità Funzionale', stabilita)) # -> score 65.5, coverage 1.0, ok + // " + const forza = r.assi['Forza & Struttura']; + expect(forza.stato).toBe('ok'); + if (forza.stato === 'ok') expect(forza.valore).toBe(70.4); + + const composizione = r.assi['Composizione Corporea']; + expect(composizione.stato).toBe('ok'); + if (composizione.stato === 'ok') expect(composizione.valore).toBe(94.8); + + const stabilita = r.assi['Stabilità & Mobilità Funzionale']; + expect(stabilita.stato).toBe('ok'); + if (stabilita.stato === 'ok') expect(stabilita.valore).toBe(65.5); + }); + + // Cardio-Respiratorio, Recupero & Sistema Nervoso ed Energia & Regolazione Stress NON si + // confrontano con demo() dell'oracolo: demo() li riempie con segnaposto dichiarati tali nel + // codice Python (spirometria=75, questionario_sonno=72, questionario_energia_stress=60, + // hrv=65 su Energia — un HRV diverso da quello di Recupero, altro segnaposto) per mostrare + // un radar completo. Il nostro motore non inventa spirometria (fuori dal perimetro di + // sottoDominiFisici, nessun calcolatore GLI) ne' un questionario mai compilato in questa + // sessione: quei sotto-domini restano assenti DAVVERO, non finti, e il peso si ridistribuisce + // sugli altri (regola di copertura, cascata.ts). Il numero che ne esce e' quindi + // legittimamente diverso da quello della demo, ma calcolabile con la STESSA formula di + // aggregazione (aggregate() in Python, aggrega() qui) sul sottoinsieme vero di dati: + // cd tests/longevity/riferimento && python3 -c " + // from isl_scoring_engine import * + // eta, sex = 38, 'M' + // vo2 = vo2max_from_mutt(230) + // print(compute_axis('Cardio-Respiratorio', {'vo2max': score_vo2max(vo2, eta, sex), 'wellness_tower_cardio': score_spo2(98)})) + // print(compute_axis('Recupero & Sistema Nervoso', {'hrv': 68, 'pressione': score_blood_pressure(114, 81), 'hrr': score_hrr(15)})) + // print(compute_axis('Energia & Regolazione Stress', {'hrv': 68})) + // " + // -> Cardio 87.2 (copertura 0.8), Recupero 73.4 (copertura 0.8), Energia 68.0 (copertura 0.5). + it('Cardio-Respiratorio, Recupero ed Energia sono ok ma con un numero diverso dalla demo(): mancano spirometria e questionario, non sono finti', () => { + const db = dbPronto('ISL-DEMO2', 'M'); + const s = apriSessione(db, { client_code: 'ISL-DEMO2', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'manuale', [ + { test_id: 'f_mutt_vam', valore_num: 230 }, + { test_id: 'f_spo2_pct', valore_num: 98 }, + { test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 }, + { test_id: 'f_hrr_1min_bpm', valore_num: 15 }, + ]); + registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]); + + const r = calcolaSessione(db, s); + + const cardio = r.assi['Cardio-Respiratorio']; + expect(cardio.stato).toBe('ok'); + if (cardio.stato === 'ok') { expect(cardio.valore).toBe(87.2); expect(cardio.copertura).toBe(0.8); } + + const recupero = r.assi['Recupero & Sistema Nervoso']; + expect(recupero.stato).toBe('ok'); + if (recupero.stato === 'ok') { expect(recupero.valore).toBe(73.4); expect(recupero.copertura).toBe(0.8); } + + const energia = r.assi['Energia & Regolazione Stress']; + expect(energia.stato).toBe('ok'); + if (energia.stato === 'ok') { expect(energia.valore).toBe(68); expect(energia.copertura).toBe(0.5); } + + // Nessun questionario in questa sessione: l'asse che dipende solo da domande resta scoperto. + expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente'); + }); +}); + +describe('sottoDominiFisici: un ingresso mancante lascia il sotto-dominio assente, mai un ripiego', () => { + it('la trazione (sollevamento sul peso corporeo) manca se manca il peso, anche con carico e ripetizioni presenti', () => { + const m = new Map([['f_row_carico_kg', 80], ['f_row_rip', 5]]); // manca f_peso_kg + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.trazione).toBeUndefined(); + }); + + it('senza eta: handgrip, spinta, vo2max e back_scratch assenti; il plank (che non la richiede) resta presente', () => { + // Stesse quattro funzioni di test-fisici.ts che prendono eta come argomento + // obbligatorio (scoreHandgrip, scorePushup, scoreVo2max, scoreBackScratch): con eta + // null nessuna delle quattro puo' essere chiamata, quindi il sotto-dominio che ne + // dipende resta assente. scorePlank non prende eta: il suo sotto-dominio non risente + // per niente dell'eta mancante. + const m = new Map([ + ['f_handgrip_kg', 48], ['f_pushup_rip', 24], ['f_plank_sec', 110], + ['f_mutt_vam', 230], ['f_back_scratch_cm', -5], + ]); + const r = sottoDominiFisici(m, null, 'M'); + expect(r.handgrip).toBeUndefined(); + expect(r.spinta).toBeUndefined(); + expect(r.vo2max).toBeUndefined(); + expect(r.back_scratch).toBeUndefined(); + expect(r.core).toBe(47.8); // scorePlank(110, 'M'), verificato in motore-fisici.test.ts + expect(r.plank).toBe(47.8); + }); + + it('il plank pesa su due sotto-domini (core e plank) dalla STESSA misura, non da due misure diverse', () => { + const m = new Map([['f_plank_sec', 79]]); + const r = sottoDominiFisici(m, 38, 'F'); + expect(r.core).toBe(r.plank); + }); +}); + +describe('un check-up e un questionario sono sessioni separate, ma un cliente e uno solo', () => { + it('un check-up 10 giorni dopo il questionario vede ENTRAMBI i sotto-domini', () => { + const db = dbPronto('ISL-0020', 'M'); + // Sessione D: questionario del giorno D. + const sQuest = salvaCompilazione(db, { + client_code: 'ISL-0020', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 }, + }); + // Sessione D+10: check-up, con una sola misura fisica. + const sCheckup = apriSessione(db, { client_code: 'ISL-0020', data: '2026-08-11', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, sCheckup, 'manuale', [{ test_id: 'f_handgrip_kg', valore_num: 48 }]); + + // Prova diretta che le due sessioni sono davvero due righe distinte in `sessioni`: + // senza questo, "il check-up vede il questionario" sarebbe banale (sarebbe la STESSA + // sessione) invece che il punto vero di misureValideAlla. + expect(sQuest).not.toBe(sCheckup); + + const sd = sottoDominiDellaSessione(db, sCheckup); + expect(sd).toHaveProperty('questionario_lifestyle'); // dal questionario di D, non di questa sessione + expect(sd).toHaveProperty('handgrip'); // dal check-up di questa sessione + }); + + it('una sessione del giorno PRIMA del questionario non vede le sue risposte', () => { + const db = dbPronto('ISL-0021', 'M'); + salvaCompilazione(db, { client_code: 'ISL-0021', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 } }); + // Aperta DOPO, ma con una data anteriore al questionario: il confronto e' sulla data + // della sessione, non sull'ordine di inserimento. + const sPrima = apriSessione(db, { client_code: 'ISL-0021', data: '2026-07-31', tipo: 'checkup', eta_alla_data: 38 }); + + const sd = sottoDominiDellaSessione(db, sPrima); + expect(sd).not.toHaveProperty('questionario_lifestyle'); + }); + + it('una misura fuori range viene ignorata e sostituita dalla precedente misura valida', () => { + const db = dbPronto('ISL-0022', 'M'); + const s1 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // valida + + const s2 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-20', tipo: 'checkup', eta_alla_data: 38 }); + // f_grasso_pct ha range_max 60 (registro.ts): 999 e' un errore di battitura evidente. + const esito = registraMisure(db, s2, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 999 }]); + expect(esito.fuoriRange).toEqual(['f_grasso_pct']); + + const misurate = misureValideAlla(db, s2); + // NON 999 (fuori range, scartata) ne' assente (persa): la precedente misura valida, + // quella di s1. + expect(misurate.get('f_grasso_pct')).toBe(12.8); + + const sd = sottoDominiDellaSessione(db, s2); + expect(sd.grasso).toBe(100); // score_fat_percent(12.8, 'M') dell'oracolo: banda 5-13 -> 100 + }); +}); + +describe('campiDelQuestionario resta un modulo di sole domande', () => { + it('nessun campo f_* (test fisico) compare fra i campi del questionario', () => { + const db = createLongevityDb(':memory:'); + const campi = campiDelQuestionario(db, '2026-08-22'); + expect(campi.length).toBeGreaterThan(0); + expect(campi.some((c) => c.id.startsWith('f_'))).toBe(false); + }); +}); From af8afebba82dd5f8647ab29c0d115a1922ced9c4 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:40:31 +0000 Subject: [PATCH 03/18] longevity: misureValideAlla, spareggio sulla riga e nota sulla conferma dei fuori range Refs #1 Co-Authored-By: Claude Sonnet 5 --- .../src/lib/longevity/motore/index.ts | 25 +++++++++++++------ .../longevity/motore-fisici-sessione.test.ts | 14 +++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index c3a1d99..2e072ad 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -144,15 +144,21 @@ export const FINESTRA_GIORNI: number | null = null; * questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò * che accadrà dopo di lei. * - * "Più recente" = data della sessione DESC, poi id DESC (a parità di data, l'inserimento - * più tardo vince) — stesso criterio di `leggiScore`. A parità di sessione (una misura - * ri-registrata più volte nella stessa sessione, oggi non capita ma non è vietato) vince - * comunque la più recente per `id` di riga in `misure`, perché la query è ordinata anche - * su quello implicitamente tramite l'ordine della sessione. + * "Più recente" = data della sessione DESC, poi id di sessione DESC (a parità di data, + * l'inserimento più tardo vince) — stesso criterio di `leggiScore`. A parità di SESSIONE + * (due misure dello stesso test_id nella stessa sessione: `registraMisure` non lo vieta, + * es. una correzione manuale dopo un refuso) l'ORDER BY aggiunge un terzo criterio + * esplicito, `m.id` di riga in `misure` DESC: vince quella scritta dopo. Senza questo + * terzo criterio le due righe uscirebbero in un ordine NON definito da SQL (nessuna delle + * due chiavi precedenti le distingue), non "implicito" come diceva una versione precedente + * di questo commento — un ordine che sembra stabile per come SQLite le restituisce oggi, + * ma su cui non si può contare. * * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da - * una precedente. + * una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7): + * quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate, + * non tolto del tutto. */ export function misureValideAlla(db: Database.Database, sessioneId: number): Map { const sessione = db.prepare( @@ -166,13 +172,16 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map JOIN sessioni s2 ON s2.id = m.sessione_id WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) - ORDER BY s2.data DESC, s2.id DESC` + ORDER BY s2.data DESC, s2.id DESC, m.id DESC` ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as { test_id: string; valore_num: number; data: string }[]; const mappa = new Map(); for (const r of righe) { - if (mappa.has(r.test_id)) continue; // gia' trovato un valore piu' recente per questo test_id + // gia' trovato un valore per questo test_id: e' il piu' recente, per data di sessione, + // poi id di sessione, poi (spareggio) id di riga in misure — tutti e tre espliciti + // nell'ORDER BY qui sopra, la prima riga per test_id in questo ciclo e' sempre quella giusta. + if (mappa.has(r.test_id)) continue; if (FINESTRA_GIORNI !== null) { const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000; if (giorni > FINESTRA_GIORNI) continue; diff --git a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts index 46115c9..b67c909 100644 --- a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts +++ b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts @@ -198,6 +198,20 @@ describe('un check-up e un questionario sono sessioni separate, ma un cliente e expect(sd).not.toHaveProperty('questionario_lifestyle'); }); + it('due misure dello stesso test_id nella stessa sessione: vince quella inserita dopo', () => { + // registraMisure non vieta di scrivere due volte lo stesso test_id nella stessa + // sessione (es. una correzione manuale dopo un refuso di battitura). data di sessione + // e id di sessione sono identici per le due righe: senza uno spareggio esplicito su + // m.id (la riga in `misure`), l'ordine fra le due sarebbe indefinito. + const db = dbPronto('ISL-0023', 'M'); + const s = apriSessione(db, { client_code: 'ISL-0023', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 20 }]); // scritta per prima, refuso + registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // corretta subito dopo + + const misurate = misureValideAlla(db, s); + expect(misurate.get('f_grasso_pct')).toBe(12.8); // l'ultima scritta, non la prima + }); + it('una misura fuori range viene ignorata e sostituita dalla precedente misura valida', () => { const db = dbPronto('ISL-0022', 'M'); const s1 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 }); From 8e0b9e46474b0550d5365f16e35e8a2e9ada113f Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:53:20 +0000 Subject: [PATCH 04/18] =?UTF-8?q?longevity:=20anagrafica=20del=20gestional?= =?UTF-8?q?e=20=E2=80=94=20elenco,=20dettaglio,=20et=C3=A0,=20apertura=20f?= =?UTF-8?q?ascicolo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elencoClienti/clienteDaCodice/etaDelCliente/apriFascicolo (versione operativa, sul singleton) affiancate alle rispettive versioni parametrizzate (elencoClientiParam/clienteDaCodiceParam/etaDelClienteParam), sullo stesso schema di codicePerUtente/codicePerUtenteLoggato: la parametrizzata resta testabile su un DB :memory:, l'operativa è quella che pagine ed endpoint del gestionale importano. apriFascicolo apre sempre senza user_id — l'accesso è #8, non questa unità — per costruzione del tipo di `dati`, non per convenzione. Refs #2 Co-Authored-By: Claude Sonnet 5 --- .../platforms/src/lib/longevity/anagrafica.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/platforms/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts index cfea267..ee33827 100644 --- a/apps/platforms/src/lib/longevity/anagrafica.ts +++ b/apps/platforms/src/lib/longevity/anagrafica.ts @@ -4,7 +4,7 @@ // 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'; -import { createIdentityDb } from './db'; +import { createIdentityDb, getLongevityDb } from './db'; // Connessione singola a identity, privata di questo modulo: non è esportata, quindi non // è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file @@ -94,3 +94,88 @@ export function etaAllaData(dataNascita: string, alla: string): number { if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; return eta; } + +// +// Gestionale del trainer (#2, unità 2a): tre letture sull'anagrafica e l'apertura di un +// fascicolo, tutte sotto lo stesso vincolo di codicePerUtente/codicePerUtenteLoggato qui +// sopra — una versione PARAMETRIZZATA (identity esplicita, esportata solo per i test e per +// comporre da qui dentro) e una versione OPERATIVA (nessun argomento, apre da sola +// getIdentityDb) che è quella che pagine ed endpoint del gestionale importano davvero. La +// versione operativa non si può testare su un DB :memory: — il singleton è uno solo per +// processo — quindi il test vero gira sulla parametrizzata, esattamente come +// anagrafica.test.ts fa già con codicePerUtente invece di codicePerUtenteLoggato. +// + +export type ClienteElenco = { + client_code: string; + nome: string; + cognome: string; + data_nascita: string | null; + /** True se il cliente ha già un account per vedere il proprio referto (user_id valorizzato + * in identity.clienti). Creare quell'account è #8, fuori da questa unità: qui si legge + * soltanto se esiste già. */ + ha_accesso: boolean; +}; + +/** L'elenco dei clienti per la tabella del gestionale, ordinato per cognome poi nome — + * l'ordine in cui un trainer cerca una persona, non l'ordine di creazione del fascicolo. */ +export function elencoClientiParam(identity: Database.Database): ClienteElenco[] { + const righe = identity.prepare( + `SELECT client_code, nome, cognome, data_nascita, user_id FROM clienti ORDER BY cognome, nome` + ).all() as { client_code: string; nome: string; cognome: string; data_nascita: string | null; user_id: number | null }[]; + return righe.map(({ user_id, ...resto }) => ({ ...resto, ha_accesso: user_id !== null })); +} + +export function elencoClienti(): ClienteElenco[] { + return elencoClientiParam(getIdentityDb()); +} + +export type ClienteDettaglio = ClienteElenco & { + email: string | null; + telefono: string | null; +}; + +/** L'anagrafica di un cliente per la sua scheda nel gestionale, `null` se il codice non + * esiste — la pagina lo traduce in un 404, non in uno schermo vuoto. */ +export function clienteDaCodiceParam(identity: Database.Database, code: string): ClienteDettaglio | null { + const r = identity.prepare( + `SELECT client_code, nome, cognome, data_nascita, email, telefono, user_id + FROM clienti WHERE client_code = ?` + ).get(code) as + { client_code: string; nome: string; cognome: string; data_nascita: string | null; + email: string | null; telefono: string | null; user_id: number | null } | undefined; + if (!r) return null; + const { user_id, ...resto } = r; + return { ...resto, ha_accesso: user_id !== null }; +} + +export function clienteDaCodice(code: string): ClienteDettaglio | null { + return clienteDaCodiceParam(getIdentityDb(), code); +} + +/** L'età del cliente alla data indicata, `null` se non ha una data di nascita in anagrafica + * (compilazione facoltativa: il fascicolo può nascere senza). Non è un ripiego a zero — + * calcolaSessione tratta già `eta_alla_data: null` come "niente Fitness Age", stessa + * barriera del motore (vedi l'intestazione di motore/index.ts). */ +export function etaDelClienteParam(identity: Database.Database, code: string, alla: string): number | null { + const r = identity.prepare(`SELECT data_nascita FROM clienti WHERE client_code = ?`).get(code) as + { data_nascita: string | null } | undefined; + if (!r?.data_nascita) return null; + return etaAllaData(r.data_nascita, alla); +} + +export function etaDelCliente(code: string, alla: string): number | null { + return etaDelClienteParam(getIdentityDb(), code, alla); +} + +/** Apre un fascicolo nuovo dal gestionale: SEMPRE senza user_id — creare l'account che + * darebbe accesso al cliente è #8, non questa unità, e il tipo di `dati` qui sotto non + * prevede quel campo (stessa garanzia strutturale di CorpoQuestionario nell'endpoint del + * questionario: chi volesse passare uno user_id da qui non supererebbe `tsc --noEmit`). + * Delega tutto a `creaCliente`, che è già la versione testabile con DB propri. */ +export function apriFascicolo(dati: { + nome: string; cognome: string; sesso: 'M' | 'F'; + data_nascita?: string; email?: string; telefono?: string; +}): string { + return creaCliente(getIdentityDb(), getLongevityDb(), dati); +} From 758463bd26c9feae3020926b1ed0bedef3f091df Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:53:35 +0000 Subject: [PATCH 05/18] =?UTF-8?q?longevity:=20gestionale.ts=20=E2=80=94=20?= =?UTF-8?q?check-up=20manuale,=20sessioni=20e=20misure=20fuori=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit campiDelCheckup raggruppa i test fisici attivi per asse nell'ordine dei pesi (letto dal dato, come contenitoriDi in vista.ts). salvaCheckup segue lo stesso schema di salvaDalForm: valida tutto (misure presenti, solo test f_*, cliente esistente) prima di aprire qualunque cosa, poi un'unica transazione — apertura sessione tipo 'checkup', registraMisure fonte 'manuale', calcolaSessione + salvaScore — perché il congelamento dei punteggi è parte del salvataggio, non un passo lasciato a chi legge dopo. sessioniDi e misureFuoriRange sono le due letture per la scheda cliente del gestionale. Refs #2 Co-Authored-By: Claude Sonnet 5 --- .../platforms/src/lib/longevity/gestionale.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/gestionale.ts diff --git a/apps/platforms/src/lib/longevity/gestionale.ts b/apps/platforms/src/lib/longevity/gestionale.ts new file mode 100644 index 0000000..b48371c --- /dev/null +++ b/apps/platforms/src/lib/longevity/gestionale.ts @@ -0,0 +1,158 @@ +// +// Il gestionale del trainer (#2, unità 2a): il modulo del check-up manuale e le letture +// sulle sessioni di un cliente. Solo il database clinico (vedi db.ts) — mai quello +// anagrafico, e mai le due parole riservate a quel confine insieme in un file che non sia +// anagrafica.ts o db.ts (tests/longevity/anagrafica.test.ts lo verifica): un cliente qui è +// solo un client_code, mai un nome. Chi ha bisogno del nome lo chiede ad anagrafica.ts, un +// altro modulo, un'altra chiamata. +import type Database from 'better-sqlite3'; +import { testAttivi, esisteTest, MODEL_VERSION } from './registro'; +import { apriSessione, registraMisure, type MisuraIn } from './misure'; +import { calcolaSessione, salvaScore, leggiScore } from './motore'; +import type { CampoQuestionario } from './vista'; + +export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] }; + +/** + * I campi del modulo di check-up alla data indicata: solo i test_id fisici (prefisso `f_`, + * per lo stesso motivo per cui `campiDelQuestionario` in vista.ts prende solo `q_*`), + * raggruppati per asse — la sezione del modulo cartaceo che il trainer compila — + * nell'ordine in cui l'asse compare nei pesi (`pesi`, livello 'asse'): stesso criterio di + * `contenitoriDi` in vista.ts, letto dal dato invece di un elenco duplicato qui. Un asse + * fisico che non fosse fra i contenitori pesati (non dovrebbe succedere: FISICI in + * registro.ts usa solo assi che PESI_ASSE dichiara) chiude in coda, nell'ordine in cui + * compare per la prima volta nel registro. + */ +export function campiDelCheckup(db: Database.Database, alla: string): GruppoCheckup[] { + const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_')); + + const ordineAssi = (db.prepare( + `SELECT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse' + GROUP BY contenitore ORDER BY MIN(rowid)` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const perAsse = new Map(); + for (const v of fisici) { + const asse = v.asse ?? '(senza asse)'; + const campo: CampoQuestionario = { + id: v.test_id, etichetta: v.etichetta, tipo: v.tipo_valore, + unita: v.unita, min: v.range_min, max: v.range_max, sottoDominio: v.sotto_dominio, + }; + if (!perAsse.has(asse)) perAsse.set(asse, []); + perAsse.get(asse)!.push(campo); + } + + const ordine = ordineAssi.filter((a) => perAsse.has(a)); + for (const a of perAsse.keys()) if (!ordine.includes(a)) ordine.push(a); + + return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! })); +} + +export type EsitoCheckup = + | { ok: true; sessioneId: number; fuoriRange: string[] } + | { ok: false; errore: string }; + +/** + * Salva un check-up manuale del trainer: stesso schema di `salvaDalForm` (vista.ts) — + * validare TUTTO prima di scrivere qualunque cosa, poi un'unica transazione (apertura + * sessione, misure, calcolo e congelamento dei punteggi) perché il congelamento è parte del + * salvataggio, non un passo lasciato a chi legge dopo (lo stesso difetto già corretto una + * volta in questo modulo, vedi il commento su `salvaDalForm`). + * + * Rifiuta PRIMA di aprire la sessione, quindi senza lasciare righe orfane in `sessioni`: + * nessuna misura, un test_id fuori dal registro, o un test_id che non è un test fisico + * (`f_*` — un `q_*` qui è un errore del chiamante, il check-up non è il questionario), o un + * client_code che non ha un soggetto in longevity. + */ +export function salvaCheckup( + db: Database.Database, + input: { + client_code: string; data: string; eta: number | null; + operatore: string; misure: MisuraIn[]; + } +): EsitoCheckup { + if (input.misure.length === 0) { + return { ok: false, errore: 'Nessuna misura da salvare.' }; + } + for (const m of input.misure) { + if (!m.test_id.startsWith('f_')) { + return { ok: false, errore: `Il check-up accetta solo test fisici: ${m.test_id}` }; + } + if (!esisteTest(db, m.test_id)) { + return { ok: false, errore: `Test non riconosciuto: ${m.test_id}` }; + } + } + const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.client_code); + if (!soggetto) { + return { ok: false, errore: `Cliente inesistente: ${input.client_code}` }; + } + + const tx = db.transaction(() => { + const sessioneId = apriSessione(db, { + client_code: input.client_code, data: input.data, tipo: 'checkup', + eta_alla_data: input.eta ?? undefined, operatore: input.operatore, + }); + const { fuoriRange } = registraMisure(db, sessioneId, 'manuale', input.misure); + // Il calcolo e il congelamento sono parte del salvataggio: vedi salvaDalForm (vista.ts). + salvaScore(db, sessioneId, calcolaSessione(db, sessioneId)); + return { sessioneId, fuoriRange }; + }); + + const { sessioneId, fuoriRange } = tx(); + return { ok: true, sessioneId, fuoriRange }; +} + +export type SessioneElenco = { + id: number; + data: string; + tipo: 'checkup' | 'questionario'; + operatore: string | null; + numeroMisure: number; + /** La Fitness Age congelata di quella sessione, `null` se insufficiente o non calcolata — + * mai un ricalcolo: legge `leggiScore`, come `refertoDi` in vista.ts. */ + fitnessAge: number | null; +}; + +/** Le sessioni di un cliente, più recente prima (data DESC, a parità id DESC — stesso + * criterio di `misureValideAlla` e `leggiScore` nel motore). */ +export function sessioniDi(db: Database.Database, code: string): SessioneElenco[] { + const righe = db.prepare( + `SELECT s.id AS id, s.data AS data, s.tipo AS tipo, s.operatore AS operatore, + (SELECT COUNT(*) FROM misure m WHERE m.sessione_id = s.id) AS numero_misure + FROM sessioni s WHERE s.client_code = ? ORDER BY s.data DESC, s.id DESC` + ).all(code) as + { id: number; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null; numero_misure: number }[]; + + return righe.map((r) => { + const punteggi = leggiScore(db, r.id); + const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; + return { + id: r.id, data: r.data, tipo: r.tipo, operatore: r.operatore, + numeroMisure: r.numero_misure, fitnessAge, + }; + }); +} + +export type MisuraFuoriRange = { + test_id: string; + etichetta: string; + valore: number | null; + unita: string | null; + data: string; +}; + +/** Le misure di un cliente marcate fuori dal range di plausibilità del registro + * (`misure.fuori_range = 1`, scritto da `registraMisure`): quelle che oggi non contano per + * nessun calcolo (`misureValideAlla` le esclude) ma restano da mostrare e, un domani, + * confermare (#7). Più recente prima. */ +export function misureFuoriRange(db: Database.Database, code: string): MisuraFuoriRange[] { + return db.prepare( + `SELECT m.test_id AS test_id, r.etichetta AS etichetta, m.valore_num AS valore, + m.unita AS unita, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + JOIN registro_test r ON r.test_id = m.test_id + WHERE m.client_code = ? AND m.fuori_range = 1 + ORDER BY s.data DESC, s.id DESC, m.id DESC` + ).all(code) as MisuraFuoriRange[]; +} From 95ffa08197c8d7406756978694a0d968d5f157ab Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:53:46 +0000 Subject: [PATCH 06/18] =?UTF-8?q?longevity:=20API=20del=20gestionale=20(cl?= =?UTF-8?q?ienti,=20check-up)=20e=20test=20dell'unit=C3=A0=202a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/api/longevity/gestionale/clienti.ts: POST del nuovo fascicolo (nome, cognome, sesso obbligatori; data di nascita/email/telefono facoltativi) → apriFascicolo → 303 alla scheda cliente, o 303 al modulo con ?errore=. pages/api/longevity/gestionale/checkup.ts: POST del check-up manuale, con misureDalForm (funzione pura, esportata e testata) per il parsing del form — virgola decimale accettata, campo vuoto = non misurato, valore non numerico ferma tutto, chiavi che non sono campi f_* attivi ignorate. operatore sempre da locals.user (mai dal corpo, stesso principio del client_code in questionario.ts); eta da etaDelCliente. Entrambi riservati a admin+trainer da RULES in auth.ts (già in vigore, verificato dal test su canAccessPath). tests/longevity/gestionale.test.ts: 17 test — salvaCheckup e il congelamento dei punteggi, il referto che combina check-up e questionario di sessioni diverse (#1), i rifiuti prima della scrittura, le misure fuori range, le versioni parametrizzate dell'anagrafica, apriFascicolo sul singleton (stesso trucco env-var di questionario-pagina.test.ts), misureDalForm, canAccessPath. vitest: 229 passed (212 di partenza + 17). tsc --noEmit: 0 errori. Refs #2 Co-Authored-By: Claude Sonnet 5 --- .../pages/api/longevity/gestionale/checkup.ts | 90 ++++++ .../pages/api/longevity/gestionale/clienti.ts | 56 ++++ .../tests/longevity/gestionale.test.ts | 281 ++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 apps/platforms/src/pages/api/longevity/gestionale/checkup.ts create mode 100644 apps/platforms/src/pages/api/longevity/gestionale/clienti.ts create mode 100644 apps/platforms/tests/longevity/gestionale.test.ts diff --git a/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts new file mode 100644 index 0000000..8ca5c92 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts @@ -0,0 +1,90 @@ +import type { APIRoute } from 'astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { etaDelCliente } from '../../../../lib/longevity/anagrafica'; +import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro'; +import { salvaCheckup } from '../../../../lib/longevity/gestionale'; +import type { MisuraIn } from '../../../../lib/longevity/misure'; + +export const prerender = false; + +// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/). + +const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/; +const REGEX_CODE = /^ISL-\d{4}$/; + +const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } }); + +/** + * Il parsing del form del check-up, isolato dall'I/O (nessun DB, nessuna Request) apposta + * per essere testato senza fixture: riceve i campi ATTIVI (da `testAttivi`, filtrati su + * `f_*` da chi chiama) e il `FormData` grezzo, e produce le misure da passare a + * `salvaCheckup` — o l'errore che ferma tutto PRIMA che una sessione si apra. + * + * Regole, dalla spec (#2): un campo vuoto = non misurato (non un errore, non uno zero); la + * virgola decimale si accetta ("32,5" come "32.5", scrittura comune su un modulo cartaceo); + * un valore non numerico è un errore che blocca l'intero check-up, non solo quel campo — + * meglio fermarsi che salvare a metà con un tacito zero; le chiavi che non sono fra i campi + * attivi (client_code, data, un q_* del questionario, un refuso) si ignorano in silenzio, + * mai un errore per la loro sola presenza — un `` nascosto o un campo dismesso non + * deve rompere il salvataggio del resto. + */ +export function misureDalForm( + form: FormData, + campiAttivi: Pick[] +): { ok: true; misure: MisuraIn[] } | { ok: false; errore: string } { + const perTestId = new Map(campiAttivi.map((c) => [c.test_id, c])); + const misure: MisuraIn[] = []; + + for (const [chiave, valoreGrezzo] of form.entries()) { + const campo = perTestId.get(chiave); + if (!campo) continue; // chiave estranea: ignorata, non è un errore del form + + const testo = String(valoreGrezzo).trim(); + if (testo === '') continue; // vuoto = non misurato + + const numero = Number(testo.replace(',', '.')); + if (!Number.isFinite(numero)) { + return { ok: false, errore: `Valore non numerico per ${chiave}: "${testo}"` }; + } + misure.push({ test_id: chiave, valore_num: numero, unita: campo.unita }); + } + + return { ok: true, misure }; +} + +export const POST: APIRoute = async ({ request, locals }) => { + const form = await request.formData(); + const clientCode = String(form.get('client_code') ?? '').trim(); + const data = String(form.get('data') ?? '').trim(); + + // Senza un codice valido non c'è una scheda a cui tornare: l'unico ritorno sensato è + // l'indice del gestionale, non /cliente/undefined/checkup. + if (!REGEX_CODE.test(clientCode)) { + return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`); + } + const erroreAllaScheda = (testo: string) => + redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?errore=${encodeURIComponent(testo)}`); + + if (!REGEX_DATA.test(data)) return erroreAllaScheda('Data non valida (formato AAAA-MM-GG).'); + const oggi = new Date().toISOString().slice(0, 10); + if (data > oggi) return erroreAllaScheda('La data del check-up non può essere nel futuro.'); + + const db = getLongevityDb(); + const campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_')); + + const parse = misureDalForm(form, campiAttivi); + if (!parse.ok) return erroreAllaScheda(parse.errore); + + // L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware: + // solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del + // client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta. + const operatore = locals.user!.username; + const eta = etaDelCliente(clientCode, data); + + const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure }); + if (!esito.ok) return erroreAllaScheda(esito.errore); + + const params = new URLSearchParams({ salvato: String(esito.sessioneId) }); + if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length)); + return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`); +}; diff --git a/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts b/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts new file mode 100644 index 0000000..e10d011 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts @@ -0,0 +1,56 @@ +import type { APIRoute } from 'astro'; +import { apriFascicolo } from '../../../../lib/longevity/anagrafica'; + +export const prerender = false; + +// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/): +// il middleware nega prima che questo file veda la richiesta. + +const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/; + +const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } }); + +/** Sempre la stessa pagina di partenza: questo endpoint ha un solo modulo che lo chiama + * (pages/longevity/gestionale/nuovo.astro, unità 2b), quindi il ritorno in caso di errore + * non serve dedurlo da un Referer — si scrive qui, una volta. */ +const erroreAlModulo = (testo: string) => + redirect(`/longevity/gestionale/nuovo?errore=${encodeURIComponent(testo)}`); + +function isSesso(v: string): v is 'M' | 'F' { + return v === 'M' || v === 'F'; +} + +export const POST: APIRoute = async ({ request }) => { + const form = await request.formData(); + const leggi = (chiave: string) => String(form.get(chiave) ?? '').trim(); + + const nome = leggi('nome'); + const cognome = leggi('cognome'); + const sesso = leggi('sesso'); + const dataNascita = leggi('data_nascita'); + const email = leggi('email'); + const telefono = leggi('telefono'); + + if (!nome) return erroreAlModulo('Il nome è obbligatorio.'); + if (!cognome) return erroreAlModulo('Il cognome è obbligatorio.'); + if (!isSesso(sesso)) return erroreAlModulo('Il sesso deve essere M o F.'); + + if (dataNascita) { + if (!REGEX_DATA.test(dataNascita)) { + return erroreAlModulo('Data di nascita non valida (formato AAAA-MM-GG).'); + } + // La data del server, non quella (manomettibile) del form: stesso criterio di + // pages/api/longevity/questionario.ts per "oggi". + const oggi = new Date().toISOString().slice(0, 10); + if (dataNascita > oggi) return erroreAlModulo('La data di nascita non può essere nel futuro.'); + } + + const code = apriFascicolo({ + nome, cognome, sesso, + data_nascita: dataNascita || undefined, + email: email || undefined, + telefono: telefono || undefined, + }); + + return redirect(`/longevity/gestionale/cliente/${code}`); +}; diff --git a/apps/platforms/tests/longevity/gestionale.test.ts b/apps/platforms/tests/longevity/gestionale.test.ts new file mode 100644 index 0000000..5e160f7 --- /dev/null +++ b/apps/platforms/tests/longevity/gestionale.test.ts @@ -0,0 +1,281 @@ +// Unità 2a (#2): il modulo del check-up manuale del trainer e le letture dell'anagrafica che +// gli servono. Stesso principio di salvataggio-e2e.test.ts — si parte dal modulo vero +// (salvaCheckup), mai da calcolaSessione/salvaScore chiamati a mano — più la prova che +// questo file esiste apposta a garantire: un check-up e un questionario sono sessioni +// separate (#1, misureValideAlla), quindi il referto del secondo deve vedere il primo. +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { salvaCheckup, sessioniDi, misureFuoriRange } from '../../src/lib/longevity/gestionale'; +import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista'; +import { + creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo, +} from '../../src/lib/longevity/anagrafica'; +import { canAccessPath } from '../../src/lib/auth'; +import { misureDalForm } from '../../src/pages/api/longevity/gestionale/checkup'; + +function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso); + return db; +} + +describe('salvaCheckup scrive la sessione e congela i punteggi da solo', () => { + it('una sessione tipo checkup, misure di fonte manuale, almeno una riga in score', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', + misure: [ + { test_id: 'f_peso_kg', valore_num: 80 }, + { test_id: 'f_handgrip_kg', valore_num: 45 }, + ], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + + const sessione = db.prepare(`SELECT tipo, operatore FROM sessioni WHERE id = ?`).get(esito.sessioneId) as + { tipo: string; operatore: string }; + expect(sessione.tipo).toBe('checkup'); + expect(sessione.operatore).toBe('nicola'); + + const fonti = db.prepare(`SELECT DISTINCT fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as + { fonte: string }[]; + expect(fonti).toEqual([{ fonte: 'manuale' }]); + + const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as + { n: number }; + expect(righeScore.n).toBeGreaterThan(0); + }); + + it('dopo un questionario, il referto del check-up vede sia gli assi del questionario sia quelli fisici (sessioni combinate, #1)', () => { + const db = dbPronto(); + const q = salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-09-01', eta: 38, + risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 }, + consensoSanitario: 'Si', liberi: {}, + }); + expect(q.ok).toBe(true); + + const c = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', + misure: [ + { test_id: 'f_handgrip_kg', valore_num: 48 }, + { test_id: 'f_pushup_rip', valore_num: 24 }, + ], + }); + expect(c.ok).toBe(true); + if (!c.ok) throw new Error(c.errore); + + const referto = refertoDi(db, c.sessioneId); + // 'Recupero & Sistema Nervoso' pesa questionario_sonno (dal questionario di prima, NON + // di questa sessione): la copertura sale sopra zero solo se misureValideAlla porta + // avanti la risposta del 2026-09-01 fino alla sessione del 2026-09-26. + const recupero = referto.assi.find((a) => a.nome === 'Recupero & Sistema Nervoso')!; + expect(recupero.copertura).toBeGreaterThan(0); + // 'Forza & Struttura' vede handgrip e spinta, appena registrati in QUESTA sessione. + const forza = referto.assi.find((a) => a.nome === 'Forza & Struttura')!; + expect(forza.copertura).toBeGreaterThan(0); + }); +}); + +describe('salvaCheckup rifiuta prima di scrivere: nessuna traccia parziale', () => { + it('un test_id sconosciuto al registro: ok:false, nessuna sessione', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_non_esiste', valore_num: 1 }], + }); + expect(esito.ok).toBe(false); + expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); + }); + + it('un q_* del questionario nel check-up: ok:false, nessuna sessione', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', + misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }], + }); + expect(esito.ok).toBe(false); + expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); + }); + + it('nessuna misura: ok:false, nessuna sessione', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [], + }); + expect(esito.ok).toBe(false); + expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); + }); + + it('un soggetto che non esiste in longevity: ok:false, nessuna sessione', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-9999', data: '2026-09-26', eta: null, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(esito.ok).toBe(false); + expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); + }); +}); + +describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () => { + it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureFuoriRange', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 500 }], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + expect(esito.fuoriRange).toEqual(['f_peso_kg']); + + const riga = db.prepare( + `SELECT fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` + ).get(esito.sessioneId) as { fuori_range: number }; + expect(riga.fuori_range).toBe(1); + + const elenco = misureFuoriRange(db, 'ISL-0001'); + expect(elenco.length).toBe(1); + expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore: 500, data: '2026-09-26' }); + }); +}); + +describe('sessioniDi', () => { + it('elenca le sessioni del cliente, più recente prima, con numero misure e fitness age congelata', () => { + const db = dbPronto(); + salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + const seconda = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], + }); + expect(seconda.ok).toBe(true); + + const elenco = sessioniDi(db, 'ISL-0001'); + expect(elenco.length).toBe(2); + expect(elenco[0].data).toBe('2026-09-26'); // la più recente prima + expect(elenco[0].numeroMisure).toBe(1); + // Con una sola misura la copertura di ogni asse resta troppo bassa per una Fitness Age: + // resta null, non un numero congetturato. + expect(elenco[0].fitnessAge).toBeNull(); + }); +}); + +describe('anagrafica per il gestionale, versioni parametrizzate (testabili su :memory:, come codicePerUtente accanto a codicePerUtenteLoggato)', () => { + it('elencoClientiParam ordina per cognome poi nome, e dice chi ha già un accesso', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const anna = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F' }); + const bruno = creaCliente(id, lg, { nome: 'Bruno', cognome: 'Rossi', sesso: 'M', user_id: 7 }); + + const elenco = elencoClientiParam(id); + expect(elenco.map((c) => c.client_code)).toEqual([bruno, anna]); // Rossi prima di Verdi + expect(elenco.find((c) => c.client_code === bruno)!.ha_accesso).toBe(true); + expect(elenco.find((c) => c.client_code === anna)!.ha_accesso).toBe(false); + }); + + it('clienteDaCodiceParam restituisce il dettaglio, null se il codice non esiste', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F', email: 'a@b.it' }); + + const dettaglio = clienteDaCodiceParam(id, code); + expect(dettaglio?.email).toBe('a@b.it'); + expect(dettaglio?.ha_accesso).toBe(false); + expect(clienteDaCodiceParam(id, 'ISL-9999')).toBeNull(); + }); + + it('etaDelClienteParam calcola l età alla data, null senza data di nascita in anagrafica', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const conData = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F', data_nascita: '1988-03-04' }); + const senzaData = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); + + expect(etaDelClienteParam(id, conData, '2026-08-21')).toBe(38); + expect(etaDelClienteParam(id, senzaData, '2026-08-21')).toBeNull(); + }); +}); + +describe('apriFascicolo, la versione operativa sul singleton', () => { + it('apre un fascicolo nuovo senza mai collegare uno user_id (l accesso è #8, non questa unità)', () => { + const dir = mkdtempSync(join(tmpdir(), 'lg-gestionale-test-')); + const idPath = join(dir, 'identity.db'); + const lgPath = join(dir, 'longevity.db'); + const primaId = process.env.IDENTITY_DB_PATH; + const primaLg = process.env.LONGEVITY_DB_PATH; + try { + // Stesso trucco di tests/longevity/questionario-pagina.test.ts: il singleton di + // getIdentityDb/getLongevityDb si crea alla prima chiamata, quindi le variabili + // d'ambiente vanno impostate PRIMA — e questo deve restare l'unico test del file + // che chiama una funzione dell'anagrafica sul singleton, non su un DB :memory: proprio. + process.env.IDENTITY_DB_PATH = idPath; + process.env.LONGEVITY_DB_PATH = lgPath; + + const code = apriFascicolo({ nome: 'Nuovo', cognome: 'Cliente', sesso: 'F' }); + expect(code).toMatch(/^ISL-\d{4}$/); + + const id = createIdentityDb(idPath); + const riga = id.prepare(`SELECT user_id FROM clienti WHERE client_code = ?`).get(code) as + { user_id: number | null }; + expect(riga.user_id).toBeNull(); + } finally { + if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId; + if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('misureDalForm, il parsing puro del modulo di check-up', () => { + const campiAttivi = [ + { test_id: 'f_peso_kg', unita: 'kg' }, + { test_id: 'f_handgrip_kg', unita: 'kg' }, + ]; + + function formCon(dati: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(dati)) f.set(k, v); + return f; + } + + it('accetta la virgola decimale', () => { + const r = misureDalForm(formCon({ f_peso_kg: '80,5' }), campiAttivi); + expect(r.ok).toBe(true); + if (r.ok) expect(r.misure).toEqual([{ test_id: 'f_peso_kg', valore_num: 80.5, unita: 'kg' }]); + }); + + it('un campo vuoto = non misurato, nessuna misura per quella chiave', () => { + const r = misureDalForm(formCon({ f_peso_kg: '', f_handgrip_kg: '45' }), campiAttivi); + expect(r.ok).toBe(true); + if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg']); + }); + + it('un valore non numerico ferma tutto: ok:false', () => { + const r = misureDalForm(formCon({ f_peso_kg: 'ottanta' }), campiAttivi); + expect(r.ok).toBe(false); + }); + + it('una chiave che non è fra i campi attivi (client_code, un q_*, un refuso) viene ignorata in silenzio', () => { + const r = misureDalForm( + formCon({ client_code: 'ISL-0001', q_ore_sonno: '7', f_peso_kg: '80' }), + campiAttivi + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_peso_kg']); + }); +}); + +describe('canAccessPath: il check-up è del trainer, non del cliente', () => { + it('/api/longevity/gestionale/checkup nega il cliente, ammette il trainer', () => { + expect(canAccessPath('cliente', '/api/longevity/gestionale/checkup')).toBe(false); + expect(canAccessPath('trainer', '/api/longevity/gestionale/checkup')).toBe(true); + }); +}); From f096807ed6ba3ffc76a3c36b0ea6c4457cab4a1b Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 12:58:17 +0000 Subject: [PATCH 07/18] longevity: schema misure con verifica/verificata_da/verificata_at + migrazione Aggiunge le tre colonne allo schema (db.ts) e una funzione migra() idempotente che le aggiunge via ALTER TABLE a un longevity.db esistente (PRAGMA table_info per capire cosa manca): CREATE TABLE IF NOT EXISTS non tocca una tabella gia' creata, e in produzione un longevity.db esiste dal 03/09/2026. misureValideAlla (motore/index.ts) lascia entrare anche le misure fuori range confermate (verifica = 'confermata'), non solo quelle nel range. Refs #7 Co-Authored-By: Claude Sonnet 5 --- apps/platforms/src/lib/longevity/db.ts | 38 +++++++++++++++++++ .../src/lib/longevity/motore/index.ts | 14 ++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/platforms/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts index 703f592..8621879 100644 --- a/apps/platforms/src/lib/longevity/db.ts +++ b/apps/platforms/src/lib/longevity/db.ts @@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure ( fonte TEXT NOT NULL CHECK (fonte IN ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), fuori_range INTEGER NOT NULL DEFAULT 0, + -- Verifica di una misura fuori range (issue #7): il trainer la conferma (entra nello + -- score come le altre, vedi misureValideAlla in motore/index.ts) o la scarta (resta + -- esclusa, per sempre). NULL = non ancora verificata. Su un longevity.db creato PRIMA + -- di questa modifica queste tre colonne NON ci sono: le aggiunge migra(), qui sotto. + verifica TEXT CHECK (verifica IN ('confermata','scartata')), + verificata_da TEXT, + verificata_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); @@ -128,6 +135,36 @@ function apri(p: string, schema: string): Database.Database { return db; } +/** + * Migrazione idempotente per un `longevity.db` aperto PRIMA di questa modifica (issue #7): + * `CREATE TABLE IF NOT EXISTS` (sopra) non aggiunge colonne a una tabella che esiste già, e + * in produzione un longevity.db esiste dal 03/09/2026 (VPS, + * /opt/docker/insanitylab-apps/apps/platforms/data/longevity.db) — la stessa trappola già + * documentata sul vincolo di `score`, qui sotto. Senza questa funzione le tre colonne di + * verifica resterebbero assenti in produzione mentre ogni database creato da zero (ogni + * test, e un domani un secondo deploy) le avrebbe già: un disallineamento silenzioso fra + * ambienti, non un errore che si vede subito. + * `PRAGMA table_info` legge le colonne DAVVERO presenti sul file aperto, non lo schema + * dichiarato sopra: per ognuna delle tre assente, un `ALTER TABLE ... ADD COLUMN` la + * aggiunge (nullable, quindi le righe esistenti restano valide contro il CHECK — NULL non + * fa mai fallire un CHECK). Su un database che le ha già il controllo salta tutto: girarla + * a ogni apertura (come seedRegistro/seedPesi) è sicuro. + */ +function migra(db: Database.Database): void { + const colonne = new Set( + (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name) + ); + if (!colonne.has('verifica')) { + db.exec(`ALTER TABLE misure ADD COLUMN verifica TEXT CHECK (verifica IN ('confermata','scartata'))`); + } + if (!colonne.has('verificata_da')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_da TEXT`); + } + if (!colonne.has('verificata_at')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_at TEXT`); + } +} + /** * ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database. * @@ -147,6 +184,7 @@ function apri(p: string, schema: string): Database.Database { */ export function createLongevityDb(path?: string): Database.Database { const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); + migra(db); seedRegistro(db); seedPesi(db, MODEL_VERSION); return db; diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index 2e072ad..d367869 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -40,8 +40,8 @@ * applicarla, e restituisce `null` se il registro (dato modificabile senza * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava * `lerp` con estremi indefiniti; e la lettura delle misure esclude - * `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma - * (punto aperto in §12). + * `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer + * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`). */ import type Database from 'better-sqlite3'; @@ -156,9 +156,11 @@ export const FINESTRA_GIORNI: number | null = null; * * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da - * una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7): - * quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate, - * non tolto del tutto. + * una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`, + * colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel + * momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione + * successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta + * esclusa come prima. */ export function misureValideAlla(db: Database.Database, sessioneId: number): Map { const sessione = db.prepare( @@ -170,7 +172,7 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data FROM misure m JOIN sessioni s2 ON s2.id = m.sessione_id - WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL + WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) ORDER BY s2.data DESC, s2.id DESC, m.id DESC` ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as From 77829b2d12719ad11bd66b882e306907a89940ff Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:00:48 +0000 Subject: [PATCH 08/18] =?UTF-8?q?longevity:=20crea=20l'accesso=20al=20sito?= =?UTF-8?q?=20per=20un=20cliente=20che=20ha=20gi=C3=A0=20il=20fascicolo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accessoDi/collegaAccesso in anagrafica.ts (e le varianti sul singleton accessoDelCliente/collegaAccessoCliente) leggono e scrivono il legame clienti.user_id restando l'unico punto che apre identity. accesso.ts aggiunge creaAccessoCliente: crea l'utente cliente, lo collega, e se il collegamento fallisce cancella l'utente appena creato (compensazione, due database, stesso schema di creaCliente). Nuova pagina del gestionale pages/longevity/gestionale/cliente/[code]/accesso.astro: genera username e password una volta sola, niente redirect, no-store. Refs #8 Co-Authored-By: Claude Sonnet 5 --- apps/platforms/src/lib/longevity/accesso.ts | 88 +++++++++++ .../platforms/src/lib/longevity/anagrafica.ts | 40 +++++ .../gestionale/cliente/[code]/accesso.astro | 82 ++++++++++ .../platforms/tests/longevity/accesso.test.ts | 140 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/accesso.ts create mode 100644 apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro create mode 100644 apps/platforms/tests/longevity/accesso.test.ts diff --git a/apps/platforms/src/lib/longevity/accesso.ts b/apps/platforms/src/lib/longevity/accesso.ts new file mode 100644 index 0000000..51801de --- /dev/null +++ b/apps/platforms/src/lib/longevity/accesso.ts @@ -0,0 +1,88 @@ +// +// Crea l'account di accesso al sito per un cliente che ha GIA' un fascicolo aperto +// (il percorso inverso di crea-fascicolo.ts, che parte da un utente esistente e apre il +// fascicolo). Qui si parte dal fascicolo — creato dal trainer al check-up, spesso prima +// che il cliente abbia un account — e si crea l'utente che gli permette di entrare da +// /longevity a leggere il proprio referto e compilare il questionario. +// +// ⚠️ Non apre identity da sé: passa dal solo modulo autorizzato a farlo (anagrafica.ts), +// che è anche l'unico a sapere cosa sia "un accesso" per un fascicolo. Questo file tocca +// due database (users in piattaforme.db, il collegamento in identity.db) ma non li apre +// mai insieme: riceve `utenti` da fuori e delega identity alle funzioni di anagrafica.ts. +import type Database from 'better-sqlite3'; +import { getDb } from '../db'; +import { createUser, deleteUser, randomPassword } from '../auth'; +import { accessoDelCliente, collegaAccessoCliente } from './anagrafica'; + +/** Solo lettere minuscole, cifre e ".", "_", "@", "-": stesso alfabeto permissivo di create-user.mjs. */ +const USERNAME_VALIDO = /^[a-z0-9._@-]{3,64}$/; + +/** + * Le dipendenze di creaAccessoCliente, iniettabili per i test (DB in memoria, doppi finti + * per accessoDi/collega) invece del singleton reale. In produzione si passa `undefined` e + * si prende il default qui sotto — lo stesso schema del singleton di getDb(): una + * connessione per processo, non riaperta a ogni chiamata. + */ +export interface DipendenzeAccesso { + utenti: Database.Database; + accessoDi: (code: string) => { userId: number | null; email: string | null } | null; + collega: (code: string, userId: number) => boolean; +} + +function dipendenzeDiDefault(): DipendenzeAccesso { + return { utenti: getDb(), accessoDi: accessoDelCliente, collega: collegaAccessoCliente }; +} + +export type EsitoAccesso = + | { ok: true; username: string; password: string } + | { ok: false; errore: string }; + +/** + * Crea l'utente `cliente` e lo collega al fascicolo `code`. Ordine dei controlli: prima + * tutto ciò che si può verificare SENZA scrivere (username valido, fascicolo esistente e + * ancora senza accesso, username libero), poi si crea l'utente, poi si collega. + * + * Se il collegamento fallisce (fascicolo sparito o già collegato nel frattempo, o + * l'update lancia per un motivo diverso) l'utente appena creato si cancella: due database + * distinti, quindi nessuna transazione unica possibile — stessa compensazione di + * creaCliente in anagrafica.ts. Un utente `cliente` senza fascicolo collegato non + * servirebbe a niente e resterebbe un accesso fantasma. + * + * La password è generata qui (randomPassword, mai scelta da chi chiama) e torna in + * chiaro UNA volta sola nell'esito: non viene salvata da nessuna parte se non nel suo + * hash dentro users.password_hash. Chi chiama non la deve loggare. + */ +export function creaAccessoCliente( + deps: DipendenzeAccesso = dipendenzeDiDefault(), + { code, username }: { code: string; username: string } +): EsitoAccesso { + const nome = username.trim().toLowerCase(); + if (!USERNAME_VALIDO.test(nome)) { + return { ok: false, errore: 'Username non valido: 3-64 caratteri fra lettere minuscole, cifre, ". _ @ -".' }; + } + + const fascicolo = deps.accessoDi(code); + if (!fascicolo) return { ok: false, errore: `Nessun fascicolo con codice ${code}.` }; + if (fascicolo.userId !== null) { + return { ok: false, errore: 'Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.' }; + } + + const usernameOccupato = deps.utenti.prepare('SELECT 1 FROM users WHERE username = ?').get(nome); + if (usernameOccupato) return { ok: false, errore: `L'username "${nome}" è già in uso.` }; + + const password = randomPassword(); + const userId = createUser(deps.utenti, nome, password, 'cliente'); + + let collegato: boolean; + try { + collegato = deps.collega(code, userId); + } catch { + collegato = false; + } + if (!collegato) { + deleteUser(deps.utenti, userId); + return { ok: false, errore: 'Il fascicolo non è più collegabile (cancellato o già collegato nel frattempo).' }; + } + + return { ok: true, username: nome, password }; +} diff --git a/apps/platforms/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts index cfea267..e103022 100644 --- a/apps/platforms/src/lib/longevity/anagrafica.ts +++ b/apps/platforms/src/lib/longevity/anagrafica.ts @@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null { return codicePerUtente(getIdentityDb(), userId); } +/** + * Lo stato di accesso di un fascicolo: chi (se qualcuno) lo può aprire da sito, e l'email + * già in anagrafica (serve solo a precompilare l'username in fase di creazione, non è + * usata per altro). `null` vuol dire codice inesistente — diverso da "esiste ma senza + * accesso", che è `{ userId: null, email }`. + */ +export function accessoDi( + identity: Database.Database, + code: string +): { userId: number | null; email: string | null } | null { + const r = identity.prepare(`SELECT user_id, email FROM clienti WHERE client_code = ?`).get(code) as + { user_id: number | null; email: string | null } | undefined; + if (!r) return null; + return { userId: r.user_id, email: r.email }; +} + +/** Come accessoDi, ma sulla connessione singleton: per il gestionale, che identity non la apre mai da sé. */ +export function accessoDelCliente(code: string): { userId: number | null; email: string | null } | null { + return accessoDi(getIdentityDb(), code); +} + +/** + * Lega un utente già creato al fascicolo: vero solo se il fascicolo esiste ed era ancora + * senza accesso (`user_id IS NULL` nel WHERE, non un controllo prima separato) — così due + * chiamate concorrenti sullo stesso fascicolo non possono collegare due utenti diversi, + * la seconda trova zero righe e torna false. Chi chiama con false deve considerare + * l'utente appena creato orfano e cancellarlo (vedi accesso.ts). + */ +export function collegaAccesso(identity: Database.Database, code: string, userId: number): boolean { + const res = identity.prepare( + `UPDATE clienti SET user_id = ? WHERE client_code = ? AND user_id IS NULL` + ).run(userId, code); + return res.changes === 1; +} + +/** Come collegaAccesso, ma sulla connessione singleton. */ +export function collegaAccessoCliente(code: string, userId: number): boolean { + return collegaAccesso(getIdentityDb(), code, userId); +} + /** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */ export function etaAllaData(dataNascita: string, alla: string): number { const n = new Date(dataNascita); diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro new file mode 100644 index 0000000..f27e423 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro @@ -0,0 +1,82 @@ +--- +// Crea l'accesso al sito per un cliente che ha già il fascicolo: il trainer sceglie +// l'username, la password la genera il sistema e si mostra UNA volta sola, qui, mai +// via redirect (finirebbe nella query string dell'URL e nella cronologia del browser). +// ⚠️ Questa pagina non apre mai il database anagrafico da sé: passa solo da +// anagrafica.ts e accesso.ts, gli unici moduli autorizzati (li sorveglia un test). +import Longevity from '../../../../../layouts/Longevity.astro'; +import { accessoDelCliente } from '../../../../../lib/longevity/anagrafica'; +import { creaAccessoCliente } from '../../../../../lib/longevity/accesso'; + +export const prerender = false; + +const { code } = Astro.params as { code: string }; + +// La password passa da qui una volta sola: niente cache, né del browser né di un proxy +// davanti a Node, che altrimenti la servirebbe di nuovo a chi ripete la stessa GET. +Astro.response.headers.set('Cache-Control', 'no-store'); + +const fascicolo = accessoDelCliente(code); +if (!fascicolo) Astro.response.status = 404; + +let errore = ''; +let creato: { username: string; password: string } | null = null; + +if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + const username = String(form.get('username') ?? ''); + const esito = creaAccessoCliente(undefined, { code, username }); + if (esito.ok) creato = { username: esito.username, password: esito.password }; + else errore = esito.errore; +} +--- + + {!fascicolo && ( +
+

Nessun fascicolo con codice {code}.

+
+ )} + + {fascicolo && fascicolo.userId !== null && ( +
+

Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.

+
+ )} + + {fascicolo && fascicolo.userId === null && creato && ( +
+

Accesso creato. Comunicala al cliente: non verrà mostrata di nuovo.

+

Utente: {creato.username}

+

Password: {creato.password}

+
+ )} + + {fascicolo && fascicolo.userId === null && !creato && ( +
+ {errore &&

{errore}

} +
+ Nome utente + +
+
+ +
+
+ )} + + +
diff --git a/apps/platforms/tests/longevity/accesso.test.ts b/apps/platforms/tests/longevity/accesso.test.ts new file mode 100644 index 0000000..3d50649 --- /dev/null +++ b/apps/platforms/tests/longevity/accesso.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createDb } from '../../src/lib/db'; +import { login } from '../../src/lib/auth'; +import { createIdentityDb, createLongevityDb } from '../../src/lib/longevity/db'; +import { creaCliente, accessoDi, collegaAccesso } from '../../src/lib/longevity/anagrafica'; +import { creaAccessoCliente, type DipendenzeAccesso } from '../../src/lib/longevity/accesso'; + +// Doppio delle dipendenze su DB in memoria: come in anagrafica.test.ts, connessioni proprie +// passate a mano, mai il singleton reale — questo è l'unico modo in cui accesso.ts si +// prova senza toccare data/identity.db o data/piattaforme.db. +function dipendenzeDiTest(identity: ReturnType, utenti: ReturnType): DipendenzeAccesso { + return { + utenti, + accessoDi: (code) => accessoDi(identity, code), + collega: (code, userId) => collegaAccesso(identity, code, userId), + }; +} + +function fascicoloPronto() { + const identity = createIdentityDb(':memory:'); + const longevity = createLongevityDb(':memory:'); + const utenti = createDb(':memory:'); + const code = creaCliente(identity, longevity, { nome: 'Mario', cognome: 'Rossi', sesso: 'M' }); + return { identity, longevity, utenti, code }; +} + +describe('creaAccessoCliente', () => { + it('crea l utente cliente, lo collega, e la password permette il login', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const esito = creaAccessoCliente(deps, { code, username: 'MarioRossi' }); + expect(esito.ok).toBe(true); + if (!esito.ok) return; + + expect(esito.username).toBe('mariorossi'); // normalizzato minuscolo + + const riga = utenti.prepare('SELECT id, password_hash, role FROM users WHERE username = ?').get('mariorossi') as + { id: number; password_hash: string; role: string }; + expect(riga.role).toBe('cliente'); + // la password torna in chiaro nell'esito, ma non è quella salvata: quella è l'hash. + expect(riga.password_hash).not.toBe(esito.password); + + // il collegamento è avvenuto davvero: accessoDi lo ritrova. + const dopo = accessoDi(identity, code); + expect(dopo?.userId).toBe(riga.id); + + // e la password restituita fa davvero login. + const token = login(utenti, 'mariorossi', esito.password); + expect(token).not.toBeNull(); + }); + + it('fascicolo già con un accesso: errore, nessun utente creato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const primo = creaAccessoCliente(deps, { code, username: 'uno' }); + expect(primo.ok).toBe(true); + + const secondo = creaAccessoCliente(deps, { code, username: 'due' }); + expect(secondo.ok).toBe(false); + + const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number }; + expect(utentiDopo.n).toBe(1); // solo il primo, "due" non è mai stato creato + }); + + it('codice inesistente: errore', () => { + const { identity, utenti } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const esito = creaAccessoCliente(deps, { code: 'ISL-9999', username: 'fantasma' }); + expect(esito.ok).toBe(false); + }); + + it('username già esistente: errore, niente collegato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + utenti.prepare("INSERT INTO users (username, password_hash, role) VALUES ('occupato', 'x', 'admin')").run(); + + const esito = creaAccessoCliente(deps, { code, username: 'occupato' }); + expect(esito.ok).toBe(false); + + const fascicolo = accessoDi(identity, code); + expect(fascicolo?.userId).toBeNull(); // resta senza accesso + }); + + it('username non valido (maiuscole non ammesse dopo la normalizzazione, o troppo corto): errore', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const corto = creaAccessoCliente(deps, { code, username: 'ab' }); + expect(corto.ok).toBe(false); + + const conSpazio = creaAccessoCliente(deps, { code, username: 'ha spazi' }); + expect(conSpazio.ok).toBe(false); + + const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number }; + expect(utentiDopo.n).toBe(0); + }); + + it('se il collegamento fallisce, l utente appena creato viene cancellato (compensazione)', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps: DipendenzeAccesso = { + utenti, + accessoDi: (c) => accessoDi(identity, c), + collega: () => false, // simula un collegamento rifiutato (es. fascicolo cambiato nel frattempo) + }; + + const esito = creaAccessoCliente(deps, { code, username: 'sfortunato' }); + expect(esito.ok).toBe(false); + + const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato'); + expect(rimasto).toBeUndefined(); // nessun utente orfano + }); + + it('se il collegamento lancia, l utente appena creato viene comunque cancellato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps: DipendenzeAccesso = { + utenti, + accessoDi: (c) => accessoDi(identity, c), + collega: () => { throw new Error('identity irraggiungibile'); }, + }; + + const esito = creaAccessoCliente(deps, { code, username: 'sfortunato2' }); + expect(esito.ok).toBe(false); + + const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato2'); + expect(rimasto).toBeUndefined(); + }); + + it('rete secondaria: la pagina del gestionale non mette la password in cache e non nomina identity', () => { + const path = join(process.cwd(), 'src/pages/longevity/gestionale/cliente/[code]/accesso.astro'); + const src = readFileSync(path, 'utf8'); + expect(src).toContain('no-store'); + expect(src).not.toContain('Identity'); + expect(src).not.toContain('.prepare('); + }); +}); From 922ab40eb2c60188d2b79f6681c440b8f609f481 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:02:16 +0000 Subject: [PATCH 09/18] longevity: verificaMisura, misureDaVerificare, endpoint di verifica dal gestionale misure.ts: misureDaVerificare(db, clientCode) legge le misure fuori range mai verificate; verificaMisura(db, {misuraId, esito, operatore}) conferma o scarta. La conferma ricalcola e ricongela lo score (salvaScore + calcolaSessione) per la sessione della misura e ogni sessione successiva dello stesso cliente, che puo' averla ereditata via misureValideAlla; lo scarto non tocca score. Rifiuta misura inesistente, gia' nel range, o gia' verificata. pages/api/longevity/gestionale/verifica.ts: POST da form (misura_id, esito), operatore dalla sessione (locals.user.username). Il client_code del redirect si legge dalla misura nel database, mai dal form. tests/longevity/verifica-misure.test.ts: migrazione (ALTER TABLE su un longevity.db vecchio, idempotente), conferma che ricalcola la sessione e le successive con sessioniRicalcolate, scarto che non scrive in score, i rifiuti (doppia verifica, misura nel range, id inesistente), canAccessPath per cliente/trainer sulla nuova rotta. Suite: 219 passed (212 + 7 nuovi). tsc --noEmit: 0 errori. Refs #7 Co-Authored-By: Claude Sonnet 5 --- apps/platforms/src/lib/longevity/misure.ts | 103 ++++++++ .../api/longevity/gestionale/verifica.ts | 52 ++++ .../tests/longevity/verifica-misure.test.ts | 226 ++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 apps/platforms/src/pages/api/longevity/gestionale/verifica.ts create mode 100644 apps/platforms/tests/longevity/verifica-misure.test.ts diff --git a/apps/platforms/src/lib/longevity/misure.ts b/apps/platforms/src/lib/longevity/misure.ts index dbad9d6..0c378e3 100644 --- a/apps/platforms/src/lib/longevity/misure.ts +++ b/apps/platforms/src/lib/longevity/misure.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { esisteTest } from './registro'; +import { calcolaSessione, salvaScore } from './motore'; export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; @@ -72,3 +73,105 @@ export function registraMisure( return { scritte: misure.length, fuoriRange }; } + +/** Una misura fuori range in attesa che il trainer decida se e' un dato vero o uno + * strumento/protocollo sbagliato (§9 della spec). L'etichetta e il range vengono dal + * registro (dato di prodotto), non duplicati qui; `data` e' quella della SESSIONE in cui + * la misura e' stata presa, utile a ordinare la lista senza un altro giro sul database. */ +export type MisuraDaVerificare = { + id: number; + test_id: string; + etichetta: string; + valore_num: number | null; + unita: string | null; + range_min: number | null; + range_max: number | null; + data: string; +}; + +/** + * Le misure di un cliente ancora da verificare: fuori range (`fuori_range = 1`) e mai + * verificate (`verifica IS NULL`) — una gia' confermata o scartata non torna qui, la + * decisione del trainer e' definitiva (vedi `verificaMisura`). Ordine per data della + * sessione decrescente: la piu' recente prima, come le altre liste del gestionale. + */ +export function misureDaVerificare(db: Database.Database, clientCode: string): MisuraDaVerificare[] { + return db.prepare( + `SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, + m.valore_num AS valore_num, m.unita AS unita, + r.range_min AS range_min, r.range_max AS range_max, + s.data AS data + FROM misure m + JOIN registro_test r ON r.test_id = m.test_id + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.client_code = ? AND m.fuori_range = 1 AND m.verifica IS NULL + ORDER BY s.data DESC, s.id DESC, m.id DESC` + ).all(clientCode) as MisuraDaVerificare[]; +} + +export type EsitoVerifica = + | { ok: true; sessioniRicalcolate: number[] } + | { ok: false; errore: string }; + +/** + * La decisione del trainer su una misura fuori range: confermarla (e' un dato vero, deve + * contare) o scartarla (e' uno strumento/protocollo sbagliato, resta fuori per sempre). + * Rifiuta una misura inesistente, una gia' dentro il range (non c'era niente da + * verificare) o gia' verificata (la decisione non si cambia scrivendoci sopra — chi + * sbaglia riapre una nuova misura, non riscrive la storia di questa). + * + * Solo la CONFERMA ricalcola: da quel momento `misureValideAlla` la lascia passare, quindi + * ogni punteggio congelato che l'ha ignorata e' cambiato sotto di lui. Si ricongela la + * sessione della misura stessa e ogni sessione SUCCESSIVA dello stesso cliente (stesso + * criterio data/id di `misureValideAlla`): anche quelle possono aver ereditato il valore + * — o la sua assenza — tramite la propagazione in avanti. Una sessione precedente non puo' + * mai vedere una misura futura, quindi non ha bisogno di ricalcolo. Lo SCARTO non tocca + * `score`: la misura restava esclusa anche prima, niente e' cambiato per il motore. + */ +export function verificaMisura( + db: Database.Database, + input: { misuraId: number; esito: 'confermata' | 'scartata'; operatore: string } +): EsitoVerifica { + const misura = db.prepare( + `SELECT m.id AS id, m.client_code AS client_code, m.sessione_id AS sessione_id, + m.fuori_range AS fuori_range, m.verifica AS verifica, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.id = ?` + ).get(input.misuraId) as + { id: number; client_code: string; sessione_id: number; fuori_range: number; verifica: string | null; data: string } + | undefined; + + if (!misura) return { ok: false, errore: `misura ${input.misuraId} inesistente` }; + if (misura.fuori_range === 0) return { ok: false, errore: 'la misura e nel range: non c e niente da verificare' }; + if (misura.verifica !== null) return { ok: false, errore: 'misura gia verificata' }; + + const sessioniRicalcolate: number[] = []; + + const tx = db.transaction(() => { + db.prepare( + `UPDATE misure SET verifica = ?, verificata_da = ?, verificata_at = datetime('now') WHERE id = ?` + ).run(input.esito, input.operatore, misura.id); + + if (input.esito === 'confermata') { + // La sessione della misura stessa (id = sessione_id) piu' ogni sessione successiva + // dello stesso cliente (data > D, o stessa data e id maggiore) — stesso confronto + // di misureValideAlla, cosi' la platea dei ricalcoli e' esattamente quella dei + // punteggi che possono essere cambiati da questa conferma. + const sessioni = db.prepare( + `SELECT id FROM sessioni + WHERE client_code = ? + AND (id = ? OR data > ? OR (data = ? AND id > ?)) + ORDER BY data ASC, id ASC` + ).all(misura.client_code, misura.sessione_id, misura.data, misura.data, misura.sessione_id) as { id: number }[]; + + for (const s of sessioni) { + salvaScore(db, s.id, calcolaSessione(db, s.id)); + sessioniRicalcolate.push(s.id); + } + } + }); + tx(); + + return { ok: true, sessioniRicalcolate }; +} diff --git a/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts new file mode 100644 index 0000000..d7f9ed5 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts @@ -0,0 +1,52 @@ +import type { APIRoute } from 'astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { verificaMisura } from '../../../../lib/longevity/misure'; + +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** + * La decisione del trainer su una misura fuori range (issue #7): confermarla o scartarla, + * dalla scheda cliente del gestionale (le pagine arrivano con #2, questo endpoint no). + * `canAccessPath` (auth.ts) gia' chiude /api/longevity/gestionale/** a chi non e' trainer + * o admin: qui non c'e' nessun controllo di ruolo da ripetere. + * + * Il codice cliente per il redirect si legge SEMPRE dalla misura nel database, mai da un + * campo del form — stesso principio di questionario.ts (il client_code non si fida di chi + * scrive la richiesta), applicato qui al lato trainer: un id di misura non e' un fascicolo + * che si possa dirottare scrivendo un altro codice nel form. + */ +export const POST: APIRoute = async ({ request, locals }) => { + const form = await request.formData(); + + const misuraIdRaw = form.get('misura_id'); + const misuraId = typeof misuraIdRaw === 'string' ? Number(misuraIdRaw) : NaN; + if (!Number.isInteger(misuraId)) return json(404, { error: 'misura inesistente' }); + + const db = getLongevityDb(); + + // Si legge il client_code PRIMA di chiamare verificaMisura: serve per il redirect anche + // quando la verifica viene rifiutata (gia' verificata, nel range...) — l'unico caso senza + // una scheda cliente su cui tornare e' proprio l'id che non esiste. + const riga = db.prepare(`SELECT client_code FROM misure WHERE id = ?`).get(misuraId) as + { client_code: string } | undefined; + if (!riga) return json(404, { error: `misura ${misuraId} inesistente` }); + + const tornaA = (query: string): Response => new Response(null, { + status: 303, + headers: { Location: `/longevity/gestionale/cliente/${riga.client_code}${query}` }, + }); + + const esitoRaw = form.get('esito'); + const esito: 'confermata' | 'scartata' | null = + esitoRaw === 'confermata' || esitoRaw === 'scartata' ? esitoRaw : null; + if (esito === null) { + return tornaA(`?errore=${encodeURIComponent('Esito non valido: deve essere confermata o scartata.')}`); + } + + const risultato = verificaMisura(db, { misuraId, esito, operatore: locals.user!.username }); + if (!risultato.ok) return tornaA(`?errore=${encodeURIComponent(risultato.errore)}`); + return tornaA(`?verificata=${esito}`); +}; diff --git a/apps/platforms/tests/longevity/verifica-misure.test.ts b/apps/platforms/tests/longevity/verifica-misure.test.ts new file mode 100644 index 0000000..d5e44a1 --- /dev/null +++ b/apps/platforms/tests/longevity/verifica-misure.test.ts @@ -0,0 +1,226 @@ +// Issue #7: verifica delle misure fuori range (confermata/scartata), migrazione dello +// schema, e ricalcolo dei punteggi congelati che una conferma cambia. +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure, verificaMisura } from '../../src/lib/longevity/misure'; +import { leggiScore, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; +import { canAccessPath } from '../../src/lib/auth'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('migrazione: un longevity.db esistente riceve le tre colonne di verifica', () => { + it('aggiunge verifica/verificata_da/verificata_at via ALTER TABLE, senza perdere le righe', () => { + const dir = mkdtempSync(join(tmpdir(), 'lg-migra-test-')); + const path = join(dir, 'longevity.db'); + try { + // Schema PRIMA di questa issue: la tabella misure senza le colonne di verifica — + // lo stesso longevity.db che gira in produzione dal 03/09/2026 (vedi commento su + // migra() in db.ts). Costruito a mano, non con createLongevityDb, che oggi crea + // gia' le colonne nuove. + // Le altre tabelle sono quelle vere (SCHEMA_LONGEVITY in db.ts, invariate da questa + // issue): solo `misure` qui e' la versione vecchia, senza le tre colonne. Con uno + // schema ridotto a mano seedRegistro/seedPesi (chiamati da createLongevityDb subito + // dopo la migrazione) fallirebbero su colonne che si aspettano e non trovano. + const vecchio = new Database(path); + vecchio.pragma('foreign_keys = ON'); + vecchio.exec(` + CREATE TABLE 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 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 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 TABLE 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')) + ); + `); + vecchio.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + vecchio.prepare( + `INSERT INTO registro_test (test_id, etichetta, tipo_valore, attivo_da) VALUES ('q_ore_sonno','Ore di sonno','num','2026-01-01')` + ).run(); + vecchio.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001','2026-08-21','questionario')`).run(); + vecchio.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte, fuori_range) + VALUES (1, 'ISL-0001', 'q_ore_sonno', 26, 'questionario', 1)` + ).run(); + vecchio.close(); + + const db = createLongevityDb(path); + const colonne = (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name); + expect(colonne).toEqual(expect.arrayContaining(['verifica', 'verificata_da', 'verificata_at'])); + + const riga = db.prepare( + `SELECT valore_num, fuori_range, verifica FROM misure WHERE test_id = 'q_ore_sonno'` + ).get() as { valore_num: number; fuori_range: number; verifica: string | null }; + expect(riga.valore_num).toBe(26); + expect(riga.fuori_range).toBe(1); + expect(riga.verifica).toBeNull(); + db.close(); + + // Riaprirla una seconda volta (le colonne ci sono gia') non deve fallire: migra() + // deve saltare l'ALTER TABLE quando non serve, non ripeterlo alla cieca. + const db2 = createLongevityDb(path); + expect(() => db2.prepare(`SELECT verifica, verificata_da, verificata_at FROM misure`).all()).not.toThrow(); + db2.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('verificaMisura: conferma', () => { + it('una misura fuori range confermata entra nello score, e la sessione successiva che la eredita viene ricalcolata', () => { + const db = dbPronto(); + + const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); + registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); // fuori range (0-14) + salvaScore(db, s1, calcolaSessione(db, s1)); + + const s2 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-15', tipo: 'questionario' }); + salvaScore(db, s2, calcolaSessione(db, s2)); + + // Baseline: 'questionario_sonno' pesa 0.20 nell'asse 'Recupero & Sistema Nervoso' + // (registro.ts, PESI_ASSE) e non e' coperto ne' da s1 (misura esclusa, fuori range non + // confermato) ne' da s2 (che la erediterebbe da s1 se fosse valida). + const asse = 'Recupero & Sistema Nervoso'; + expect(leggiScore(db, s1).assi[asse].copertura).toBe(0); + expect(leggiScore(db, s2).assi[asse].copertura).toBe(0); + + const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; + const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); + + expect(esito.ok).toBe(true); + if (esito.ok) expect(esito.sessioniRicalcolate.sort((a, b) => a - b)).toEqual([s1, s2].sort((a, b) => a - b)); + + const rigaMisura = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as + { verifica: string; verificata_da: string }; + expect(rigaMisura.verifica).toBe('confermata'); + expect(rigaMisura.verificata_da).toBe('nicola'); + + // Dopo la conferma la copertura sale a 0.20 (il peso di 'questionario_sonno') sia + // nella sessione della misura sia in quella successiva, che la eredita da + // misureValideAlla — senza aver ripetuto il test. + expect(leggiScore(db, s1).assi[asse].copertura).toBe(0.2); + expect(leggiScore(db, s2).assi[asse].copertura).toBe(0.2); + }); +}); + +describe('verificaMisura: scarto', () => { + it('una misura scartata resta esclusa, e non scrive nessuna riga nuova in score', () => { + const db = dbPronto(); + const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); + registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); + + const primaN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n; + + const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; + const esito = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'nicola' }); + + expect(esito.ok).toBe(true); + if (esito.ok) expect(esito.sessioniRicalcolate).toEqual([]); + + const dopoN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n; + expect(dopoN).toBe(primaN); // nessun ricalcolo per uno scarto + + const rigaMisura = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string }; + expect(rigaMisura.verifica).toBe('scartata'); + + // Ancora esclusa da misureValideAlla, esattamente come prima della verifica. + const dominio = calcolaSessione(db, s1); + expect(dominio.assi['Recupero & Sistema Nervoso'].copertura).toBe(0); + }); +}); + +describe('verificaMisura: rifiuti', () => { + it('rifiuta una seconda verifica sulla stessa misura', () => { + const db = dbPronto(); + const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); + registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); + const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; + + const prima = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); + expect(prima.ok).toBe(true); + + const seconda = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'donata' }); + expect(seconda.ok).toBe(false); + if (!seconda.ok) expect(seconda.errore).toMatch(/gia.*verificat/i); + + // La seconda chiamata non ha scritto niente: resta la decisione della prima. + const riga = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as + { verifica: string; verificata_da: string }; + expect(riga.verifica).toBe('confermata'); + expect(riga.verificata_da).toBe('nicola'); + }); + + it('rifiuta una misura che e gia nel range: non c e niente da verificare', () => { + const db = dbPronto(); + const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); + registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 7.5 }]); // nel range 0-14 + const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; + + const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); + expect(esito.ok).toBe(false); + + const riga = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string | null }; + expect(riga.verifica).toBeNull(); // nessuna scrittura + }); + + it('rifiuta un id di misura inesistente', () => { + const db = dbPronto(); + const esito = verificaMisura(db, { misuraId: 999999, esito: 'confermata', operatore: 'nicola' }); + expect(esito.ok).toBe(false); + if (!esito.ok) expect(esito.errore).toMatch(/inesistente/); + }); +}); + +describe('accesso: la verifica e roba da trainer, non da cliente', () => { + it('canAccessPath chiude /api/longevity/gestionale/verifica al cliente e la apre al trainer', () => { + expect(canAccessPath('cliente', '/api/longevity/gestionale/verifica')).toBe(false); + expect(canAccessPath('trainer', '/api/longevity/gestionale/verifica')).toBe(true); + }); +}); From 4cd911098dd908afbea881cd83e5843ebe13e44e Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:02:50 +0000 Subject: [PATCH 10/18] longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/longevity/progressione.ts | 129 +++++++++++++++ .../tests/longevity/progressione.test.ts | 148 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/progressione.ts create mode 100644 apps/platforms/tests/longevity/progressione.test.ts 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/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); + }); +}); From 5dc6b85d521dccb08dd12327d53817f8396fcb47 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:02:57 +0000 Subject: [PATCH 11/18] longevity: pagina /longevity/progressione, grafico e tabella dell'andamento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progressione.tsx: una linea per macro-score (connectNulls=false: un dato mancante è un buco, non una linea inventata), tratteggio e spessore a distinguere le serie invece di un colore nuovo, riga verticale tratteggiata sui cambi di versione; sotto, la tabella per asse. Colori identici a Radar.tsx, nessuno nuovo. progressione.astro: stesso modello di io.astro (codicePerUtenteLoggato, nessuna query diretta); sotto i due punti mostra il messaggio che ne serve almeno uno in più. io.astro linka la pagina nuova. Refs #5 Co-Authored-By: Claude Sonnet 5 --- .../src/components/longevity/Progressione.tsx | 139 ++++++++++++++++++ apps/platforms/src/pages/longevity/io.astro | 3 +- .../src/pages/longevity/progressione.astro | 47 ++++++ apps/platforms/src/styles/longevity.css | 16 ++ apps/platforms/tests/longevity/pagine.test.ts | 15 ++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 apps/platforms/src/components/longevity/Progressione.tsx create mode 100644 apps/platforms/src/pages/longevity/progressione.astro 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/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 daaac5c..8823402 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 c4ce3c7..82d173f 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -93,4 +93,19 @@ 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'); + }); }); From 8ee2c8a2b483b3b09f1c7b396cb396177014cce9 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:05:32 +0000 Subject: [PATCH 12/18] =?UTF-8?q?longevity:=20layout=20e=20stile=20del=20g?= =?UTF-8?q?estionale=20=E2=80=94=20barra=20laterale,=20tabelle,=20bottone?= =?UTF-8?q?=20d'azione?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variante "gestionale" di Longevity.astro (finora dichiarata e non implementata): barra laterale sul modello di StressIndex.astro, con le voci Clienti, Nuovo cliente e un link "Il mio referto". CSS nuovo in longevity.css, solo classi lg-* e solo i colori già ammessi (nessun colore letterale nuovo: il bianco del testo sul bottone pieno riusa var(--lg-surface), non un #fff aggiuntivo). Refs #2 --- apps/platforms/src/layouts/Longevity.astro | 63 ++++++++++++++---- apps/platforms/src/styles/longevity.css | 77 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/apps/platforms/src/layouts/Longevity.astro b/apps/platforms/src/layouts/Longevity.astro index 8ec8396..27b048b 100644 --- a/apps/platforms/src/layouts/Longevity.astro +++ b/apps/platforms/src/layouts/Longevity.astro @@ -5,8 +5,8 @@ // dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro // volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno // strumento di lavoro. Il layout prevede due forme: -// - "referto": una colonna che scorre, senza navigazione (questo task) -// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale) +// - "referto": una colonna che scorre, senza navigazione (il cliente) +// - "gestionale": barra laterale sul modello di StressIndex.astro (il trainer, Unità 2b #2) import '@fontsource/montserrat/400.css'; import '@fontsource/montserrat/500.css'; import '@fontsource/montserrat/600.css'; @@ -19,10 +19,18 @@ interface Props { title: string; /** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */ crumbs?: string[]; - /** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, non ancora implementata). */ + /** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, per il trainer). */ variante?: 'referto' | 'gestionale'; } const { title, crumbs = [], variante = 'referto' } = Astro.props; +const path = Astro.url.pathname; + +// Le voci della barra laterale del gestionale: nasce con due pagine, non le sette di +// Stress Index — un elenco più lungo arriverà con gli sviluppi futuri (#3, #7, #8). +const NAV_GESTIONALE = [ + { href: '/longevity/gestionale', label: 'Clienti' }, + { href: '/longevity/gestionale/nuovo', label: 'Nuovo cliente' }, +]; --- @@ -34,14 +42,45 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props; -
-
-

- Longevity - {crumbs.map((c) => / {c})} -

-
-
-
+ {variante === 'gestionale' ? ( + <> + + + +
+
+

+ Longevity + {crumbs.map((c) => / {c})} +

+
+
+
+ + ) : ( +
+
+

+ Longevity + {crumbs.map((c) => / {c})} +

+
+
+
+ )} diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index daaac5c..6375e52 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -116,6 +116,10 @@ .lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; } .lg-field > span { color: var(--c-heading); font-weight: 500; } .lg-field input[type="number"], +.lg-field input[type="text"], +.lg-field input[type="date"], +.lg-field input[type="email"], +.lg-field input[type="tel"], .lg-field textarea { border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface); } @@ -135,6 +139,79 @@ } .lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; } .lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; } +/* Il link "Annulla" accanto al bottone di invio (nuovo.astro, checkup.astro): stesso rigo, + peso visivo minore — non compete con l'azione che porta avanti il modulo. */ +.lg-form__nav a { align-self: center; font-size: .85rem; color: var(--c-text-light); text-decoration: none; } +.lg-form__nav a:hover { color: var(--c-heading); } + +/* --- il gestionale del trainer (Unità 2b, #2): barra laterale sul modello di + StressIndex.astro, ma nella palette di Longevity — niente sfondo scuro con colori + letterali, solo i token già ammessi qui. --- */ +.lg[data-variante="gestionale"] { justify-content: flex-start; align-items: flex-start; } + +.lg-side { + width: 240px; flex: 0 0 240px; height: 100vh; position: sticky; top: 0; overflow-y: auto; + background: var(--c-bg-alt); border-right: 1px solid var(--lg-line); + padding: 28px 20px 40px; display: flex; flex-direction: column; +} +.lg-brand { + display: block; font-family: var(--font-heading); font-size: 1.15rem; font-weight: 600; + letter-spacing: .06em; text-transform: uppercase; color: var(--c-heading); text-decoration: none; line-height: 1.15; +} +.lg-brand .eyebrow { display: block; font-size: .68rem; letter-spacing: .18em; color: var(--c-accent); margin-bottom: 5px; } +.lg-home { + display: inline-block; margin: 16px 0 22px; font-family: var(--font-heading); font-size: .66rem; + font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light); + text-decoration: none; border-bottom: 1px solid transparent; +} +.lg-home:hover { color: var(--c-heading); border-bottom-color: var(--c-accent); } + +.lg-nav { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid var(--lg-line); padding-top: 16px; } +.lg-nav__link { + padding: 9px 12px; text-decoration: none; font-family: var(--font-heading); font-size: .72rem; + font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--c-text-light); + border-left: 3px solid transparent; +} +.lg-nav__link:hover { color: var(--c-heading); background: var(--c-bg); } +.lg-nav__link.is-active { color: var(--c-heading); background: var(--lg-surface); border-left-color: var(--c-accent); } +.lg-nav__link--referto { margin-top: 16px; border-top: 1px dashed var(--lg-tratteggio); padding-top: 16px; } + +.lg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; } + +/* intestazione di pagina con un'azione a destra (es. "Nuovo cliente", "Nuovo check-up") */ +.lg-head--azioni { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } + +/* bottone pieno per un link che porta a un'azione (non un + + + From 179aeb442a380031d6977528b552fc7fac2e6e86 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:05:42 +0000 Subject: [PATCH 14/18] =?UTF-8?q?longevity:=20pagina=20del=20gestionale=20?= =?UTF-8?q?=E2=80=94=20scheda=20cliente=20(referto,=20sessioni,=20fuori=20?= =?UTF-8?q?range)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/longevity/gestionale/cliente/[code].astro: anagrafica, il referto dell'ultima sessione (stesso Radar/MacroScore di io.astro, qui per il trainer), la tabella delle sessioni e le misure fuori range (colore --lg-fuori-range). Un codice inesistente è 404. Gli esiti ?salvato/?fuori/?errore dell'API di check-up sono mostrati qui. Refs #2 --- .../longevity/gestionale/cliente/[code].astro | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro new file mode 100644 index 0000000..cb0de4e --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro @@ -0,0 +1,151 @@ +--- +// La scheda di un cliente: anagrafica, il referto dell'ultima sessione (lo stesso che il +// cliente vede da solo su /longevity/io, qui per il trainer), l'elenco delle sessioni e le +// misure fuori range. Un codice che non esiste in anagrafica è un 404, non uno schermo +// vuoto — non c'è niente da mostrare per un fascicolo che non c'è. +import Longevity from '../../../../layouts/Longevity.astro'; +import Radar from '../../../../components/longevity/Radar'; +import MacroScore from '../../../../components/longevity/MacroScore.astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { clienteDaCodice, etaDelCliente } from '../../../../lib/longevity/anagrafica'; +import { sessioniDi, misureFuoriRange } from '../../../../lib/longevity/gestionale'; +import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista'; +import { formattaNumero } from '../../../../lib/longevity/formato'; + +export const prerender = false; + +const { code } = Astro.params; +const cliente = code ? clienteDaCodice(code) : null; +if (!cliente) return new Response('Cliente non trovato', { status: 404 }); + +const db = getLongevityDb(); +const oggi = new Date().toISOString().slice(0, 10); +const eta = etaDelCliente(cliente.client_code, oggi); +const sessioni = sessioniDi(db, cliente.client_code); +const fuoriRange = misureFuoriRange(db, cliente.client_code); +const ultimaId = ultimaSessioneDi(db, cliente.client_code); +const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null; + +const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = { + checkup: 'Check-up', + questionario: 'Questionario', +}; +const formattaData = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }); + +const params = Astro.url.searchParams; +const salvato = params.get('salvato'); +const fuoriCount = params.get('fuori'); +const errore = params.get('errore'); +--- + +
+
+

{cliente.cognome} {cliente.nome}

+

+ {cliente.client_code} · {eta !== null ? `${eta} anni` : 'età non registrata'} · + accesso {cliente.ha_accesso ? 'attivo' : 'non ancora attivato'} +

+
+ Nuovo check-up +
+ + {errore &&

{errore}

} + {salvato &&

Check-up salvato (sessione #{salvato}).

} + {fuoriCount && ( +

+ {fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di + plausibilità: verificale più sotto. +

+ )} + +
+

Anagrafica

+

Email: {cliente.email ?? '—'}

+

Telefono: {cliente.telefono ?? '—'}

+

Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}

+
+ + {!referto && ( +
+

Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.

+
+ )} + + {referto && ( +
+
+

Fitness Age

+
+ + {referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'} + + {referto.fitnessAge !== null && anni} +
+

+ Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}% + {referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'} +

+
+ +
+

Radar

+ +
+ +
+ {referto.macro.map((m) => ( + + ))} +
+
+ )} + +
+

Sessioni

+ {sessioni.length === 0 ? ( +

Nessuna sessione.

+ ) : ( + + + + + + {sessioni.map((s) => ( + + + + + + + + ))} + +
DataTipoOperatoreMisureFitness Age
{formattaData(s.data)}{ETICHETTA_TIPO[s.tipo]}{s.operatore ?? '—'}{s.numeroMisure}{s.fitnessAge !== null ? formattaNumero(s.fitnessAge) : '—'}
+ )} +
+ + {fuoriRange.length > 0 && ( +
+

Misure fuori range

+ + + + {fuoriRange.map((m) => ( + + + + + + ))} + +
DataTestValore
{formattaData(m.data)}{m.etichetta} + {m.valore !== null ? formattaNumero(m.valore) : '—'}{m.unita ? ` ${m.unita}` : ''} +
+
+ )} +
From 9375a0380da364a1a763c708bd427e58105a0917 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:05:47 +0000 Subject: [PATCH 15/18] =?UTF-8?q?longevity:=20pagina=20del=20gestionale=20?= =?UTF-8?q?=E2=80=94=20modulo=20di=20check-up=20manuale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/longevity/gestionale/cliente/[code]/checkup.astro: campi da campiDelCheckup, raggruppati per asse, etichetta e unità, input inputmode="decimal" tutti facoltativi (la virgola decimale e i campi vuoti li gestisce l'endpoint). Invio verso /api/longevity/gestionale/checkup. Refs #2 --- .../gestionale/cliente/[code]/checkup.astro | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro new file mode 100644 index 0000000..35a41a4 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro @@ -0,0 +1,57 @@ +--- +// Il modulo del check-up manuale: i campi letti dal registro (campiDelCheckup), +// raggruppati per asse come il modulo cartaceo. L'invio va all'API del gestionale, che fa +// la validazione vera (pages/api/longevity/gestionale/checkup.ts) — qui c'è solo il form. +import Longevity from '../../../../../layouts/Longevity.astro'; +import { getLongevityDb } from '../../../../../lib/longevity/db'; +import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica'; +import { campiDelCheckup } from '../../../../../lib/longevity/gestionale'; + +export const prerender = false; + +const { code } = Astro.params; +const cliente = code ? clienteDaCodice(code) : null; +if (!cliente) return new Response('Cliente non trovato', { status: 404 }); + +const oggi = new Date().toISOString().slice(0, 10); +const gruppi = campiDelCheckup(getLongevityDb(), oggi); +const errore = Astro.url.searchParams.get('errore'); +--- + +
+

Nuovo check-up

+

{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato oggi.

+
+ + {errore &&

{errore}

} + +
+ + + + + {gruppi.map((g) => ( +
+

{g.asse}

+ {g.campi.map((c) => ( + + ))} +
+ ))} + +
+ Annulla + +
+
+
From 948068b31224ae4e14a08c5d422ccaebd548d5d7 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:05:52 +0000 Subject: [PATCH 16/18] longevity: index.astro smista per ruolo, test del gestionale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/longevity/index.astro: trainer e admin → /longevity/gestionale, cliente → /longevity/io come prima (l'assert esistente sul redirect a /longevity/io resta valido, il ramo cliente lo chiama ancora testualmente). tests/longevity/pagine.test.ts: le quattro pagine del gestionale esistono, usano variante="gestionale", non toccano il DB direttamente e non nominano il database anagrafico riservato; index.astro smista per ruolo. Refs #2 --- .../platforms/src/pages/longevity/index.astro | 11 ++++-- apps/platforms/tests/longevity/pagine.test.ts | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/platforms/src/pages/longevity/index.astro b/apps/platforms/src/pages/longevity/index.astro index 34516da..e33e3fb 100644 --- a/apps/platforms/src/pages/longevity/index.astro +++ b/apps/platforms/src/pages/longevity/index.astro @@ -1,7 +1,12 @@ --- -// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al -// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath -// e canAccessAdminPath in src/lib/auth.ts). +// Punto di ingresso della piattaforma: smista per ruolo, non è un indice proprio. +// Il trainer e l'admin lavorano nel gestionale (Unità 2b, #2); il cliente resta sulla +// scorciatoia al proprio referto, come prima. La protezione della rotta è già del +// middleware (isProtectedPath e canAccessPath in src/lib/auth.ts): qui arriva solo chi è +// già autenticato su una delle due sezioni. export const prerender = false; + +const ruolo = Astro.locals.user?.role; +if (ruolo === 'admin' || ruolo === 'trainer') return Astro.redirect('/longevity/gestionale'); return Astro.redirect('/longevity/io'); --- diff --git a/apps/platforms/tests/longevity/pagine.test.ts b/apps/platforms/tests/longevity/pagine.test.ts index c4ce3c7..badca69 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -94,3 +94,41 @@ describe('il guscio della piattaforma', () => { expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); }); }); + +describe('il gestionale del trainer (Unità 2b)', () => { + const PAGINE_GESTIONALE = [ + 'src/pages/longevity/gestionale/index.astro', + 'src/pages/longevity/gestionale/nuovo.astro', + 'src/pages/longevity/gestionale/cliente/[code].astro', + 'src/pages/longevity/gestionale/cliente/[code]/checkup.astro', + ]; + + it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => { + for (const p of PAGINE_GESTIONALE) { + expect(existsSync(join(process.cwd(), p))).toBe(true); + expect(leggi(p)).toMatch(/variante=["']gestionale["']/); + } + }); + + it('nessuna pagina del gestionale interroga il database direttamente', () => { + // Il gestionale legge sempre attraverso lib/longevity (anagrafica, gestionale, vista): + // una .prepare( dentro una .astro sarebbe una query scritta a mano nella pagina. + for (const p of PAGINE_GESTIONALE) { + expect(leggi(p)).not.toMatch(/\.prepare\(/); + } + }); + + it('nessuna pagina del gestionale nomina il database anagrafico riservato', () => { + for (const p of PAGINE_GESTIONALE) { + expect(leggi(p)).not.toMatch(/Identity/); + } + }); + + it('index.astro smista per ruolo: trainer e admin al gestionale, il cliente al proprio referto', () => { + const src = leggi('src/pages/longevity/index.astro'); + expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/gestionale['"`]/); + expect(src).toMatch(/trainer/); + expect(src).toMatch(/admin/); + expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); + }); +}); From 2c9a74f849e7689970e5c33f43d360d301535ab8 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:11:38 +0000 Subject: [PATCH 17/18] longevity: verifica misure fuori range in scheda cliente, link a crea accesso Scheda cliente ([code].astro): sezione "Misure da verificare" da misureDaVerificare (id, range) al posto di misureFuoriRange (nessun id, nessuna azione); ogni riga ha due form verso /api/longevity/gestionale/verifica (conferma/scarta), messaggio dopo la decisione, e link "Crea accesso al referto" quando il cliente non ha ancora un accesso. gestionale.ts: rimossa misureFuoriRange (e il tipo MisuraFuoriRange), sostituita ovunque da misureDaVerificare (misure.ts), l'unica con id e range utile alle azioni. accesso.astro: link "Torna alla scheda" in tutti gli stati. Refs #7 Refs #8 Co-Authored-By: Claude Sonnet 5 --- .../platforms/src/lib/longevity/gestionale.ts | 24 ---------- .../longevity/gestionale/cliente/[code].astro | 45 +++++++++++++++---- .../gestionale/cliente/[code]/accesso.astro | 4 ++ .../tests/longevity/gestionale.test.ts | 9 ++-- apps/platforms/tests/longevity/pagine.test.ts | 22 ++++++++- 5 files changed, 67 insertions(+), 37 deletions(-) diff --git a/apps/platforms/src/lib/longevity/gestionale.ts b/apps/platforms/src/lib/longevity/gestionale.ts index b48371c..84abcb5 100644 --- a/apps/platforms/src/lib/longevity/gestionale.ts +++ b/apps/platforms/src/lib/longevity/gestionale.ts @@ -132,27 +132,3 @@ export function sessioniDi(db: Database.Database, code: string): SessioneElenco[ }; }); } - -export type MisuraFuoriRange = { - test_id: string; - etichetta: string; - valore: number | null; - unita: string | null; - data: string; -}; - -/** Le misure di un cliente marcate fuori dal range di plausibilità del registro - * (`misure.fuori_range = 1`, scritto da `registraMisure`): quelle che oggi non contano per - * nessun calcolo (`misureValideAlla` le esclude) ma restano da mostrare e, un domani, - * confermare (#7). Più recente prima. */ -export function misureFuoriRange(db: Database.Database, code: string): MisuraFuoriRange[] { - return db.prepare( - `SELECT m.test_id AS test_id, r.etichetta AS etichetta, m.valore_num AS valore, - m.unita AS unita, s.data AS data - FROM misure m - JOIN sessioni s ON s.id = m.sessione_id - JOIN registro_test r ON r.test_id = m.test_id - WHERE m.client_code = ? AND m.fuori_range = 1 - ORDER BY s.data DESC, s.id DESC, m.id DESC` - ).all(code) as MisuraFuoriRange[]; -} diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro index cb0de4e..73bf6cf 100644 --- a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro @@ -8,7 +8,8 @@ import Radar from '../../../../components/longevity/Radar'; import MacroScore from '../../../../components/longevity/MacroScore.astro'; import { getLongevityDb } from '../../../../lib/longevity/db'; import { clienteDaCodice, etaDelCliente } from '../../../../lib/longevity/anagrafica'; -import { sessioniDi, misureFuoriRange } from '../../../../lib/longevity/gestionale'; +import { sessioniDi } from '../../../../lib/longevity/gestionale'; +import { misureDaVerificare } from '../../../../lib/longevity/misure'; import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista'; import { formattaNumero } from '../../../../lib/longevity/formato'; @@ -22,7 +23,7 @@ const db = getLongevityDb(); const oggi = new Date().toISOString().slice(0, 10); const eta = etaDelCliente(cliente.client_code, oggi); const sessioni = sessioniDi(db, cliente.client_code); -const fuoriRange = misureFuoriRange(db, cliente.client_code); +const daVerificare = misureDaVerificare(db, cliente.client_code); const ultimaId = ultimaSessioneDi(db, cliente.client_code); const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null; @@ -37,6 +38,13 @@ const params = Astro.url.searchParams; const salvato = params.get('salvato'); const fuoriCount = params.get('fuori'); const errore = params.get('errore'); +const verificata = params.get('verificata'); +const MESSAGGIO_VERIFICA: Record<'confermata' | 'scartata', string> = { + confermata: 'Misura confermata: punteggi ricalcolati.', + scartata: 'Misura scartata.', +}; +const messaggioVerifica = + verificata === 'confermata' || verificata === 'scartata' ? MESSAGGIO_VERIFICA[verificata] : null; --- - Nuovo check-up +
+ {!cliente.ha_accesso && ( + Crea accesso al referto + )} + Nuovo check-up +
{errore &&

{errore}

} {salvato &&

Check-up salvato (sessione #{salvato}).

} + {messaggioVerifica &&

{messaggioVerifica}

} {fuoriCount && (

{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di @@ -129,18 +143,33 @@ const errore = params.get('errore'); )} - {fuoriRange.length > 0 && ( + {daVerificare.length > 0 && (

-

Misure fuori range

+

Misure da verificare

+

+ Una misura confermata è un dato vero: entra nei punteggi e ricalcola quella sessione e + le successive. Una scartata è un errore di strumento o protocollo: resta fuori per sempre. +

- + - {fuoriRange.map((m) => ( + {daVerificare.map((m) => ( + + ))} diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro index f27e423..a06cf95 100644 --- a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro @@ -34,12 +34,14 @@ if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') { {!fascicolo && (

Nessun fascicolo con codice {code}.

+

Torna alla scheda

)} {fascicolo && fascicolo.userId !== null && (

Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.

+

Torna alla scheda

)} @@ -48,6 +50,7 @@ if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') {

Accesso creato. Comunicala al cliente: non verrà mostrata di nuovo.

Utente: {creato.username}

Password: {creato.password}

+

Torna alla scheda

)} @@ -67,6 +70,7 @@ if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') { />
+ Torna alla scheda
diff --git a/apps/platforms/tests/longevity/gestionale.test.ts b/apps/platforms/tests/longevity/gestionale.test.ts index 5e160f7..bbfb897 100644 --- a/apps/platforms/tests/longevity/gestionale.test.ts +++ b/apps/platforms/tests/longevity/gestionale.test.ts @@ -9,7 +9,8 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; -import { salvaCheckup, sessioniDi, misureFuoriRange } from '../../src/lib/longevity/gestionale'; +import { salvaCheckup, sessioniDi } from '../../src/lib/longevity/gestionale'; +import { misureDaVerificare } from '../../src/lib/longevity/misure'; import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista'; import { creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo, @@ -125,7 +126,7 @@ describe('salvaCheckup rifiuta prima di scrivere: nessuna traccia parziale', () }); describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () => { - it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureFuoriRange', () => { + it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureDaVerificare', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', @@ -140,9 +141,9 @@ describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () ).get(esito.sessioneId) as { fuori_range: number }; expect(riga.fuori_range).toBe(1); - const elenco = misureFuoriRange(db, 'ISL-0001'); + const elenco = misureDaVerificare(db, 'ISL-0001'); expect(elenco.length).toBe(1); - expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore: 500, data: '2026-09-26' }); + expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore_num: 500, data: '2026-09-26' }); }); }); diff --git a/apps/platforms/tests/longevity/pagine.test.ts b/apps/platforms/tests/longevity/pagine.test.ts index c558e98..5d55d28 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -1,6 +1,6 @@ // tests/longevity/pagine.test.ts import { describe, it, expect } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8'); @@ -146,4 +146,24 @@ describe('il gestionale del trainer (Unità 2b)', () => { expect(src).toMatch(/admin/); expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); }); + + it('la scheda cliente porta le azioni di verifica di una misura fuori range (#7) e il link per creare l\'accesso (#8)', () => { + const src = leggi('src/pages/longevity/gestionale/cliente/[code].astro'); + expect(src).toMatch(/action=["']\/api\/longevity\/gestionale\/verifica["']/); + expect(src).toMatch(/name=["']misura_id["']/); + expect(src).toMatch(/value=["']confermata["']/); + expect(src).toMatch(/value=["']scartata["']/); + expect(src).toMatch(/\/longevity\/gestionale\/cliente\/\$\{cliente\.client_code\}\/accesso/); + }); + + it('nessuna pagina longevity usa piu misureFuoriRange, rimossa da gestionale.ts a favore di misureDaVerificare (#7)', () => { + // Scandisce TUTTE le pagine sotto src/pages/longevity/, stesso criterio di + // referto.test.ts: un elenco scritto qui non protegge una pagina nuova. + const dir = join(process.cwd(), 'src/pages/longevity'); + const pagine = (readdirSync(dir, { recursive: true }) as string[]).filter((f) => f.endsWith('.astro')); + expect(pagine.length).toBeGreaterThan(0); + for (const f of pagine) { + expect(readFileSync(join(dir, f), 'utf8'), `src/pages/longevity/${f}`).not.toMatch(/misureFuoriRange/); + } + }); }); From e74266f9a0ecb5b4cb85a7181d54b67c0718b250 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:15:21 +0000 Subject: [PATCH 18/18] node_modules fuori da git: il symlink tracciato puntava al checkout di produzione Il pattern 'node_modules/' ignora solo le cartelle, non i symlink: un symlink apps/platforms/node_modules -> /opt/docker/insanitylab-apps/... era finito in git (13d8cdd). Nei cloni npm ci passava dal symlink e svuotava la cartella del checkout di produzione; nel checkout di produzione il symlink punterebbe a se' stesso. Ora il pattern e' 'node_modules' (cartelle e symlink). Fixes #9 Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 2 +- apps/platforms/node_modules | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 120000 apps/platforms/node_modules diff --git a/.gitignore b/.gitignore index e27adf0..5a2272d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ .astro/ .env diff --git a/apps/platforms/node_modules b/apps/platforms/node_modules deleted file mode 120000 index 9b5e35b..0000000 --- a/apps/platforms/node_modules +++ /dev/null @@ -1 +0,0 @@ -/opt/docker/insanitylab-apps/apps/platforms/node_modules \ No newline at end of file
DataTestValore
DataTestValoreRange attesoDecisione
{formattaData(m.data)} {m.etichetta} - {m.valore !== null ? formattaNumero(m.valore) : '—'}{m.unita ? ` ${m.unita}` : ''} + {m.valore_num !== null ? formattaNumero(m.valore_num) : '—'}{m.unita ? ` ${m.unita}` : ''} + {m.range_min ?? '—'}–{m.range_max ?? '—'} +
+ + +
+
+ + +