58 Commits

Author SHA1 Message Date
Adriano 680224e773 longevity: #15 bersagli touch >=44px per radio, pulsanti e link-pulsante
Tablet del centro: label di radio (lg-field--consenso, alternative del
check-up), button di lg-form__nav e lg-btn a min-height 44px, senza
cambiare l'aspetto desktop.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:36 +02:00
Adriano 8aeb25f930 longevity: #15 pagine del flusso guidato: consulenza, check-up, scheda cliente
Nuova pagina consulenza.astro (radio per livello e obiettivo, niente
select). checkup.astro mostra "Passo 3 di 4" e porta avanti flusso=nuovo,
con «Salta» verso la scheda. La scheda cliente ([code].astro) mostra la
sezione "Prima consulenza" (letta, Modifica/Compila) e, con
flusso=questionario, il riquadro "Passo 4 di 4" che rimanda a "Crea
accesso" se il cliente non lo ha ancora.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:33 +02:00
Adriano 575a3988b7 longevity: #15 API della prima consulenza e flusso guidato nel check-up
Nuova API consulenza.ts (POST, autore da locals.user, redirect a
checkup?flusso=nuovo o alla scheda cliente). clienti.ts ora apre il flusso
guidato verso /consulenza?flusso=nuovo invece della scheda. checkup.ts:
un check-up NUOVO con flusso=nuovo prosegue con flusso=questionario; una
correzione lo ignora sempre.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:28 +02:00
Adriano 498395dddb longevity: #15 libreria e schema della prima consulenza, con test
Tabella consulenza (client_code, UPSERT, nessuno storico) e modulo
lib/longevity/consulenza.ts: consulenzaDi, salvaConsulenza con validazioni
(livello 1-3, obiettivo nell'elenco chiuso, data AAAA-MM-GG, disponibilita
0-7, testo max 5000). 22 test: modulo, schema, API consulenza.ts e
checkup.ts (flusso guidato), redirect di clienti.ts, nessuna pagina cliente
importa consulenza.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:24 +02:00
Adriano b0f30f9e4c longevity: correzioni passo 3 su #13 unità B — scarto alternative e nota
testIdScartatiDalleAlternative: una sezione senza scelta in `scelte` (radio
assente dal form) non scarta più nulla, invece di ripiegare in silenzio
sulla prima alternativa (perdeva campi come f_plank_sec senza alt_core).

API checkup.ts: la nota operatore si valida (lunghezza max 5000, TESTO_MAX
esportato da giornale.ts) PRIMA di salvare il check-up, non più dopo — una
nota troppo lunga non faceva più fallire aggiungiVoce in silenzio ma
perdeva comunque la nota col check-up già scritto. maxlength sulla
textarea in checkup.astro.

Suite: 314 verdi, 4 rossi di piattaforma (baseline, invariati). tsc e
build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:59:19 +02:00
Adriano 81021d18f3 longevity: test unità B — struttura modulo, alternative, nota su giornale (#13)
gestionale-modulo-checkup.test.ts: ordine sezioni, pressione/SpO2 esclusi,
dismesso in «Altre misure» su data vecchia, dettagli MUTT, preselezione
alternativa (prima/più recente/ultimo check-up). checkup-alternative-api.test.ts:
API scarta i campi dell'alternativa non scelta (radio inviata o assente),
nota operatore aggiunge voce di giornale (data check-up, autore operatore),
nota vuota o assente (correzione) non aggiunge nulla.

Suite completa: 313 verdi, 4 rossi di piattaforma (baseline, invariati).
tsc --noEmit e npm run build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:57:02 +02:00
Adriano c8187ce4db longevity: modulo check-up con sezioni e alternative del manuale (#13)
strutturaCheckup in gestionale.ts (ordine Pre-test..Strumenti, sezioni ad
alternative con soglia testuale, «Altre misure» per i test dismessi in
correzione, pressione/SpO2 esclusi); checkup.astro con radio button e
fieldset (niente <select>); API checkup.ts scarta i campi delle
alternative non scelte e aggiunge una voce di giornale dalla nota
operatore sul check-up nuovo.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:54:26 +02:00
Adriano cffb0a624d longevity: correzione etichette sit-to-stand e push-up (unità A)
sit-to-stand era descritto come 30 secondi, il test dura 1 minuto;
push-up non diceva che le donne lo fanno sulle ginocchia.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:49:24 +02:00
Adriano f57a07a964 longevity: test unitari e di sessione per la regola dei gruppi (#13)
motore-fisici-gruppi.test.ts (nuovo): un caso per ogni alternativa che da
punteggio (handgrip/spinta/arti_inferiori/vo2max/hrr/flamingo/back_scratch,
media dx/sx e storico), un lato solo -> nessun punteggio, recenza fra
alternative (pushup/panca in entrambe le direzioni), alternativa piu recente
ma incompleta esclusa (sit-to-stand senza altezza), i test "solo registrati"
non producono chiavi.

motore-fisici-sessione.test.ts: due nuovi casi end-to-end su DB (due sessioni,
due test diversi per la stessa sotto-metrica) che confermano che lo score usa
il test piu recente, in entrambe le direzioni.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:48:07 +02:00
Adriano abd0626ab0 longevity: registro e motore per la nuova batteria test (manuale Nicola 27/09)
Nuovi test_id nel registro (altezza, FC riposo, handgrip/back scratch/flamingo
dx-sx, panca manubri/bilanciere, trazioni, sit-to-stand, plank ginocchia,
6MWT, 2km walk UKK, MILFIT, MUTT FC max/1'/2'/step) con attivo_da, e i quattro
test_id storici sostituiti (handgrip_kg, back_scratch_cm, flamingo_cadute,
hrr_1min_bpm) marcati attivo_a 2026-09-28 senza cancellarli.

motore/index.ts: nuova recenzaValideAlla (posizione di recenza per test_id,
stesso ORDER BY di misureValideAlla, fattorizzata la query condivisa).

motore/fisici.ts: regola dei gruppi in sottoDominiFisici (recenza opzionale) -
per ogni sotto-metrica con piu' test alternativi si sceglie l'alternativa
COMPLETA la cui misura piu' recente vince; senza recenza resta l'ordine di
priorita' di oggi. Handgrip/back_scratch/flamingo ora mediano dx/sx; spinta e
arti_inferiori aggiungono panca bilanciere e sit-to-stand; vo2max aggiunge
MILFIT e 2km walk alla cascata esistente.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:45:26 +02:00
Adriano 2231a476c7 longevity: sezione Giornale di bordo nella scheda cliente
Form (data, nota) ed elenco voci dopo Anagrafica, cancella visibile solo ad
autore o admin, messaggi per ?giornale=aggiunta|cancellata.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:24:22 +02:00
Adriano cef08aaf9f longevity: API giornale di bordo per cliente (aggiungi/cancella)
POST unico con campo azione, autore e ruolo sempre da locals.user, redirect
303 alla scheda cliente #giornale.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:23:03 +02:00
Adriano 37b1a316d8 longevity: test unitari giornale di bordo per cliente
Aggiunta/lettura, rifiuti di aggiungiVoce, cancellaVoce (autore/admin/altro
trainer/id inesistente), schema idempotente, nessuna pagina cliente importa
giornale.ts.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:40 +02:00
Adriano 00ecfe86c3 longevity: schema e libreria giornale di bordo per cliente
Tabella giornale in longevity.db (solo client_code) e giornale.ts:
vociDelGiornale, aggiungiVoce (validazioni), cancellaVoce (autore o admin).
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:07 +02:00
Adriano 6bbfcd8f3c longevity: pagina dettaglio sessione e modifica check-up dalla scheda (#12)
Nuova pagina .../sessione/[id].astro: dettaglio misure per asse, con
link "Modifica" per un checkup. checkup.astro diventa correzione con
?sessione=<id> (campi precompilati, attivi alla data della sessione).
La scheda cliente linka ogni sessione e mostra l'esito della correzione.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:13:41 +02:00
Adriano 9e566ad7b1 longevity: API checkup accetta sessione_id per correggere (#12)
Un sessione_id nel form fa passare da correggiCheckup invece di
salvaCheckup, stesso endpoint. Il client_code per validazioni e
redirect si legge sempre dalla sessione nel DB, mai dal form.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:12:00 +02:00
Adriano 19035e67b0 longevity: correggiCheckup e misureDellaSessione, sovrascrittura misure
Aggiunge la correzione di un check-up gia' salvato: aggiorna sessione e
misure (cambiate/tolte/nuove), azzera la verifica sulle misure cambiate,
ricalcola i punteggi della sessione e delle successive. Estratto
fuoriRangeDi in misure.ts per non duplicare il calcolo min/max.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:09:15 +02:00
Adriano 880c928f43 docs: README e spec Longevity al 26/09
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:02:23 +00:00
Adriano 558def3d52 docs: README piattaforme e spec Longevity aggiornati al gestionale in produzione
README: host piattaforme.tielogic.xyz, conteggio test, sezione Longevity (ruoli,
pagine, regola della misura valida più recente), procedura di aggiornamento con
backup, dati nel volume, apertura pigra del DB, trappole dei test e node_modules.
Spec: §13 con i punti di §11/§12 chiusi da #1 #2 #5 #6 #7 #8 e quelli ancora aperti;
decisioni del 03/09 e 26/09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:02:22 +00:00
Adriano 8f95a385d9 Longevity: nessuna pagina senza uscita (#10)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:48:42 +00:00
Adriano 22a400b854 longevity: nessuna pagina senza uscita, "Il mio referto" solo a chi ha un fascicolo
Refs #10

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:48:03 +00:00
Adriano 12b2e91503 Longevity: gestionale trainer, test fisici, progressione, verifica misure, accesso cliente (#1 #2 #5 #7 #8 #9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:15:29 +00:00
Adriano e74266f9a0 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) <noreply@anthropic.com>
2026-09-26 13:15:24 +00:00
Adriano 2c9a74f849 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 <noreply@anthropic.com>
2026-09-26 13:11:38 +00:00
Adriano 6cbb205064 Merge branch 'lavoro/goldrake-i8' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 8579199115 Merge branch 'lavoro/goldrake-i7' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano d2b508e8ac Merge branch 'lavoro/goldrake-i5' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 948068b312 longevity: index.astro smista per ruolo, test del gestionale
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
2026-09-26 13:05:52 +00:00
Adriano 9375a0380d longevity: pagina del gestionale — modulo di check-up manuale
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
2026-09-26 13:05:47 +00:00
Adriano 179aeb442a longevity: pagina del gestionale — scheda cliente (referto, sessioni, fuori range)
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
2026-09-26 13:05:42 +00:00
Adriano fd56afdf44 longevity: pagine del gestionale — elenco clienti e apertura fascicolo
pages/longevity/gestionale/index.astro: tabella clienti (codice, cognome nome, età,
accesso, data e tipo dell'ultima sessione via sessioniDi per cliente — va bene finché
i fascicoli sono pochi, non pensata per scalare).
pages/longevity/gestionale/nuovo.astro: form verso /api/longevity/gestionale/clienti,
sesso a radio (non <select>: la parola inglese avrebbe fatto scattare il controllo
anti-SQL di referto.test.ts, che cerca \bSELECT\b senza distinguere l'HTML).

Refs #2
2026-09-26 13:05:38 +00:00
Adriano 8ee2c8a2b4 longevity: layout e stile del gestionale — barra laterale, tabelle, bottone d'azione
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
2026-09-26 13:05:32 +00:00
Adriano 5dc6b85d52 longevity: pagina /longevity/progressione, grafico e tabella dell'andamento
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 <noreply@anthropic.com>
2026-09-26 13:02:57 +00:00
Adriano 4cd911098d longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo
Una sessione per punto (solo quelle con righe in score), i punteggi congelati
da leggiScore, e cambioVersione quando model_version o quest_version (non
nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con
quest_version NULL, non innesca né nasconde un cambio.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:50 +00:00
Adriano 922ab40eb2 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 <noreply@anthropic.com>
2026-09-26 13:02:16 +00:00
Adriano 77829b2d12 longevity: crea l'accesso al sito per un cliente che ha già il fascicolo
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 <noreply@anthropic.com>
2026-09-26 13:00:48 +00:00
Adriano f096807ed6 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 <noreply@anthropic.com>
2026-09-26 12:58:25 +00:00
Adriano 95ffa08197 longevity: API del gestionale (clienti, check-up) e test dell'unità 2a
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 <noreply@anthropic.com>
2026-09-26 12:53:46 +00:00
Adriano 758463bd26 longevity: gestionale.ts — check-up manuale, sessioni e misure fuori range
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 <noreply@anthropic.com>
2026-09-26 12:53:35 +00:00
Adriano 8e0b9e4647 longevity: anagrafica del gestionale — elenco, dettaglio, età, apertura fascicolo
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 <noreply@anthropic.com>
2026-09-26 12:53:27 +00:00
Adriano af8afebba8 longevity: misureValideAlla, spareggio sulla riga e nota sulla conferma dei fuori range
Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:40:31 +00:00
Adriano 4968b75472 longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni
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 <noreply@anthropic.com>
2026-09-26 12:38:34 +00:00
Adriano 48910d84ba 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 <noreply@anthropic.com>
2026-09-26 12:38:01 +00:00
Adriano Dal Pastro 13d8cdd137 Il sito lascia le piattaforme: separazione fusa in main
Fuso `feat/split-apps` (fermo dal 03/09 e mai portato in main, mentre il container
delle piattaforme girava gia' da li'): il sito e le piattaforme erano online in due
copie, con database diversi e ruoli divergenti — `nicola` era `admin` di la' e
`piattaforme` di qua, e la promozione di ieri valeva solo su una delle due.

Adesso `insanitylab.it/campus`, `/piattaforme` e `/login` fanno 404: le aree
riservate stanno solo su `piattaforme.tielogic.xyz`. Il pannello `/admin` resta.
Nel database del sito restano `admin` ed `editor`; `nicola` e `adriano` sono usciti.

Il lavoro di oggi e' passato intatto: edizione `settembre-2026` coi cinque
pacchetti, i 73 tag, il countdown al 19/09 e la correzione del sabato. Verificato
sull'immagine nuova prima di metterla, chiedendo la pagina a quattro date.

Tre cose che lo split del 03/09 aveva lasciato indietro, trovate eseguendo:

- **`Dockerfile` del sito copiava ancora `campus-content`**, spostato sotto
  `apps/platforms`: il build falliva. Nel sito non c'era piu' un solo riferimento a
  quella cartella - solo la riga del Dockerfile. Rimossa.
- **`migra-utenti.mjs --togli-dal-sito` era un no-op silenzioso**: il `continue`
  sugli utenti gia' presenti a destinazione saltava anche la cancellazione
  dall'origine. Siccome copiare e ripulire sono due passi separati apposta, l'opzione
  non funzionava proprio nel caso per cui esiste. Ora chi e' gia' di la' esce di qua
  lo stesso, e il riepilogo dice quanti ne ha tolti.
- **`.env` non e' in git**, quindi il merge non lo ha spostato sotto `apps/sito/`.
  L'`up` e' fallito dopo che il `down` aveva gia' tolto il container: **9,7 secondi
  di sito giu'**, misurati. La prova con `docker run` non lo aveva visto perche' non
  legge `env_file`. Il rimedio - `docker compose config` prima dello swap - e' scritto
  nel README del sito, che prima non diceva come si fa un deploy.

Sito 149 test verdi, piattaforme 202.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 10:18:00 +00:00
Adriano Dal Pastro 33091be802 Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts:
#	apps/sito/compose.yaml
2026-09-04 10:09:29 +00:00
Adriano Dal Pastro faf7647a5f La promo di settembre entra in produzione prima della mezzanotte
Gli Opening Bundles scadono stasera alle 23:59:59 e dopo di loro l'elenco delle
edizioni era vuoto: senza questo merge, dal 5 mattina la pagina avrebbe mostrato
"Nessuna promozione in corso" con le promo del cliente vive.

Merge autorizzato da Adriano il 04/09/2026 dopo aver visto l'anteprima.

Lo scambio a mezzanotte non richiede altri interventi: la pagina legge l'edizione
valida nell'istante della richiesta, e il nuovo container va in produzione adesso
con dentro entrambe le edizioni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 09:35:14 +00:00
Adriano Dal Pastro 2b71cbb49e L'inaugurazione e' di sabato, e la fascia morta fra 700 e 701 px
Due rilievi della revisione, uno dei quali fuori dalla promo.

**`eventi.astro`: "venerdi' 19 settembre 2026"**, ma il 19/09/2026 e' **sabato**
(`calendari.ts:136` lo diceva gia' giusto). E' la meta description della pagina
dell'inaugurazione: e' cio' che si legge nell'anteprima di un link su WhatsApp,
che e' il canale con cui questo cliente parla ai suoi. Una riga, ma pubblica e
falsa.

**`.cards-5` fra 700 e 701 px non era coperta da nessuna regola**: la fascia
intermedia aveva `and (min-width: 701px)`, e a larghezze frazionarie - uno zoom
del browser dà 700,5 px - non valeva ne' quella ne' il blocco <=700, quindi le
cinque card tornavano a sei colonne dentro ~570 px. Tolto il `min-width`: ci
pensa il blocco <=700, che viene dopo e a pari specificita' vince.

Tolta anche la riga ridondante `.cards-5 { 1fr }` nel blocco <=700: `.lp .cards`
copre gia' tutte le griglie a una colonna, ed e' lo stesso meccanismo per cui
`.cards-3` funziona senza una regola sua.

Restano non toccati, perche' verificati corretti: la virgola di "composizione
corporea, e analisi HRV" e' **nella creativa** (a fine riga, nel layout su tre
righe), e le code esplicative delle card 1 e 2 vengono dai testi di agosto
gia' approvati dal cliente, non dalle creative compresse per il formato social.

186 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:53:30 +00:00
Adriano Dal Pastro f914d0699a La promo di settembre subentra ai bundle il 5, cinque pacchetti
Gli Opening Bundles scadono il 04/09 alle 23:59:59 e dopo di loro non c'era
nessuna edizione: dal 5 il sito avrebbe mostrato "Nessuna promozione in corso"
mentre le promo erano vive. Adriano ha consegnato le sei creative il 03/09.

Edizione `settembre-2026` (5 -> 19 settembre, chiude il giorno
dell'inaugurazione): tornano i pacchetti da 5 incontri, con in piu' il
Check-up avanzato, che e' una valutazione singola e per questo dice
"1 valutazione" invece di "5 incontri". Prezzi 250/150/130/80/70.

Due cose che la struttura non prevedeva:

- **Nessun prezzo barrato.** Le creative danno il solo prezzo promo, e i
  listini pieni di agosto (300/220/140/100) sono numeri che nessuno ha
  riconfermato - riportarli accanto ai nuovi sarebbe stato inventarli.
  `price-old` diventa opzionale nella CardSpec (`priceOld`) invece di essere
  un tag vuoto: cosi' la spec dichiara che quella card non ne ha, e il test
  lo pretende solo dove c'e'. Agosto e i bundle sono marcati `priceOld: true`,
  quindi restano sotto controllo come prima.

- **Cinque card non stanno in una griglia da due o da tre** senza lasciare un
  buco in fondo. `.cards-5` va a sei colonne con span due, cosi' le ultime due
  si spostano al centro; a due card per riga sotto i 1000px, a una sotto i 700.

Testi ripresi dall'edizione di agosto, gia' approvata dal cliente, cambiando
solo cio' che le date rendevano falso: la FAQ delle class diceva "gli orari
saranno pubblicati entro Settembre" e adesso e' settembre, quindi rimanda al
calendario che esiste. La FAQ 7 sul Check-up e' nuova e va fatta leggere a
Nicola prima della pubblicazione.

186 test verdi, `astro check` invariato (i 3 errori della pagina promo sono
preesistenti e non toccati).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:43:50 +00:00
AdrianoDev a59b4e0c0e crea-fascicolo: aprire il fascicolo di chi ha gia' un account
"Anche un admin deve avere il suo referto" (Adriano). Il codice lo permetteva
gia': il referto si aggancia all'utente, non al ruolo. Mancava il modo di
creare la riga che lega utente e fascicolo senza passare da seed-demo, che
inventa anche la persona e le risposte.

Non indovina niente: --sesso e' obbligatorio perche' entra nelle curve dei
punteggi, la data di nascita si puo' omettere e allora l'eta' resta vuota -
meglio un campo vuoto di una data plausibile e falsa. Si ferma se quell'utente
ha gia' un fascicolo: due fascicoli per la stessa persona sono due storie
parallele, e nessuna e' quella giusta.

Il fascicolo nasce vuoto: il referto dice "non hai ancora compilato il
questionario" finche' la persona non lo compila, che e' il percorso vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 13:02:54 +00:00
AdrianoDev c8a6b318cb Dopo il login si atterra sulla scelta della piattaforma
Chiesto da Adriano: si entra e si sceglie dove andare, anche quando la scelta
e' una sola. Prima ogni ruolo saltava dritto nella sua sezione.

Non basta cambiare landingFor: /piattaforme era riservato ai ruoli admin e
piattaforme, quindi mandarci un cliente avrebbe prodotto un CICLO di redirect
- il middleware nega la pagina, rimanda a landingFor, landingFor rimanda dove
il middleware nega. L'indice diventa la casa comune (aperto a ogni ruolo),
mentre Stress Index, che sta sotto lo stesso percorso, resta riservato: la sua
regola viene prima, perche' vince la prima che combacia.

L'indice mostra a ognuno solo le sezioni che puo' aprire, quindi la scelta non
espone mai una porta chiusa.

Un test nuovo presidia l'invariante che rende impossibile il ciclo: la
destinazione di landingFor deve essere accessibile a TUTTI i ruoli.

Verificato in produzione: il cliente demo atterra sulla scelta e vede la sola
card Longevity, adriano vede Campus e Stress Index, e da li' il percorso
prosegue fino al referto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:53:32 +00:00
AdrianoDev 62b19b21d9 Il link del sito all'area riservata punta all'host che esiste
Era rimasto apps.insanitylab.it, che e' l'indirizzo definitivo ma non ha
ancora un record su Aruba: alla fusione il menu del sito avrebbe portato a un
errore di DNS, e nessun test se ne sarebbe accorto perche' il link esce dal
dominio e non lo serve nessuna rotta interna.

Ora punta a piattaforme.tielogic.xyz, con un test che lo presidia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:45:17 +00:00
AdrianoDev 89d2186e69 Il vecchio sottodominio esce anche dal compose del ramo
insanitylab.tielogic.xyz e' stato tolto dal DNS: una regola Traefik che lo
nomina fa fallire una sfida ACME a ogni tentativo, ed e' cio' che Let's
Encrypt limita. Resta il redirect di www.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:53 +00:00
AdrianoDev f5fac89c1b Via dal router il vecchio sottodominio, tolto dal DNS
Adriano ha rimosso il record insanitylab.tielogic.xyz il 03/09/2026. Una
regola Traefik che nomina un host inesistente non e' inerte: il router chiede
un certificato per i nomi della sua regola, e la sfida ACME su un nome che
non risolve fallisce a ogni tentativo - proprio cio' che Let's Encrypt limita.

Resta il redirect di www verso l'apex, che e' l'unico ancora vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:52 +00:00
AdrianoDev 15dbe6a397 Le aree riservate hanno il loro host: piattaforme.tielogic.xyz
Creato da Adriano sulla zona tielogic.xyz (Hostinger). Sostituisce il prestito
di insanitylab.tielogic.xyz, durato un'ora: quell'host e' tornato al suo 308
verso l'apex e il sito e' di nuovo esattamente com'era.

allowedDomains, router del container e router di redirect del sito cambiano
insieme: e' la terna che decide di chi e' un host, e sfasarla lascia un
indirizzo senza padrone o un login che risponde 403.

Verificato in HTTPS: certificato emesso per il nome nuovo, login e referto
funzionanti, insanitylab.it 200 e i due redirect al loro posto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev 665c598462 Il vecchio sottodominio torna al suo redirect
Adriano ha creato piattaforme.tielogic.xyz, che e' il nome giusto per le aree
riservate: il prestito di insanitylab.tielogic.xyz e' durato un'ora e finisce
qui. Il sito torna esattamente come stamattina - apex, www e vecchio
sottodominio - e le piattaforme stanno su un host loro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev a7b81e2ff4 Le aree riservate partono da insanitylab.tielogic.xyz
Deciso da Adriano il 03/09/2026: il dominio del cliente resta su Aruba e non
si tocca, le piattaforme partono da un sottodominio nostro. Scelto l'host che
risolveva gia' e aveva gia' il certificato - fino a oggi faceva 308 verso
l'apex del sito - invece di aspettare un record DNS nuovo.

Tre modifiche coordinate, che vanno insieme o l'host resta senza padrone:
- allowedDomains dell'app: aggiunto l'host temporaneo accanto a quello
  definitivo. Senza, dietro Traefik il CSRF di Astro respinge ogni POST con
  403 e il login sembra rifiutare le credenziali.
- router del container: Host(insanitylab.tielogic.xyz)
- compose del sito: quell'host esce dal router di redirect (www continua)

Quando il record A "apps" esistera' su Aruba si tengono doppi per un po',
invece di spostare di netto un indirizzo che qualcuno ha gia' salvato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:36:33 +00:00
AdrianoDev 4103376eb1 Il vecchio sottodominio esce dal redirect: serve alle aree riservate
insanitylab.tielogic.xyz faceva 308 verso l'apex dal 12/07. Dal 03/09/2026
ospita le aree riservate (Campus, Stress Index, Longevity) mentre si aspetta
il record A di apps.insanitylab.it su Aruba: il dominio del cliente resta
dov'e', le piattaforme partono da un sottodominio nostro.

www.insanitylab.it continua a reindirizzare come prima.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:34:59 +00:00
AdrianoDev 18b0877026 Longevity: il registro si semina da solo, e un cliente demo con un referto vero
seedRegistro/seedPesi esistevano dal 22/08 ed erano chiamati SOLO dai test: su
un database nuovo il registro restava vuoto, campiDelQuestionario non trovava
niente e la pagina del questionario si apriva SENZA DOMANDE. Non era un problema
della demo, era il primo avvio in produzione - lo stesso difetto gia' visto in
questo ramo (una funzione corretta che nessuno chiama), con la stessa
correzione: agganciarla dove non si puo' dimenticare, cioe' alla creazione del
database. Entrambe le semine sono INSERT OR REPLACE, quindi rilanciarle e' a
vuoto, e i punteggi gia' emessi non cambiano (sono congelati nella tabella
"score", che e' quello che il referto legge). Quattro test lo presidiano.

scripts/seed-demo.ts crea l'utente con ruolo "cliente", il fascicolo (passando
da creaCliente, l'unico varco fra identity e longevity) e compila il
questionario attraverso salvaDalForm - lo stesso percorso della pagina vera,
consenso compreso - cosi' i punteggi vengono calcolati e congelati come in
produzione. Si ferma se l'utente esiste gia': rifare la demo su dati esistenti
vuol dire duplicare o cancellare un fascicolo, e non e' una decisione da script.

Verificato girando l'app: Energy 74,5/100 con copertura 50%, Performance e
Recovery dichiarati insufficienti (5% e 35%) con l'elenco di cosa manca, Fitness
Age assente. E' la regola di Nicola all'opera - annullare invece di stimare - ed
e' lo stato vero del prodotto: nel registro ci sono solo le 20 domande del
questionario, i test fisici arrivano col gestionale trainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:27:48 +00:00
66 changed files with 6537 additions and 140 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/ node_modules
dist/ dist/
.astro/ .astro/
.env .env
+50 -3
View File
@@ -1,4 +1,4 @@
# InsanityLab — aree riservate (`apps.insanitylab.it`) # InsanityLab — aree riservate (`piattaforme.tielogic.xyz`)
Applicazione Astro autosufficiente con le tre aree riservate: **Biohacking Campus**, Applicazione Astro autosufficiente con le tre aree riservate: **Biohacking Campus**,
**Stress Index** e **Longevity**. Nasce il 03/09/2026 separando dal sito **Stress Index** e **Longevity**. Nasce il 03/09/2026 separando dal sito
@@ -8,7 +8,7 @@ Applicazione Astro autosufficiente con le tre aree riservate: **Biohacking Campu
| | Sito | Quest'app | | | Sito | Quest'app |
|---|---|---| |---|---|---|
| Host | `insanitylab.it` | `apps.insanitylab.it` | | Host | `insanitylab.it` | `piattaforme.tielogic.xyz` (in futuro anche `apps.insanitylab.it`) |
| Utenti | 3 redazionali (`admin`, `superuser`, `user`) | uno per ogni cliente del centro (`piattaforme`, `cliente`, `trainer`, `admin`) | | Utenti | 3 redazionali (`admin`, `superuser`, `user`) | uno per ogni cliente del centro (`piattaforme`, `cliente`, `trainer`, `admin`) |
| Database | `insanitylab.db` (contenuti, articoli, utenti del sito) | `piattaforme.db` + `longevity.db` + `identity.db` | | Database | `insanitylab.db` (contenuti, articoli, utenti del sito) | `piattaforme.db` + `longevity.db` + `identity.db` |
| Login | `/admin/login`, solo redazione | `/login`, ed è l'unica porta | | Login | `/admin/login`, solo redazione | `/login`, ed è l'unica porta |
@@ -22,12 +22,31 @@ separazione, non un difetto. Le due `lib/auth.ts` sono parenti ma non sono la st
```bash ```bash
npm ci npm ci
npm run dev # http://localhost:4321 npm run dev # http://localhost:4321
npm test # 26 file, 197 test npm test # 32 file, 261 test (26/09/2026)
npm run create-user -- <nome> <password> <ruolo> # ruoli: piattaforme, cliente, trainer, admin npm run create-user -- <nome> <password> <ruolo> # ruoli: piattaforme, cliente, trainer, admin
``` ```
I dati stanno in `data/` (gitignorata): `piattaforme.db`, `longevity.db`, `identity.db`. I dati stanno in `data/` (gitignorata): `piattaforme.db`, `longevity.db`, `identity.db`.
## Longevity: cosa c'è
| Chi | Dove | Cosa fa |
|---|---|---|
| `trainer`, `admin` | `/longevity/gestionale` | elenco clienti, nuovo cliente (apre il fascicolo su identity e longevity), scheda cliente con referto, sessioni e **misure da verificare** (conferma/scarta), nuovo check-up con i 24 test fisici `f_`, **crea accesso** al referto (password mostrata una volta) |
| `cliente` | `/longevity/io`, `/longevity/progressione`, `/longevity/questionario` | il proprio referto (radar, macro-score, Fitness Age), l'andamento nel tempo, il questionario |
`/longevity` smista: staff al gestionale, cliente al referto. Ogni pagina ha un'uscita:
il referto porta «Piattaforme» e, allo staff, «← Gestionale».
Il calcolo di una sessione usa, per ogni test, **la misura valida più recente del cliente fino
alla data della sessione** (`misureValideAlla` in `lib/longevity/motore/index.ts`): check-up e
questionario sono sessioni separate e si combinano. Una misura fuori dal range di plausibilità
non conta finché il trainer non la conferma; confermarla ricalcola quella sessione e le
successive. `FINESTRA_GIORNI = null`: oggi una misura non scade — la durata la decide Nicola.
Fuori per ora: import Wellness Tower e dinamometro (#3), percentili interni (#4). Il consenso ai
dati sanitari è firmato su carta prima dell'accesso (decisione di Adriano, 26/09, #6).
## Rilascio ## Rilascio
⚠️⚠️ **Il DNS viene prima del container.** `apps.insanitylab.it` deve già risolvere verso ⚠️⚠️ **Il DNS viene prima del container.** `apps.insanitylab.it` deve già risolvere verso
@@ -42,6 +61,24 @@ node scripts/migra-utenti.mjs # prova a vuoto
node scripts/migra-utenti.mjs --applica # porta gli utenti dal DB del sito node scripts/migra-utenti.mjs --applica # porta gli utenti dal DB del sito
``` ```
**Aggiornare una versione già in linea** (checkout `/opt/docker/insanitylab-apps`, su `main`):
```bash
D=/opt/docker/insanitylab-platforms/data; B=/opt/docker/insanitylab-platforms/backup/$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p $B && for f in longevity identity piattaforme; do sqlite3 $D/$f.db ".backup '$B/$f.db'"; done
git pull --ff-only origin main
cd apps/platforms && docker compose up -d --build
```
⚠️ **I dati veri sono in `/opt/docker/insanitylab-platforms/data`** (il volume del compose), non
in `apps/platforms/data` del checkout: quella è una copia vecchia che il container non legge.
⚠️ **Il database di Longevity si apre alla prima richiesta autenticata**, non all'avvio: le
migrazioni (`migra()` in `lib/longevity/db.ts`, colonne aggiunte con `ALTER TABLE`) e il seed del
registro partono solo allora. Per farle subito, dopo il deploy:
`docker exec -w /app insanitylab-platforms node --input-type=module -e 'const m = await import("/app/dist/server/chunks/<chunk db>.mjs"); m.<getLongevityDb>()'`
(nome del chunk e dell'export con `grep -l registro_test -r dist/server/chunks`).
## Trappole già pagate ## Trappole già pagate
⚠️ **Le versioni si fissano esatte.** `^7.0.5` sulla nuova app aveva risolto ad **astro ⚠️ **Le versioni si fissano esatte.** `^7.0.5` sulla nuova app aveva risolto ad **astro
@@ -65,5 +102,15 @@ qualcuno. Una rotta nuova senza regola risulta **vietata**, e lo si scopre prova
⚠️ **Le API rispondono con uno stato, non con un redirect** (401/403 su `/api/*`): mandare ⚠️ **Le API rispondono con uno stato, non con un redirect** (401/403 su `/api/*`): mandare
un 302 verso il login a chi ha chiesto del JSON produce un 200 con dentro dell'HTML. un 302 verso il login a chi ha chiesto del JSON produce un 200 con dentro dell'HTML.
⚠️ **Due test vietano parole, non codice.** `tests/longevity/anagrafica.test.ts` fallisce se un
file `.ts`/`.astro` contiene insieme «Identity» e «Longevity» (anche in un commento): solo
`lib/longevity/anagrafica.ts` e `db.ts` possono aprire i due database insieme.
`tests/longevity/referto.test.ts` intercetta «select» come SQL, anche l'HTML `<select>`: nei
form si usano i radio.
⚠️ **`node_modules` non si traccia.** Il pattern era `node_modules/`, che ignora le cartelle ma
non i symlink: un symlink verso il checkout di produzione era finito in git e un `npm ci` in un
clone svuotava la cartella di produzione (#9). Ora il pattern è `node_modules`.
⚠️ **Stress Index non ha un backend**: `src/lib/stress-index/data.ts` sono dieci clienti ⚠️ **Stress Index non ha un backend**: `src/lib/stress-index/data.ts` sono dieci clienti
inventati scritti a mano. In linea c'è la sua interfaccia, non i suoi dati. inventati scritti a mano. In linea c'è la sua interfaccia, non i suoi dati.
+9 -1
View File
@@ -11,7 +11,15 @@ export default defineConfig({
// il login smette di funzionare, e l'errore sembra "credenziali sbagliate". // il login smette di funzionare, e l'errore sembra "credenziali sbagliate".
// Stessa configurazione del sito, con l'host di quest'app. // Stessa configurazione del sito, con l'host di quest'app.
security: { security: {
allowedDomains: [{ hostname: 'apps.insanitylab.it', protocol: 'https' }], allowedDomains: [
// L'host definitivo, quando il record A su Aruba ci sara'.
{ hostname: 'apps.insanitylab.it', protocol: 'https' },
// ⚠️ L'host in uso oggi (03/09/2026): il dominio del cliente resta su Aruba e le aree
// riservate stanno su un sottodominio nostro. Finche' e' in uso deve stare qui:
// senza, dietro Traefik il controllo CSRF di Astro respinge ogni POST con 403 e il
// login sembra rifiutare le credenziali.
{ hostname: 'piattaforme.tielogic.xyz', protocol: 'https' },
],
}, },
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
+7 -1
View File
@@ -23,7 +23,13 @@ services:
- /opt/docker/insanitylab-platforms/data:/app/data - /opt/docker/insanitylab-platforms/data:/app/data
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.insanitylab-platforms.rule=Host(`apps.insanitylab.it`) # L'host di casa, creato da Adriano il 03/09/2026 sulla zona tielogic.xyz (Hostinger).
# ⚠️ Per un'ora circa le piattaforme avevano preso in prestito `insanitylab.tielogic.xyz`:
# quel prestito e' finito, e poi Adriano ha tolto del tutto quel record dal DNS.
# Quando il record A `apps` esistera' su Aruba, qui si aggiunge
# `|| Host(`apps.insanitylab.it`)` e si tengono doppi per un po', invece di spostare
# di netto un indirizzo che nel frattempo qualcuno ha salvato.
- traefik.http.routers.insanitylab-platforms.rule=Host(`piattaforme.tielogic.xyz`)
- traefik.http.routers.insanitylab-platforms.tls=true - traefik.http.routers.insanitylab-platforms.tls=true
- traefik.http.routers.insanitylab-platforms.entrypoints=websecure - traefik.http.routers.insanitylab-platforms.entrypoints=websecure
- traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge
+20
View File
@@ -26,6 +26,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"marked": "^18.0.11", "marked": "^18.0.11",
"tsx": "^4.23.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
@@ -6245,6 +6246,25 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+4 -1
View File
@@ -10,7 +10,9 @@
"start": "node ./dist/server/entry.mjs", "start": "node ./dist/server/entry.mjs",
"test": "vitest run", "test": "vitest run",
"create-user": "node scripts/create-user.mjs", "create-user": "node scripts/create-user.mjs",
"sync-campus": "node scripts/sync-campus.mjs" "sync-campus": "node scripts/sync-campus.mjs",
"seed-demo": "tsx scripts/seed-demo.ts",
"crea-fascicolo": "tsx scripts/crea-fascicolo.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "11.0.1", "@astrojs/node": "11.0.1",
@@ -31,6 +33,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"marked": "^18.0.11", "marked": "^18.0.11",
"tsx": "^4.23.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+75
View File
@@ -0,0 +1,75 @@
/**
* crea-fascicolo.ts — apre il fascicolo Longevity di una persona che ha già un account.
*
* A COSA SERVE: il referto si aggancia all'utente, non al ruolo (`codicePerUtenteLoggato`),
* quindi chiunque abbia un account — cliente, trainer o admin — può avere il proprio, ma
* solo se esiste la riga che lo lega al fascicolo. Finché non c'è il gestionale trainer,
* questo è l'unico modo di crearla senza inventarsi dati come fa seed-demo.
*
* COME SI USA:
* npm run crea-fascicolo -- --utente nicola --nome Nicola --cognome Antonelli --sesso M
* opzionali: --nascita 1980-01-31 --email x@y.it --telefono 333...
* percorsi: --utenti-db / --longevity-db / --identity-db, oppure DB_PATH /
* LONGEVITY_DB_PATH / IDENTITY_DB_PATH (gli stessi nomi del container).
*
* ⚠️ NON inventa niente. `--sesso` è obbligatorio perché entra nel calcolo dei punteggi
* (le curve dei test fisici sono distinte per sesso), e la data di nascita si può omettere
* ma allora l'età della sessione resta vuota: meglio un campo vuoto che una data
* plausibile e falsa.
* ⚠️ Si ferma se quell'utente ha già un fascicolo: due fascicoli per la stessa persona
* significano due storie parallele, e nessuna delle due è quella giusta.
* ⚠️ Il fascicolo nasce VUOTO: il referto dirà «non hai ancora compilato il questionario»
* finché la persona non lo compila da sé, che è il percorso vero.
*
* DIPENDE DA: tsx (devDependency).
*/
import { createLongevityDb, createIdentityDb } from '../src/lib/longevity/db';
import { creaCliente, codicePerUtente } from '../src/lib/longevity/anagrafica';
import { createDb } from '../src/lib/db';
const argv = process.argv.slice(2);
const opt = (nome: string, def?: string): string | undefined => {
const i = argv.indexOf(nome);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
};
const utente = opt('--utente');
const nome = opt('--nome');
const cognome = opt('--cognome');
const sesso = opt('--sesso');
if (!utente || !nome || !cognome || (sesso !== 'M' && sesso !== 'F')) {
console.error('Uso: npm run crea-fascicolo -- --utente <username> --nome <N> --cognome <C> --sesso <M|F>');
console.error(' opzionali: --nascita AAAA-MM-GG --email <mail> --telefono <n>');
process.exit(1);
}
const utentiDb = createDb(opt('--utenti-db', process.env.DB_PATH ?? 'data/piattaforme.db'));
const longevity = createLongevityDb(opt('--longevity-db', process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db'));
const identity = createIdentityDb(opt('--identity-db', process.env.IDENTITY_DB_PATH ?? 'data/identity.db'));
const riga = utentiDb.prepare('SELECT id, role FROM users WHERE username = ?').get(utente) as
{ id: number; role: string } | undefined;
if (!riga) {
console.error(`L'utente "${utente}" non esiste: crealo prima con npm run create-user.`);
process.exit(1);
}
const esistente = codicePerUtente(identity, riga.id);
if (esistente) {
console.error(`"${utente}" ha già il fascicolo ${esistente}: non ne apro un secondo.`);
process.exit(1);
}
const code = creaCliente(identity, longevity, {
nome, cognome, sesso,
data_nascita: opt('--nascita'),
email: opt('--email'),
telefono: opt('--telefono'),
user_id: riga.id,
});
console.log(`fascicolo ${code} aperto per ${nome} ${cognome}`);
console.log(` utente ${utente} (id ${riga.id}, ruolo ${riga.role})`);
console.log(` nascita ${opt('--nascita') ?? '— non dichiarata: l\'età delle sessioni resterà vuota'}`);
console.log('\nÈ vuoto: da /longevity/questionario la persona lo compila e il referto compare.');
+11 -3
View File
@@ -60,11 +60,19 @@ if (candidati.length === 0) {
const ins = dst.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)'); const ins = dst.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
const del = togli ? src.prepare('DELETE FROM users WHERE username = ?') : null; const del = togli ? src.prepare('DELETE FROM users WHERE username = ?') : null;
let copiati = 0, saltati = 0; let copiati = 0, saltati = 0, tolti = 0;
for (const u of candidati) { for (const u of candidati) {
if (gia.has(u.username)) { if (gia.has(u.username)) {
console.log(` = ${u.username.padEnd(16)} ${u.role.padEnd(12)} già presente a destinazione, lasciato com'è`); // Già a destinazione: non lo si tocca di là (una migrazione che sovrascrive non si può
// rilanciare). Ma `--togli-dal-sito` riguarda l'ORIGINE, e va fatto lo stesso: chi è
// arrivato in un giro precedente non ha meno diritto di uscire dal sito di chi arriva
// adesso. Fino al 04/09/2026 questo `continue` saltava anche la cancellazione, e
// l'opzione diventava un no-op silenzioso appena la copia era già avvenuta — che è il
// caso normale, visto che copiare e ripulire sono due passi separati apposta.
const esce = togli && applica;
console.log(` = ${u.username.padEnd(16)} ${u.role.padEnd(12)} già a destinazione, lasciato com'è${esce ? ' — tolto dal sito' : ''}`);
if (esce) { del.run(u.username); tolti++; }
saltati++; saltati++;
continue; continue;
} }
@@ -76,5 +84,5 @@ for (const u of candidati) {
} }
} }
console.log(`\n${applica ? `copiati ${copiati}` : `da copiare ${candidati.length - saltati}`}, saltati ${saltati}.`); console.log(`\n${applica ? `copiati ${copiati}` : `da copiare ${candidati.length - saltati}`}, saltati ${saltati}${togli && applica ? `, tolti dal sito ${tolti + copiati}` : ''}.`);
if (!applica) console.log('Niente è stato scritto.'); if (!applica) console.log('Niente è stato scritto.');
+123
View File
@@ -0,0 +1,123 @@
/**
* seed-demo.ts — un cliente dimostrativo con un referto vero da mostrare.
*
* COSA FA, in quest'ordine:
* 1. apre i tre database (li crea se mancano; il registro dei test si semina da sé,
* vedi src/lib/longevity/db.ts);
* 2. crea un utente con ruolo `cliente` in piattaforme.db;
* 3. crea il suo fascicolo — la riga con il nome in identity.db, quella senza nome in
* longevity.db — passando da `creaCliente`, l'unico varco fra le due;
* 4. compila il questionario per lui con risposte plausibili, attraverso `salvaDalForm`:
* cioè lo stesso percorso della pagina vera, consenso compreso. Da lì i punteggi
* vengono calcolati e congelati, ed è quello che il referto legge.
*
* COME SI USA:
* npm run seed-demo # utente "demo-cliente", password generata
* npm run seed-demo -- --utente nicola-demo --password sceltaDaTe
* npm run seed-demo -- --data 2026-09-03 # data della compilazione (default: oggi)
* percorsi: --utenti-db / --longevity-db / --identity-db, oppure le variabili
* DB_PATH / LONGEVITY_DB_PATH / IDENTITY_DB_PATH (stessi nomi del container).
*
* ⚠️ NON è idempotente per scelta: se l'utente esiste già lo dice e si ferma, senza
* toccare niente. Rifare la demo su dati esistenti significherebbe o duplicare un
* fascicolo o cancellarne uno, e nessuna delle due è una cosa che uno script deve
* decidere da solo.
*
* ⚠️ 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: 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.
*/
import { randomBytes } from 'node:crypto';
import { createLongevityDb, createIdentityDb } from '../src/lib/longevity/db';
import { creaCliente, etaAllaData } from '../src/lib/longevity/anagrafica';
import { salvaDalForm, campiDelQuestionario } from '../src/lib/longevity/vista';
import { createDb } from '../src/lib/db';
import { createUser } from '../src/lib/auth';
const argv = process.argv.slice(2);
const opt = (nome: string, def: string): string => {
const i = argv.indexOf(nome);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
};
const utente = opt('--utente', 'demo-cliente');
const password = opt('--password', randomBytes(9).toString('base64url'));
const nome = opt('--nome', 'Giulia');
const cognome = opt('--cognome', 'Esempio');
const nascita = opt('--nascita', '1990-05-14');
const data = opt('--data', new Date().toISOString().slice(0, 10));
const utentiDb = createDb(opt('--utenti-db', process.env.DB_PATH ?? 'data/piattaforme.db'));
const longevity = createLongevityDb(opt('--longevity-db', process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db'));
const identity = createIdentityDb(opt('--identity-db', process.env.IDENTITY_DB_PATH ?? 'data/identity.db'));
const esistente = utentiDb.prepare('SELECT id FROM users WHERE username = ?').get(utente) as { id: number } | undefined;
if (esistente) {
console.error(`L'utente "${utente}" esiste già (id ${esistente.id}): non tocco niente.`);
console.error('Usa --utente <altro-nome> per una demo nuova.');
process.exit(1);
}
/**
* Le risposte della persona inventata: una in salute ma non perfetta. Un questionario
* con tutti i valori al massimo darebbe un referto tutto verde, che è il modo migliore
* per non far vedere niente — né le curve, né la soglia del 40%, né cosa manca.
*/
const RISPOSTE: Record<string, number> = {
// sonno
q_ore_sonno: 7, q_min_addorm: 20, q_risvegli: 1, q_riposato: 7,
q_sonnolenza_diurna: 2, q_caffeina: 3,
// energia e stress
q_energia_media: 7, q_calo_pomeridiano: 4, q_tensione: 3, q_sopraffatto: 4,
q_esaurimento: 1, q_controllo: 7, q_sicurezza_gestione: 8, q_pensieri_lavoro: 2,
// stile di vita
q_attivita: 3, q_alimentazione: 7, q_alcol_life: 4, q_sigarette: 0,
q_luce: 2, q_schermi: 30,
};
// Il registro è la fonte: se una domanda è stata aggiunta o rinominata, questo script deve
// dirlo invece di salvare un questionario a metà senza che si veda.
const campi = campiDelQuestionario(longevity, data);
const mancanti = campi.filter((c) => !(c.id in RISPOSTE)).map((c) => c.id);
const inventate = Object.keys(RISPOSTE).filter((id) => !campi.some((c) => c.id === id));
if (mancanti.length || inventate.length) {
if (mancanti.length) console.error(`⚠️ domande del registro senza risposta qui: ${mancanti.join(', ')}`);
if (inventate.length) console.error(`⚠️ risposte che il registro non conosce: ${inventate.join(', ')}`);
console.error('Allinea RISPOSTE al registro prima di rifare la demo.');
process.exit(1);
}
const userId = createUser(utentiDb, utente, password, 'cliente');
const code = creaCliente(identity, longevity, {
nome, cognome, sesso: 'F', data_nascita: nascita, user_id: userId,
});
const esito = salvaDalForm(longevity, {
client_code: code,
data,
eta: etaAllaData(nascita, data),
risposte: RISPOSTE,
consensoSanitario: 'sì',
});
if (!esito.ok) {
console.error(`Il questionario non è stato salvato: ${esito.errore}`);
process.exit(1);
}
console.log(`utente ${utente} (ruolo cliente, id ${userId})`);
console.log(`password ${password}`);
console.log(`fascicolo ${code} — ${nome} ${cognome}, ${etaAllaData(nascita, data)} anni`);
console.log(`sessione ${esito.sessioneId}, questionario del ${data}, ${campi.length} risposte`);
console.log('\nEntra da /login e atterri sul referto: /longevity/io');
@@ -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<string, string | number | null> = { data: p.data };
for (const nome of nomiMacro) riga[nome] = p.macro[nome];
return riga;
});
const cambi = punti.filter((p) => p.cambioVersione !== null);
return (
<div className="lg-progressione">
<p className="lg-progressione__intro">
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.
</p>
<div className="lg-progressione__grafico">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={dati} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid stroke={LINEA} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="data" stroke={TESTO_CHIARO} fontSize={11} tickLine={false} />
<YAxis domain={[0, 100]} stroke={TESTO_CHIARO} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ border: `1px solid ${LINEA}`, borderRadius: 0, fontSize: 12 }}
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
/>
<Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />
{cambi.map((p) => (
<ReferenceLine
key={p.sessioneId}
x={p.data}
stroke={TRATTEGGIO}
strokeDasharray="4 3"
label={{ value: p.cambioVersione ?? '', position: 'top', fill: TESTO_CHIARO, fontSize: 10 }}
/>
))}
{nomiMacro.map((nome, i) => {
const stile = STILI_SERIE[i % STILI_SERIE.length];
return (
<Line
key={nome}
type="monotone"
dataKey={nome}
name={etichetta(nome)}
stroke={ACCENTO}
strokeWidth={stile.width}
strokeDasharray={stile.dash}
dot={{ r: 3 }}
connectNulls={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
{/* 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. */}
<div className="lg-progressione__tabella">
<table className="lg-progressione__table">
<thead>
<tr>
<th></th>
{punti.map((p) => (
<th key={p.sessioneId} className="lg-num">{p.data}</th>
))}
</tr>
</thead>
<tbody>
{nomiAssi.map((nome) => (
<tr key={nome}>
<td>{nome}</td>
{punti.map((p) => {
const v = p.assi[nome];
return (
<td key={p.sessioneId} className="lg-num">
{v !== null && v !== undefined ? formattaNumero(v) : '—'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+65 -12
View File
@@ -5,8 +5,8 @@
// dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro // 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 // volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno
// strumento di lavoro. Il layout prevede due forme: // strumento di lavoro. Il layout prevede due forme:
// - "referto": una colonna che scorre, senza navigazione (questo task) // - "referto": una colonna che scorre, senza navigazione (il cliente)
// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale) // - "gestionale": barra laterale sul modello di StressIndex.astro (il trainer, Unità 2b #2)
import '@fontsource/montserrat/400.css'; import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css'; import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css'; import '@fontsource/montserrat/600.css';
@@ -14,15 +14,31 @@ import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css'; import '@fontsource/open-sans/600.css';
import '../styles/global.css'; import '../styles/global.css';
import '../styles/longevity.css'; import '../styles/longevity.css';
import { codicePerUtenteLoggato } from '../lib/longevity/anagrafica';
interface Props { interface Props {
title: string; title: string;
/** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */ /** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */
crumbs?: string[]; 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'; variante?: 'referto' | 'gestionale';
} }
const { title, crumbs = [], variante = 'referto' } = Astro.props; const { title, crumbs = [], variante = 'referto' } = Astro.props;
const path = Astro.url.pathname;
// Nessuna pagina di Longevity deve lasciare l'utente senza un'uscita (#10): il gestionale
// mostra "Il mio referto" solo a chi ha davvero un fascicolo, e il referto porta sempre
// un link fuori dalla piattaforma (più uno al gestionale, per lo staff).
const user = Astro.locals.user;
const staff = user?.role === 'admin' || user?.role === 'trainer';
const haFascicolo = user ? codicePerUtenteLoggato(user.id) !== null : false;
// 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' },
];
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -34,14 +50,51 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props;
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head> </head>
<body class="lg" data-variante={variante}> <body class="lg" data-variante={variante}>
<div class="lg-shell"> {variante === 'gestionale' ? (
<header class="lg-top"> <>
<p class="lg-top__crumbs"> <input type="checkbox" id="lg-menu" class="lg-menu-toggle" hidden />
<strong>Longevity</strong> <label for="lg-menu" class="lg-menu-btn" aria-label="Apri il menu">☰ Menu</label>
{crumbs.map((c) => <span> / {c}</span>)} <aside class="lg-side">
</p> <a class="lg-brand" href="/longevity/gestionale">
</header> <span class="eyebrow">InsanityLab</span>
<main class="lg-main"><slot /></main> Longevity
</div> </a>
<a class="lg-home" href="/piattaforme">Piattaforme</a>
<nav class="lg-nav">
{NAV_GESTIONALE.map((item) => (
<a href={item.href} class:list={['lg-nav__link', { 'is-active': path === item.href }]}>
{item.label}
</a>
))}
{haFascicolo && (
<a class="lg-nav__link lg-nav__link--referto" href="/longevity/io">Il mio referto</a>
)}
</nav>
</aside>
<div class="lg-body">
<header class="lg-top">
<p class="lg-top__crumbs">
<strong>Longevity</strong>
{crumbs.map((c) => <span> / {c}</span>)}
</p>
</header>
<main class="lg-main"><slot /></main>
</div>
</>
) : (
<div class="lg-shell">
<header class="lg-top">
<p class="lg-top__crumbs">
<strong>Longevity</strong>
{crumbs.map((c) => <span> / {c}</span>)}
</p>
<p class="lg-top__links">
{staff && <a class="lg-top__link" href="/longevity/gestionale">← Gestionale</a>}
<a class="lg-top__link" href="/piattaforme">Piattaforme</a>
</p>
</header>
<main class="lg-main"><slot /></main>
</div>
)}
</body> </body>
</html> </html>
+19 -7
View File
@@ -61,9 +61,15 @@ export function logout(db: Database.Database, token: string): void {
// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto: // /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
// qui arrivano solo utenti già autenticati su una di quelle rotte. // qui arrivano solo utenti già autenticati su una di quelle rotte.
const RULES: [RegExp, Role[]][] = [ const RULES: [RegExp, Role[]][] = [
// Le due piattaforme storiche: il Biohacking Campus (contenuti) e Stress Index. // ⚠️ L'INDICE /piattaforme e' la casa comune: lo apre chiunque sia loggato, perche' e'
// la pagina su cui si atterra dopo il login (deciso da Adriano il 03/09/2026). Mostra a
// ognuno solo le sezioni che puo' aprire davvero — il filtro e' in data/piattaforme.
// Stress Index sta SOTTO quel percorso ma resta riservato, quindi la sua regola viene
// prima: vince la prima che combacia, e la piu' specifica deve poter parlare per prima.
[/^\/piattaforme\/stress-index(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme\/?$/, [...ROLES]],
// Il Biohacking Campus (contenuti del corso).
[/^\/campus(\/|$)/, ['admin', 'piattaforme']], [/^\/campus(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
// Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer. // Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer.
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
@@ -130,12 +136,18 @@ export function isRole(v: unknown): v is Role {
} }
/** /**
* Dove atterra chi ha appena fatto il login. ⚠️ `/admin` non esiste in quest'app: il * Dove atterra chi ha appena fatto il login: **sempre l'indice delle piattaforme**, per
* fondo della scala è l'indice delle piattaforme, non un pannello. * tutti i ruoli (deciso da Adriano il 03/09/2026 — si entra e si sceglie dove andare,
* anche quando la scelta e' una sola).
*
* ⚠️ Questa funzione e' anche il "torna a casa" del middleware quando un ruolo apre una
* sezione che non gli spetta. Quindi la sua destinazione DEVE essere aperta a ogni ruolo,
* o si forma un ciclo di redirect: il middleware nega la pagina, rimanda a landingFor, e
* landingFor rimanda dove il middleware nega di nuovo. E' esattamente il guasto che il
* sito ha rischiato lo stesso giorno con gli account emigrati. Un test lo presidia
* girando su tutti i ruoli.
*/ */
export function landingFor(role: string): string { export function landingFor(_role: string): string {
if (role === 'cliente') return '/longevity/io';
if (role === 'trainer') return '/longevity/gestionale';
return '/piattaforme'; return '/piattaforme';
} }
@@ -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 };
}
+126 -1
View File
@@ -4,7 +4,7 @@
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione // 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. // la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3'; 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 // 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 // è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
@@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null {
return codicePerUtente(getIdentityDb(), userId); 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. */ /** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number { export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita); const n = new Date(dataNascita);
@@ -94,3 +134,88 @@ export function etaAllaData(dataNascita: string, alla: string): number {
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
return 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);
}
@@ -0,0 +1,157 @@
// La prima consulenza (#15): una scheda sola per cliente — livello, obiettivo, note dello
// screening iniziale — che il trainer aggiorna in ogni follow-up. Correggere e salvare
// SOVRASCRIVE, nessuno storico (decisione di Adriano, 27/09/2026): il racconto dei cambi va
// nel giornale di bordo (giornale.ts), non qui. Stesso vincolo del resto di longevity.db:
// solo client_code, mai un nome.
import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
export const TESTO_MAX = 5000;
// Elenco chiuso dell'obiettivo primario (foglio «Profilo Cliente», call del 27/09): chiave
// salvata nel DB, etichetta mostrata nel form — stesso schema di ETICHETTA_TIPO altrove nel
// gestionale.
export const OBIETTIVI: { chiave: string; etichetta: string }[] = [
{ chiave: 'performance_sportiva', etichetta: 'Performance sportiva' },
{ chiave: 'energia_quotidiana', etichetta: 'Energia quotidiana' },
{ chiave: 'recupero_benessere', etichetta: 'Recupero e benessere' },
{ chiave: 'riabilitazione', etichetta: 'Riabilitazione' },
{ chiave: 'ricomposizione_corporea', etichetta: 'Ricomposizione corporea' },
{ chiave: 'dimagrimento', etichetta: 'Dimagrimento' },
{ chiave: 'altro', etichetta: 'Altro' },
];
const CHIAVI_OBIETTIVI = new Set(OBIETTIVI.map((o) => o.chiave));
export type Consulenza = {
client_code: string;
livello: number | null;
obiettivo_primario: string | null;
obiettivo_specifico: string | null;
data_target: string | null;
note_mediche: string | null;
storia_sportiva: string | null;
limitazioni: string | null;
disponibilita_giorni: number | null;
fascia_oraria: string | null;
note_screening: string | null;
aggiornata_da: string;
aggiornata_at: string;
};
/** La scheda del cliente, o null se non è mai stata compilata. */
export function consulenzaDi(db: Database.Database, clientCode: string): Consulenza | null {
return (db.prepare(`SELECT * FROM consulenza WHERE client_code = ?`).get(clientCode) as
Consulenza | undefined) ?? null;
}
// I campi grezzi, tutti facoltativi e tutti stringhe: chi chiama (l'API) li legge dal
// FormData così come sono, la validazione vera è qui — stesso confine di aggiungiVoce
// (giornale.ts), che riceve stringhe e decide se sono valide.
export type CampiConsulenza = {
livello?: string;
obiettivoPrimario?: string;
obiettivoSpecifico?: string;
dataTarget?: string;
noteMediche?: string;
storiaSportiva?: string;
limitazioni?: string;
disponibilitaGiorni?: string;
fasciaOraria?: string;
noteScreening?: string;
};
export type EsitoSalvataggio = { ok: true } | { ok: false; errore: string };
// Una stringa vuota (dopo trim) diventa NULL: tutti i campi sono facoltativi (decisione di
// Adriano, 27/09/2026), e NULL è "non compilato", non un errore.
const nullSeVuoto = (v: string | undefined): string | null => {
const t = (v ?? '').trim();
return t === '' ? null : t;
};
const testoValido = (v: string | null): v is string | null => v === null || v.length <= TESTO_MAX;
/**
* Scrive (o riscrive) la scheda di prima consulenza. Rifiuta PRIMA di scrivere: soggetto
* inesistente, livello fuori 1-3, obiettivo fuori elenco, data_target malformata,
* disponibilità fuori 0-7 o non intera, un testo libero oltre TESTO_MAX — stesso schema di
* validazione di aggiungiVoce (giornale.ts) e salvaCheckup (gestionale.ts). UPSERT: una
* seconda chiamata sullo stesso client_code sovrascrive la prima, niente storico.
*/
export function salvaConsulenza(
db: Database.Database,
input: { clientCode: string; autore: string; campi: CampiConsulenza }
): EsitoSalvataggio {
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
if (!soggetto) {
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
}
const c = input.campi;
let livello: number | null = null;
const livelloGrezzo = (c.livello ?? '').trim();
if (livelloGrezzo !== '') {
livello = Number(livelloGrezzo);
if (!Number.isInteger(livello) || livello < 1 || livello > 3) {
return { ok: false, errore: 'Livello non valido: deve essere 1, 2 o 3.' };
}
}
const obiettivoPrimario = nullSeVuoto(c.obiettivoPrimario);
if (obiettivoPrimario !== null && !CHIAVI_OBIETTIVI.has(obiettivoPrimario)) {
return { ok: false, errore: `Obiettivo primario non valido: ${obiettivoPrimario}` };
}
const dataTarget = nullSeVuoto(c.dataTarget);
if (dataTarget !== null && !REGEX_DATA.test(dataTarget)) {
return { ok: false, errore: 'Data target non valida (formato AAAA-MM-GG).' };
}
let disponibilitaGiorni: number | null = null;
const disponibilitaGrezza = (c.disponibilitaGiorni ?? '').trim();
if (disponibilitaGrezza !== '') {
disponibilitaGiorni = Number(disponibilitaGrezza);
if (!Number.isInteger(disponibilitaGiorni) || disponibilitaGiorni < 0 || disponibilitaGiorni > 7) {
return { ok: false, errore: 'Disponibilità non valida: deve essere un numero intero fra 0 e 7.' };
}
}
const obiettivoSpecifico = nullSeVuoto(c.obiettivoSpecifico);
const noteMediche = nullSeVuoto(c.noteMediche);
const storiaSportiva = nullSeVuoto(c.storiaSportiva);
const limitazioni = nullSeVuoto(c.limitazioni);
const fasciaOraria = nullSeVuoto(c.fasciaOraria);
const noteScreening = nullSeVuoto(c.noteScreening);
for (const [nome, valore] of [
['Obiettivo specifico', obiettivoSpecifico], ['Note mediche', noteMediche],
['Storia sportiva', storiaSportiva], ['Limitazioni', limitazioni],
['Fascia oraria', fasciaOraria], ['Note screening', noteScreening],
] as const) {
if (!testoValido(valore)) {
return { ok: false, errore: `${nome} non può superare ${TESTO_MAX} caratteri.` };
}
}
db.prepare(
`INSERT INTO consulenza (
client_code, livello, obiettivo_primario, obiettivo_specifico, data_target,
note_mediche, storia_sportiva, limitazioni, disponibilita_giorni, fascia_oraria,
note_screening, aggiornata_da, aggiornata_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (client_code) DO UPDATE SET
livello = excluded.livello, obiettivo_primario = excluded.obiettivo_primario,
obiettivo_specifico = excluded.obiettivo_specifico, data_target = excluded.data_target,
note_mediche = excluded.note_mediche, storia_sportiva = excluded.storia_sportiva,
limitazioni = excluded.limitazioni, disponibilita_giorni = excluded.disponibilita_giorni,
fascia_oraria = excluded.fascia_oraria, note_screening = excluded.note_screening,
aggiornata_da = excluded.aggiornata_da, aggiornata_at = excluded.aggiornata_at`
).run(
input.clientCode, livello, obiettivoPrimario, obiettivoSpecifico, dataTarget,
noteMediche, storiaSportiva, limitazioni, disponibilitaGiorni, fasciaOraria,
noteScreening, input.autore
);
return { ok: true };
}
+90 -1
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { seedRegistro, seedPesi, MODEL_VERSION } from './registro';
/** /**
* Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. * Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore.
@@ -53,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure (
fonte TEXT NOT NULL CHECK (fonte IN fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')), ('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
@@ -102,6 +110,36 @@ CREATE TABLE IF NOT EXISTS score (
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL)) CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
); );
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
-- Giornale di bordo per cliente (issue #16, solo staff): una nota di testo libero, mai
-- modificata — sbagliata si cancella e si riscrive (decisione di Adriano, 27/09/2026). Come
-- il resto di questo schema, solo client_code: nessun nome qui, mai in longevity.db.
CREATE TABLE IF NOT EXISTS giornale (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
autore TEXT NOT NULL,
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC);
-- Prima consulenza (issue #15): una scheda sola per cliente, sovrascritta a ogni modifica —
-- nessuno storico (decisione di Adriano, 27/09/2026, come il giornale qui sopra ma senza
-- neanche l'aggiungi/cancella: qui è un UPSERT unico). Solo client_code, mai un nome.
CREATE TABLE IF NOT EXISTS consulenza (
client_code TEXT PRIMARY KEY REFERENCES soggetti(client_code),
livello INTEGER CHECK (livello IN (1,2,3)),
obiettivo_primario TEXT,
obiettivo_specifico TEXT,
data_target TEXT,
note_mediche TEXT,
storia_sportiva TEXT,
limitazioni TEXT,
disponibilita_giorni INTEGER CHECK (disponibilita_giorni BETWEEN 0 AND 7),
fascia_oraria TEXT,
note_screening TEXT,
aggiornata_da TEXT NOT NULL,
aggiornata_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`; `;
const SCHEMA_IDENTITY = ` const SCHEMA_IDENTITY = `
@@ -127,8 +165,59 @@ function apri(p: string, schema: string): Database.Database {
return db; 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.
*
* Fino al 03/09/2026 `seedRegistro` e `seedPesi` esistevano, erano giusti, ed erano
* chiamati **solo dai test**: su un database nuovo il registro restava vuoto, quindi
* `campiDelQuestionario` non trovava niente e la pagina del questionario si apriva
* **senza domande**. Non era un difetto della demo: era il primo avvio in produzione.
* È lo stesso difetto già visto una volta in questo ramo — «una funzione corretta che
* nessuno chiama» — e la correzione è la stessa: agganciarla dove non si può dimenticare.
*
* Entrambe le semine sono `INSERT OR REPLACE`, quindi rilanciarle è a vuoto; e il
* registro è **dato di prodotto** (le domande e i pesi decisi con Nicola), non dato di
* un cliente: riallinearlo a ogni avvio è voluto, così una domanda modificata in
* `registro.ts` entra col rilascio invece che con una migrazione a mano.
* ⚠️ I punteggi già emessi non cambiano: sono congelati in `score` e il referto legge
* quelli, mai un ricalcolo (vedi `refertoDi`).
*/
export function createLongevityDb(path?: string): Database.Database { export function createLongevityDb(path?: string): Database.Database {
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
migra(db);
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
return db;
} }
export function createIdentityDb(path?: string): Database.Database { export function createIdentityDb(path?: string): Database.Database {
@@ -0,0 +1,494 @@
//
// 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, fuoriRangeDi, type MisuraIn, type Fonte } from './misure';
import { calcolaSessione, salvaScore, leggiScore } from './motore';
import type { CampoQuestionario } from './vista';
export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] };
/** L'ordine degli assi dai pesi (livello 'asse'), nell'ordine in cui compaiono per la prima
* volta — stesso criterio di `contenitoriDi` in vista.ts. Estratto qui perché lo usano sia
* `campiDelCheckup` sia `misureDellaSessione` (#12): due elenchi di assi che potrebbero
* divergere sarebbero peggio di uno solo. */
function ordineAssiDaiPesi(db: Database.Database): string[] {
return (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);
}
/**
* 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 = ordineAssiDaiPesi(db);
const perAsse = new Map<string, CampoQuestionario[]>();
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)! }));
}
// --- Struttura del modulo di check-up (#13, manuale di Nicola del 27/09/2026) --------------
// A differenza di campiDelCheckup (raggruppato per asse, invariato: lo usa ancora la pagina
// di dettaglio sessione, sessione/[id].astro, per la vista di sola lettura), qui l'ordine e
// il raggruppamento sono quelli del modulo cartaceo: Pre-test -> Mobilità -> Equilibrio ->
// Presa -> Core -> Forza spinta -> Forza trazione -> Arti inferiori -> Aerobico -> Strumenti.
// Una sezione o e' a campi fissi, o e' "ad alternative" (piu' protocolli per la stessa
// sotto-metrica, uno solo si compila: radio button, mai <select> — tests/longevity/
// referto.test.ts intercetta la parola). test_id qui sono stringhe: si risolvono contro
// testAttivi in strutturaCheckup, cosi' un test dismesso sparisce da solo dal modulo.
type AlternativaRaw = { chiave: string; nome: string; soglia: string; campi: string[]; dettagli?: string[] };
type SezioneRaw =
| { titolo: string; tipo: 'campi'; campi: string[] }
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaRaw[] };
// Pressione e SpO2 NON compaiono nel modulo (decisione di Adriano, 27/09/2026: arrivano
// dall'import Wellness Tower di #3, non da inserimento manuale) — esclusi anche da
// «Altre misure», non solo dalle sezioni: sono attivi nel registro ma il modulo non li mostra.
const ESCLUSI_DAL_MODULO = new Set(['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']);
const STRUTTURA_RAW: SezioneRaw[] = [
{ titolo: 'Pre-test', tipo: 'campi', campi: ['f_fc_riposo_bpm', 'f_peso_kg', 'f_altezza_cm'] },
{ titolo: 'Mobilità', tipo: 'campi', campi: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm', 'f_sit_reach_cm'] },
{ titolo: 'Equilibrio', tipo: 'alternative', chiave: 'equilibrio', alternative: [
{ chiave: 'occhi_aperti', nome: 'Flamingo, occhi aperti',
soglia: 'Soglia: 4-7 cadute a occhi aperti → passa a occhi chiusi.',
campi: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
{ chiave: 'occhi_chiusi', nome: 'Flamingo, occhi chiusi', soglia: '',
campi: ['f_flamingo_oc_dx', 'f_flamingo_oc_sx'] },
] },
{ titolo: 'Presa', tipo: 'campi', campi: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
{ titolo: 'Core', tipo: 'alternative', chiave: 'core', alternative: [
{ chiave: 'ginocchia', nome: 'Plank sulle ginocchia',
soglia: 'Soglia: ≥30-45 s sulle ginocchia → passa a plank pieno.', campi: ['f_plank_ginocchia_sec'] },
{ chiave: 'pieno', nome: 'Plank pieno', soglia: '', campi: ['f_plank_sec'] },
] },
{ titolo: 'Forza spinta', tipo: 'alternative', chiave: 'spinta', alternative: [
{ chiave: 'manubri', nome: 'Panca con manubri (5RM, kg per manubrio)',
soglia: 'Soglia: esegue ≥1 push-up corretto → prova push-up.',
campi: ['f_panca_man_carico_kg', 'f_panca_man_rip'] },
{ chiave: 'pushup', nome: 'Push-up (donne sulle ginocchia)',
soglia: 'Soglia: fascia «Buono» o oltre → passa a panca con bilanciere.', campi: ['f_pushup_rip'] },
{ chiave: 'bilanciere', nome: 'Panca con bilanciere (5RM)', soglia: '',
campi: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
] },
{ titolo: 'Forza trazione', tipo: 'alternative', chiave: 'trazione', alternative: [
{ chiave: 'rematore', nome: 'Rematore (5RM)',
soglia: 'Soglia: ≥1 trazione, o carico 0,95-1,19× peso corporeo → passa a trazioni.',
campi: ['f_row_carico_kg', 'f_row_rip'] },
{ chiave: 'trazioni', nome: 'Trazioni alla sbarra', soglia: '', campi: ['f_trazioni_rip'] },
] },
{ titolo: 'Arti inferiori', tipo: 'alternative', chiave: 'arti_inferiori', alternative: [
{ chiave: 'sit_to_stand', nome: 'Sit-to-stand, 1 minuto (sedia 43-45 cm)',
soglia: 'Soglia: rapporto risultato/predetto ≥1,0 → passa a squat.', campi: ['f_sit_to_stand_rip'] },
{ chiave: 'squat', nome: 'Squat (5RM)', soglia: '', campi: ['f_squat_carico_kg', 'f_squat_rip'] },
] },
{ titolo: 'Aerobico', tipo: 'alternative', chiave: 'aerobico', alternative: [
{ chiave: '6mwt', nome: '6 Minute Walk Test',
soglia: 'Soglia: ≥80% del predetto → passa a Queen\'s College o 2 km.', campi: ['f_6mwt_m'] },
{ chiave: 'queens', nome: 'Queen\'s College Step Test',
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_step_hr_recovery'] },
{ chiave: '2km', nome: 'Camminata 2 km (UKK)',
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
{ chiave: 'mutt', nome: 'MUTT', soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).',
campi: ['f_mutt_vam', 'f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm', 'f_mutt_fc_2min_bpm'],
dettagli: [
'f_mutt_fc_7_5', 'f_mutt_fc_8_5', 'f_mutt_fc_9_5', 'f_mutt_fc_10_5',
'f_mutt_fc_11_5', 'f_mutt_fc_12_5', 'f_mutt_fc_13_5', 'f_mutt_fc_14_5',
] },
{ chiave: 'milfit', nome: 'MILFIT',
soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).', campi: ['f_milfit_watt'] },
{ chiave: 'calibre', nome: 'Calibre (VO2max misurato)', soglia: '', campi: ['f_vo2max'] },
] },
{ titolo: 'Strumenti (Wellness Tower, Stress Index)', tipo: 'campi',
campi: ['f_grasso_pct', 'f_muscolo_pct', 'f_whr', 'f_hrv_score', 'f_spalla_outreach_deg', 'f_spalla_buckling_deg'] },
];
export type AlternativaCheckup = {
chiave: string; nome: string; soglia: string;
campi: CampoQuestionario[]; dettagli?: CampoQuestionario[];
};
export type SezioneCheckup =
| { titolo: string; tipo: 'campi'; campi: CampoQuestionario[] }
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaCheckup[] };
export type ModuloCheckup = { sezioni: SezioneCheckup[]; altreMisure: CampoQuestionario[] };
/**
* Il modulo del check-up nell'ordine del manuale (#13): solo i test `f_*` attivi alla data
* indicata. Un test attivo ma assente da STRUTTURA_RAW (i dismessi, correggendo una sessione
* vecchia) finisce in `altreMisure`, mai perso — tranne pressione e SpO2 (ESCLUSI_DAL_MODULO),
* che non compaiono da nessuna parte qui: restano nel registro, li scrive l'import #3.
*/
export function strutturaCheckup(db: Database.Database, alla: string): ModuloCheckup {
const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_'));
const perId = new Map<string, CampoQuestionario>(fisici.map((v) => [v.test_id, {
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,
}]));
const usati = new Set<string>(ESCLUSI_DAL_MODULO);
const risolvi = (ids: string[]): CampoQuestionario[] => {
const out: CampoQuestionario[] = [];
for (const id of ids) {
usati.add(id);
const c = perId.get(id);
if (c) out.push(c); // non attivo alla data: si salta, non un campo vuoto
}
return out;
};
const sezioni: SezioneCheckup[] = [];
for (const s of STRUTTURA_RAW) {
if (s.tipo === 'campi') {
const campi = risolvi(s.campi);
if (campi.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'campi', campi });
} else {
const alternative: AlternativaCheckup[] = [];
for (const a of s.alternative) {
const campi = risolvi(a.campi);
const dettagli = a.dettagli ? risolvi(a.dettagli) : undefined;
if (campi.length > 0 || (dettagli && dettagli.length > 0)) {
alternative.push({ chiave: a.chiave, nome: a.nome, soglia: a.soglia, campi, dettagli });
}
}
if (alternative.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'alternative', chiave: s.chiave, alternative });
}
}
const altreMisure = fisici.filter((v) => !usati.has(v.test_id)).map((v) => perId.get(v.test_id)!);
return { sezioni, altreMisure };
}
/**
* Quale alternativa preselezionare in una sezione ad alternative (#13): la prima, in ordine
* di elencazione, che ha almeno uno dei suoi test_id (campi o dettagli) fra `valori` — in
* correzione `valori` sono le misure della sessione, in un check-up nuovo quelle dell'ultimo
* check-up del cliente (`valoriUltimoCheckup`). Senza corrispondenza vince la prima
* dell'elenco (il livello neofita, come dice l'analisi).
*/
export function alternativaSelezionata(
sezione: Extract<SezioneCheckup, { tipo: 'alternative' }>, valori: Map<string, number>
): string {
for (const alt of sezione.alternative) {
const ids = [...alt.campi.map((c) => c.id), ...(alt.dettagli?.map((c) => c.id) ?? [])];
if (ids.some((id) => valori.has(id))) return alt.chiave;
}
return sezione.alternative[0]?.chiave ?? '';
}
/**
* Le misure dell'ultimo check-up del cliente (più recente prima, come `sessioniDi`), per
* precompilare `alternativaSelezionata` in un check-up NUOVO — mappa vuota se il cliente non
* ha ancora un check-up.
*/
export function valoriUltimoCheckup(db: Database.Database, clientCode: string): Map<string, number> {
const riga = db.prepare(
`SELECT id FROM sessioni WHERE client_code = ? AND tipo = 'checkup' ORDER BY data DESC, id DESC LIMIT 1`
).get(clientCode) as { id: number } | undefined;
if (!riga) return new Map();
const dettaglio = misureDellaSessione(db, riga.id);
const out = new Map<string, number>();
for (const m of dettaglio?.misure ?? []) {
if (m.valore_num !== null) out.set(m.test_id, m.valore_num);
}
return out;
}
/**
* I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13):
* `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel
* form). Una sezione ASSENTE da `scelte` (nessuna radio arrivata: form senza JS che non ha
* ancora marcato nulla, o client non-browser) non scarta NIENTE — ripiegare sulla prima
* alternativa butterebbe via in silenzio campi compilati di un'altra (es. f_plank_sec senza
* alt_core), correzione del passo 3 su #13. Una sezione a campi fissi non contribuisce: nulla
* da scartare lì.
*/
export function testIdScartatiDalleAlternative(
sezioni: SezioneCheckup[], scelte: Map<string, string>
): Set<string> {
const scartati = new Set<string>();
for (const s of sezioni) {
if (s.tipo !== 'alternative') continue;
const scelta = scelte.get(s.chiave);
if (scelta === undefined) continue; // nessuna radio per questa sezione: non si scarta nulla
for (const alt of s.alternative) {
if (alt.chiave === scelta) continue;
for (const c of alt.campi) scartati.add(c.id);
for (const c of alt.dettagli ?? []) scartati.add(c.id);
}
}
return scartati;
}
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 MisuraDiSessione = {
id: number; test_id: string; etichetta: string;
valore_num: number | null; valore_txt: string | null; unita: string | null;
fonte: Fonte; fuori_range: number; verifica: 'confermata' | 'scartata' | null;
};
export type SessioneDettaglio = {
id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario';
operatore: string | null; misure: MisuraDiSessione[];
};
/**
* Il dettaglio di una sessione con le sue misure (#12, pagina di dettaglio e precompilazione
* della correzione): null se la sessione non esiste. Le misure sono ordinate per asse —
* stesso criterio di `campiDelCheckup`, via `ordineAssiDaiPesi` — poi per test_id, così il
* dettaglio di un check-up si legge negli stessi raggruppamenti del modulo che lo ha
* compilato.
*/
export function misureDellaSessione(db: Database.Database, sessioneId: number): SessioneDettaglio | null {
const sess = db.prepare(
`SELECT id, client_code, data, tipo, operatore FROM sessioni WHERE id = ?`
).get(sessioneId) as
{ id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null }
| undefined;
if (!sess) return null;
const righe = db.prepare(
`SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, r.asse AS asse,
m.valore_num AS valore_num, m.valore_txt AS valore_txt, m.unita AS unita,
m.fonte AS fonte, m.fuori_range AS fuori_range, m.verifica AS verifica
FROM misure m JOIN registro_test r ON r.test_id = m.test_id
WHERE m.sessione_id = ?`
).all(sessioneId) as (MisuraDiSessione & { asse: string | null })[];
const posizioneAsse = new Map(ordineAssiDaiPesi(db).map((a, i) => [a, i]));
righe.sort((a, b) => {
const pa = posizioneAsse.get(a.asse ?? '') ?? Infinity;
const pb = posizioneAsse.get(b.asse ?? '') ?? Infinity;
return pa - pb || a.test_id.localeCompare(b.test_id);
});
const misure: MisuraDiSessione[] = righe.map(({ asse: _asse, ...m }) => m);
return { id: sess.id, client_code: sess.client_code, data: sess.data, tipo: sess.tipo, operatore: sess.operatore, misure };
}
/**
* Corregge un check-up già salvato (#12): stesse validazioni di `salvaCheckup`, poi
* un'unica transazione che aggiorna la sessione e le sue misure e ricalcola i punteggi.
* Nessuno storico delle misure: la correzione SOVRASCRIVE (decisione di Adriano, 27/09/2026)
* — una misura cambiata perde la verifica precedente (il trainer deve rivederla sul nuovo
* valore, non su quello vecchio), una tolta dal form si cancella, una nuova si inserisce
* con `registraMisure` (fonte 'manuale': resta l'unico INSERT di `misure`, vedi misure.ts).
*
* Rifiuta PRIMA di scrivere: nessuna misura, un test_id fuori dal registro o non fisico
* (stesso criterio di `salvaCheckup`), una sessione inesistente, o una sessione che non è
* un check-up (un questionario non si corregge da qui).
*/
export function correggiCheckup(
db: Database.Database,
input: {
sessioneId: number; 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 sessione = db.prepare(
`SELECT id, client_code, data, tipo FROM sessioni WHERE id = ?`
).get(input.sessioneId) as
{ id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario' } | undefined;
if (!sessione) {
return { ok: false, errore: `Sessione inesistente: ${input.sessioneId}` };
}
if (sessione.tipo !== 'checkup') {
return { ok: false, errore: 'Solo un check-up si corregge da qui, non un questionario.' };
}
// Solo le misure fisiche (f_*) della sessione entrano nel confronto: di tipo checkup non
// ce ne sono altre, ma per sicurezza (e per lo stesso motivo di salvaCheckup) una misura
// non f_* eventualmente presente non si tocca.
const esistenti = (db.prepare(
`SELECT id, test_id, valore_num, unita FROM misure WHERE sessione_id = ?`
).all(input.sessioneId) as { id: number; test_id: string; valore_num: number | null; unita: string | null }[])
.filter((r) => r.test_id.startsWith('f_'));
const esistentiPerTestId = new Map(esistenti.map((r) => [r.test_id, r]));
const nuoviPerTestId = new Map(input.misure.map((m) => [m.test_id, m]));
const fuoriRange: string[] = [];
const daInserire: MisuraIn[] = [];
const tx = db.transaction(() => {
db.prepare(
`UPDATE sessioni SET data = ?, eta_alla_data = ?, operatore = ? WHERE id = ?`
).run(input.data, input.eta ?? null, input.operatore, input.sessioneId);
const tuttiTestId = new Set([...esistentiPerTestId.keys(), ...nuoviPerTestId.keys()]);
for (const testId of tuttiTestId) {
const vecchia = esistentiPerTestId.get(testId);
const nuova = nuoviPerTestId.get(testId);
if (vecchia && !nuova) {
db.prepare(`DELETE FROM misure WHERE id = ?`).run(vecchia.id); // tolto dal form
continue;
}
if (!vecchia && nuova) {
daInserire.push(nuova); // nuovo campo compilato: un solo INSERT via registraMisure
continue;
}
if (vecchia && nuova) {
const invariato = (vecchia.valore_num ?? null) === (nuova.valore_num ?? null);
if (invariato) continue; // la riga resta com'è: mantiene verifica/verificata_*
const fuori = fuoriRangeDi(db, testId, nuova.valore_num);
if (fuori) fuoriRange.push(testId);
db.prepare(
`UPDATE misure SET valore_num = ?, unita = ?, fuori_range = ?,
verifica = NULL, verificata_da = NULL, verificata_at = NULL
WHERE id = ?`
).run(nuova.valore_num ?? null, nuova.unita ?? null, fuori ? 1 : 0, vecchia.id);
}
}
if (daInserire.length > 0) {
const { fuoriRange: fuoriNuove } = registraMisure(db, input.sessioneId, 'manuale', daInserire);
fuoriRange.push(...fuoriNuove);
}
// Ricalcolo: la sessione stessa più ogni sessione del cliente dalla posizione minima fra
// quella vecchia e quella nuova (stesso criterio di verificaMisura, misure.ts) — l'id
// non cambia con una correzione, solo la data può spostare la sessione: il minimo copre
// entrambi i lati.
const dataMin = input.data < sessione.data ? input.data : sessione.data;
const daRicalcolare = db.prepare(
`SELECT id FROM sessioni
WHERE client_code = ? AND (data > ? OR (data = ? AND id >= ?))
ORDER BY data ASC, id ASC`
).all(sessione.client_code, dataMin, dataMin, input.sessioneId) as { id: number }[];
for (const s of daRicalcolare) {
salvaScore(db, s.id, calcolaSessione(db, s.id));
}
});
tx();
return { ok: true, sessioneId: input.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,
};
});
}
@@ -0,0 +1,92 @@
// Il giornale di bordo per cliente (#16, solo staff — il cliente non lo vede): una nota di
// testo libero legata a una data, che il trainer scrive per tenere la storia del percorso.
// Stesso vincolo di gestionale.ts: solo il database clinico (vedi db.ts), mai un nome — un
// cliente qui è solo un client_code, mai un nome. Nessuna voce automatica (decisione di
// Adriano, 27/09/2026): si aggiunge e si cancella, non si modifica — una nota sbagliata si
// cancella e si riscrive, così non c'è uno storico di modifiche da tenere.
import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
// Esportato (#13, passo 3): l'API del check-up valida la nota PRIMA di salvare, con lo
// stesso limite di aggiungiVoce — un numero duplicato qui e lì marcirebbe silenziosamente.
export const TESTO_MAX = 5000;
export type VoceGiornale = {
id: number;
data: string;
autore: string;
testo: string;
created_at: string;
};
/** Le voci di un cliente, più recente prima (data DESC, a parità id DESC — stesso criterio
* di sessioniDi in gestionale.ts). */
export function vociDelGiornale(db: Database.Database, clientCode: string): VoceGiornale[] {
return db.prepare(
`SELECT id, data, autore, testo, created_at FROM giornale
WHERE client_code = ? ORDER BY data DESC, id DESC`
).all(clientCode) as VoceGiornale[];
}
export type EsitoAggiunta = { ok: true; id: number } | { ok: false; errore: string };
/**
* Aggiunge una voce al giornale. Rifiuta PRIMA di scrivere: testo vuoto dopo trim, testo
* troppo lungo, data malformata o nel futuro, cliente senza soggetto in longevity — stesso
* schema di validazione di salvaCheckup (gestionale.ts).
*/
export function aggiungiVoce(
db: Database.Database,
input: { clientCode: string; data: string; autore: string; testo: string }
): EsitoAggiunta {
const testo = input.testo.trim();
if (testo === '') {
return { ok: false, errore: 'Il testo della nota non può essere vuoto.' };
}
if (testo.length > TESTO_MAX) {
return { ok: false, errore: `Il testo della nota non può superare ${TESTO_MAX} caratteri.` };
}
if (!REGEX_DATA.test(input.data)) {
return { ok: false, errore: 'Data non valida (formato AAAA-MM-GG).' };
}
const oggi = new Date().toISOString().slice(0, 10);
if (input.data > oggi) {
return { ok: false, errore: 'La data della nota non può essere nel futuro.' };
}
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
if (!soggetto) {
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
}
const r = db.prepare(
`INSERT INTO giornale (client_code, data, autore, testo) VALUES (?, ?, ?, ?)`
).run(input.clientCode, input.data, input.autore, testo);
return { ok: true, id: Number(r.lastInsertRowid) };
}
export type EsitoCancellazione = { ok: true; clientCode: string } | { ok: false; errore: string };
/**
* Cancella una voce: solo chi l'ha scritta o un admin può farlo (decisione di Adriano,
* 27/09/2026) — un altro trainer non cancella la nota di un collega. Restituisce il
* client_code della voce cancellata, letto dal database, per il redirect alla scheda giusta
* — stesso principio di verificaMisura (misure.ts): l'id non porta con sé il fascicolo, lo
* si legge da dove è scritto.
*/
export function cancellaVoce(
db: Database.Database,
input: { id: number; utente: string; ruolo: string }
): EsitoCancellazione {
const voce = db.prepare(
`SELECT client_code, autore FROM giornale WHERE id = ?`
).get(input.id) as { client_code: string; autore: string } | undefined;
if (!voce) {
return { ok: false, errore: `Voce inesistente: ${input.id}` };
}
if (voce.autore !== input.utente && input.ruolo !== 'admin') {
return { ok: false, errore: 'Solo l\'autore della nota o un admin possono cancellarla.' };
}
db.prepare(`DELETE FROM giornale WHERE id = ?`).run(input.id);
return { ok: true, clientCode: voce.client_code };
}
+119 -11
View File
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { esisteTest } from './registro'; import { esisteTest } from './registro';
import { calcolaSessione, salvaScore } from './motore';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
@@ -24,6 +25,19 @@ export function apriSessione(
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
} }
/**
* True se un valore numerico esce dal range di plausibilità del test (registro_test).
* Unico punto di calcolo: usato da `registraMisure` all'inserimento e da `correggiCheckup`
* (gestionale.ts, #12) alla correzione, così il confronto min/max non vive in due posti.
*/
export function fuoriRangeDi(db: Database.Database, testId: string, valoreNum: number | undefined): boolean {
if (valoreNum === undefined) return false;
const r = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`).get(testId) as
{ range_min: number | null; range_max: number | null };
return (r.range_min !== null && valoreNum < r.range_min) ||
(r.range_max !== null && valoreNum > r.range_max);
}
/** /**
* L'unico punto in cui si scrive nella tabella `misure`. * L'unico punto in cui si scrive nella tabella `misure`.
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una * Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
@@ -46,7 +60,6 @@ export function registraMisure(
} }
} }
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
const ins = db.prepare( const ins = db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range) `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
@@ -55,20 +68,115 @@ export function registraMisure(
const tx = db.transaction(() => { const tx = db.transaction(() => {
for (const m of misure) { for (const m of misure) {
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null }; const fuori = fuoriRangeDi(db, m.test_id, m.valore_num);
let fuori = 0; if (fuori) fuoriRange.push(m.test_id);
if (m.valore_num !== undefined) {
if ((r.range_min !== null && m.valore_num < r.range_min) ||
(r.range_max !== null && m.valore_num > r.range_max)) {
fuori = 1;
fuoriRange.push(m.test_id);
}
}
ins.run(sessioneId, sess.client_code, m.test_id, ins.run(sessioneId, sess.client_code, m.test_id,
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori); m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori ? 1 : 0);
} }
}); });
tx(); tx();
return { scritte: misure.length, fuoriRange }; 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 };
}
@@ -0,0 +1,277 @@
/**
* 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.
*
* REGOLA DEI GRUPPI (issue #13, manuale di Nicola del 27/09/2026): alcune sotto-metriche
* hanno più test alternativi (es. handgrip = media dx/sx oggi, o il vecchio f_handgrip_kg
* storico). Per ognuna si considerano solo le alternative COMPLETE (tutti gli ingressi
* presenti, età/peso/altezza comprese quando la funzione le richiede — un'alternativa
* "solo registrata", senza tabella nel manuale, non entra mai nel gruppo), e fra quelle si
* sceglie la cui misura più recente vince (`recenza`, da `recenzaValideAlla` in
* `motore/index.ts`: 0 = più recente). A parità — o quando `recenza` non è passata, cioè il
* comportamento di oggi — vince l'ordine in cui le alternative sono elencate nel gruppo.
* Vedi `sceglieAlternativa` più sotto.
*
* FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore
* GLI che non esiste ancora), 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. (Milfit, 2km walk e sit-to-stand erano qui
* fino al #13: dal manuale hanno una tabella di riferimento e sono entrati nella regola dei
* gruppi qui sotto.)
*/
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, TIERS_BENCH_M, TIERS_BENCH_F, scoreSitToStand, scorePlank,
scoreGrassoPercento, scoreMuscoloPercento, scoreWhr, scoreVo2max, vo2maxDaMutt,
vo2maxDaMilfit, vo2maxDa2kmWalk, vo2maxDaStepTest, scoreSpo2, scorePressione,
scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla,
} from './test-fisici';
export type { Sesso };
/** Un'alternativa possibile per una sotto-metrica di gruppo: `valore` è `undefined` se
* mancava anche un solo ingresso (alternativa incompleta, non entra nel gruppo);
* `idRecenza` sono i test_id "propri" di questa alternativa — non le variabili di contesto
* condivise come peso/età/altezza — usati per confrontarne la freschezza. */
type Alternativa = { valore: number | undefined; idRecenza: string[] };
/**
* Sceglie, fra le alternative COMPLETE di un gruppo, quella con la misura più recente:
* la posizione di un'alternativa è il MASSIMO (il più vecchio) fra le posizioni di
* `recenza` dei suoi `idRecenza` — un'alternativa è fresca quanto il suo ingresso più
* vecchio, non quanto il più nuovo. Senza `recenza` (firma opzionale di
* `sottoDominiFisici`, o nessuno dei suoi id in mappa) vale solo l'ordine di elencazione
* (indice più basso vince): è il comportamento di prima del #13, invariato per i
* chiamanti che non passano `recenza`.
*/
function sceglieAlternativa(alternative: Alternativa[], recenza?: Map<string, number>): number | undefined {
let scelto: { valore: number; posizione: number; indice: number } | undefined;
alternative.forEach((alt, indice) => {
if (alt.valore === undefined) return; // alternativa incompleta: esclusa dal gruppo
let posizione = indice;
if (recenza) {
let massimo = -1;
for (const id of alt.idRecenza) {
const p = recenza.get(id);
if (p !== undefined && p > massimo) massimo = p;
}
if (massimo !== -1) posizione = massimo;
}
if (!scelto || posizione < scelto.posizione || (posizione === scelto.posizione && indice < scelto.indice)) {
scelto = { valore: alt.valore, posizione, indice };
}
});
return scelto?.valore;
}
/** Media di due misure, solo se ENTRAMBE sono presenti — un solo lato non produce mai un
* punteggio (regola dei gruppi, issue #13). */
function mediaCoppia(a: number | undefined, b: number | undefined): number | undefined {
return a !== undefined && b !== undefined ? (a + b) / 2 : undefined;
}
/**
* `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).
* `recenza` (opzionale, `recenzaValideAlla`) alimenta la regola dei gruppi: senza, vale solo
* l'ordine di elencazione di ogni gruppo, cioè il comportamento di prima del #13.
*
* 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<string, number>, eta: number | null, sesso: Sesso, recenza?: Map<string, number>
): Record<string, number> {
const out: Record<string, number> = {};
const pesoKg = m.get('f_peso_kg');
const altezzaCm = m.get('f_altezza_cm');
// Nessun BMI di ripiego (DECISIONI d nell'issue): senza peso E altezza, resta `undefined`
// e tutto ciò che lo richiede (sit-to-stand, 2km walk) non dà punteggio.
const bmi = (pesoKg !== undefined && altezzaCm !== undefined) ? pesoKg / (altezzaCm / 100) ** 2 : undefined;
// --- Forza & Struttura ----------------------------------------------------
// handgrip: media dx/sx (manuale) se entrambi presenti, altrimenti lo storico f_handgrip_kg.
const handgripMedia = mediaCoppia(m.get('f_handgrip_dx_kg'), m.get('f_handgrip_sx_kg'));
const handgripStorico = m.get('f_handgrip_kg');
if (eta !== null) {
const handgrip = sceglieAlternativa([
{ valore: handgripMedia !== undefined ? scoreHandgrip(handgripMedia, eta, sesso) : undefined,
idRecenza: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
{ valore: handgripStorico !== undefined ? scoreHandgrip(handgripStorico, eta, sesso) : undefined,
idRecenza: ['f_handgrip_kg'] },
], recenza);
if (handgrip !== undefined) out.handgrip = handgrip;
}
// spinta: panca con bilanciere (carico/rip/peso, tabella TIERS_BENCH) oppure push-up.
const pancaBilCarico = m.get('f_panca_bil_carico_kg');
const pancaBilRip = m.get('f_panca_bil_rip');
const pancaValore = (pancaBilCarico !== undefined && pesoKg !== undefined && pancaBilRip !== undefined)
? scoreSollevamentoSuPeso(pancaBilCarico, pesoKg, pancaBilRip, TIERS_BENCH_M, TIERS_BENCH_F, sesso)
: undefined;
const pushup = m.get('f_pushup_rip');
const pushupValore = (pushup !== undefined && eta !== null) ? scorePushup(pushup, eta, sesso) : undefined;
const spinta = sceglieAlternativa([
{ valore: pancaValore, idRecenza: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
{ valore: pushupValore, idRecenza: ['f_pushup_rip'] },
], recenza);
if (spinta !== undefined) out.spinta = spinta;
// trazione: invariata (solo f_row). f_trazioni_rip si registra ma non dà punteggio
// (nessuna tabella di riferimento nel manuale, DECISIONI a).
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);
}
// arti_inferiori: squat (invariato) oppure sit-to-stand (rip, età, sesso, bmi — SENZA
// ripiego: bmi assente = alternativa incompleta).
const squatCarico = m.get('f_squat_carico_kg');
const squatRip = m.get('f_squat_rip');
const squatValore = (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined)
? scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso)
: undefined;
const sitToStandRip = m.get('f_sit_to_stand_rip');
const sitToStandValore = (sitToStandRip !== undefined && eta !== null && bmi !== undefined)
? scoreSitToStand(sitToStandRip, eta, sesso, bmi)
: undefined;
const artiInferiori = sceglieAlternativa([
{ valore: squatValore, idRecenza: ['f_squat_carico_kg', 'f_squat_rip'] },
{ valore: sitToStandValore, idRecenza: ['f_sit_to_stand_rip'] },
], recenza);
if (artiInferiori !== undefined) out.arti_inferiori = artiInferiori;
// 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.
// f_plank_ginocchia_sec (ginocchia) si registra ma non dà punteggio (DECISIONI a).
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, MUTT, MILFIT, 2km walk (UKK) o step test — in quest'ordine di
// priorità, la regola dei gruppi sceglie fra le alternative complete quella più recente.
const vo2Diretto = m.get('f_vo2max');
const muttVam = m.get('f_mutt_vam');
const milfitWatt = m.get('f_milfit_watt');
const walk2kmMin = m.get('f_walk2km_min');
const walk2kmFc = m.get('f_walk2km_fc_bpm');
const stepHr = m.get('f_step_hr_recovery');
const vo2MuttValore = muttVam !== undefined ? vo2maxDaMutt(muttVam) : undefined;
const vo2MilfitValore = (milfitWatt !== undefined && pesoKg !== undefined)
? vo2maxDaMilfit(milfitWatt, pesoKg) : undefined;
const vo2WalkValore = (walk2kmMin !== undefined && walk2kmFc !== undefined && eta !== null && bmi !== undefined)
? vo2maxDa2kmWalk(walk2kmMin, walk2kmFc, eta, bmi) : undefined;
const vo2StepValore = stepHr !== undefined ? vo2maxDaStepTest(stepHr, sesso) : undefined;
const vo2max = sceglieAlternativa([
{ valore: vo2Diretto, idRecenza: ['f_vo2max'] },
{ valore: vo2MuttValore, idRecenza: ['f_mutt_vam'] },
{ valore: vo2MilfitValore, idRecenza: ['f_milfit_watt'] },
{ valore: vo2WalkValore, idRecenza: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
{ valore: vo2StepValore, idRecenza: ['f_step_hr_recovery'] },
], recenza);
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);
// hrr: MUTT (FC max - FC dopo 1', manuale) oppure lo storico f_hrr_1min_bpm (già un calo).
const muttFcMax = m.get('f_mutt_fc_max_bpm');
const muttFc1min = m.get('f_mutt_fc_1min_bpm');
const hrrMuttValore = (muttFcMax !== undefined && muttFc1min !== undefined)
? scoreRecuperoCardiaco(muttFcMax - muttFc1min) : undefined;
const hrrStorico = m.get('f_hrr_1min_bpm');
const hrrStoricoValore = hrrStorico !== undefined ? scoreRecuperoCardiaco(hrrStorico) : undefined;
const hrr = sceglieAlternativa([
{ valore: hrrMuttValore, idRecenza: ['f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm'] },
{ valore: hrrStoricoValore, idRecenza: ['f_hrr_1min_bpm'] },
], recenza);
if (hrr !== undefined) out.hrr = hrr;
// --- Stabilità & Mobilità Funzionale ------------------------------------------
// flamingo: media dx/sx a occhi APERTI (la tabella Eurofit è a occhi aperti, DECISIONI b;
// occhi chiusi si registra ma non dà punteggio) oppure lo storico f_flamingo_cadute.
const flamingoOaMedia = mediaCoppia(m.get('f_flamingo_oa_dx'), m.get('f_flamingo_oa_sx'));
const flamingoOaValore = flamingoOaMedia !== undefined ? scoreFlamingo(flamingoOaMedia) : undefined;
const flamingoCaduteStorico = m.get('f_flamingo_cadute');
const flamingoStoricoValore = flamingoCaduteStorico !== undefined ? scoreFlamingo(flamingoCaduteStorico) : undefined;
const flamingo = sceglieAlternativa([
{ valore: flamingoOaValore, idRecenza: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
{ valore: flamingoStoricoValore, idRecenza: ['f_flamingo_cadute'] },
], recenza);
if (flamingo !== undefined) out.flamingo = flamingo;
const sitReach = m.get('f_sit_reach_cm');
if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso);
// back_scratch: media dx/sx (manuale) oppure lo storico f_back_scratch_cm.
const backMedia = mediaCoppia(m.get('f_back_scratch_dx_cm'), m.get('f_back_scratch_sx_cm'));
const backStorico = m.get('f_back_scratch_cm');
if (eta !== null) {
const backScratch = sceglieAlternativa([
{ valore: backMedia !== undefined ? scoreBackScratch(backMedia, eta, sesso) : undefined,
idRecenza: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm'] },
{ valore: backStorico !== undefined ? scoreBackScratch(backStorico, eta, sesso) : undefined,
idRecenza: ['f_back_scratch_cm'] },
], recenza);
if (backScratch !== undefined) out.back_scratch = backScratch;
}
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;
}
+192 -42
View File
@@ -6,15 +6,25 @@
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel * Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì * registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un * (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale) non contribuisce al * test senza curva dichiarata (es. un campo testuale, o una misura fisica —
* calcolo. * vedi sotto) non contribuisce a QUESTO livello.
* *
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione * Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test * `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o * curva generica (età, sesso, a volte un'altra misura come il peso
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`, * corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette * da curva (questionario) e sotto-domini fisici — in un'unica mappa.
* assi, i tre macro-score e la Fitness Age. *
* 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 * `salvaScore` congela il risultato nella tabella `score`, una riga per
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
@@ -30,8 +40,8 @@
* applicarla, e restituisce `null` se il registro (dato modificabile senza * applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude * `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma * `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer
* (punto aperto in §12). * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`).
*/ */
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
@@ -41,6 +51,7 @@ import {
} from './curve'; } from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata'; import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro'; 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`. */ /** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = { type ParamsCurva = {
@@ -114,6 +125,172 @@ 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 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 — 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.
*/
/** Una riga grezza di `misure`, con la sessione di provenienza: usata sia da
* `misureValideAlla` che da `recenzaValideAlla` (stessa query, stesso ORDER BY). */
type RigaMisuraGrezza = { test_id: string; valore_num: number; data: string; sessione_id: number };
/** Fattorizza la query condivisa fra `misureValideAlla` e `recenzaValideAlla`: stessa
* sessione, stesse righe, stesso ordine. Aggiunto `s2.id AS sessione_id` (assente
* nella query originale) perche' `recenzaValideAlla` deve sapere DA QUALE sessione viene
* ogni riga, non solo la data — due sessioni nello stesso giorno sono comunque due
* posizioni di recenza diverse (lo stesso criterio di `ORDER BY s2.id DESC`). */
function righeMisureGrezze(
db: Database.Database, sessioneId: number
): { sessione: { client_code: string; data: string; id: number }; righe: RigaMisuraGrezza[] } {
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, s2.id AS sessione_id
FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id
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 RigaMisuraGrezza[];
return { sessione, righe };
}
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
const mappa = new Map<string, number>();
for (const r of righe) {
// 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;
}
mappa.set(r.test_id, r.valore_num);
}
return mappa;
}
/**
* La posizione di recenza di ogni test_id (0 = il piu' recente), sullo STESSO ordine di
* `misureValideAlla` (`righeMisureGrezze`: data di sessione DESC, poi id di sessione DESC).
* Non e' una data: e' un rango relativo fra le SESSIONI (data, sessione_id) di questo
* cliente fino a questa sessione compresa — due test_id misurati nella stessa sessione
* hanno la STESSA posizione (sono ugualmente "recenti": la regola dei gruppi di
* `sottoDominiFisici`, issue #13, rompe quel pareggio con l'ordine di priorita' del
* gruppo, non con l'id di riga in `misure`). Serve a scegliere, fra piu' alternative
* complete per una stessa sotto-metrica (es. panca vs push-up), quella la cui misura piu'
* recente vince.
*/
export function recenzaValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
const mappa = new Map<string, number>();
let posizione = -1;
let chiaveSessionePrecedente: string | null = null;
for (const r of righe) {
if (FINESTRA_GIORNI !== null) {
const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000;
if (giorni > FINESTRA_GIORNI) continue;
}
if (mappa.has(r.test_id)) continue; // gia' assegnata la sua posizione (la piu' recente)
const chiaveSessione = `${r.data}|${r.sessione_id}`;
if (chiaveSessione !== chiaveSessionePrecedente) {
posizione++;
chiaveSessionePrecedente = chiaveSessione;
}
mappa.set(r.test_id, posizione);
}
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<string, number> {
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);
// Recenza per test_id (issue #13): serve alla regola dei gruppi di sottoDominiFisici,
// per scegliere fra alternative COMPLETE quella con la misura piu' recente.
const recenza = recenzaValideAlla(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<string, number[]>();
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<string, number> = {};
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, recenza) : {};
return { ...daCurva, ...daFisici };
}
/** /**
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. * 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é * `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
@@ -134,43 +311,16 @@ export type RisultatoSessione = {
/** /**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da * Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi 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 { export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare( const sessione = db.prepare(
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?` `SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined; ).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const attivi = testAttivi(db, sessione.data); // Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione.
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno const sottoDominio = sottoDominiDellaSessione(db, sessioneId);
// 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<string, number>(
(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<string, number[]>();
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<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso. // Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
const nomiAssi = (db.prepare( const nomiAssi = (db.prepare(
@@ -15,18 +15,15 @@
* comparabili fra protocolli), la seconda perché non è mai chiamata da * comparabili fra protocolli), la seconda perché non è mai chiamata da
* nessun peso. * nessun peso.
* *
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate * COLLEGATE AL MOTORE tramite `motore/fisici.ts` (`sottoDominiFisici`), non
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono * tramite `applicaCurva`: `applicaCurva` smista solo sulle sette curve
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo * generiche dichiarate nel vocabolario di `registro.ts` (`bell`, `lin_dec`,
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts` * `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e la sua firma prende
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e * `(voce, valore)`, senza modo di passare età e sesso che quasi tutte queste
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`, * funzioni richiedono — estenderla resta lavoro futuro, non fatto qui.
* senza modo di passare età e sesso, che quasi tutte queste funzioni * `sottoDominiFisici` aggira il problema chiamando queste funzioni
* richiedono. Agganciarle è lavoro del piano degli import, non di questa * direttamente, con gli argomenti che servono presi dalla mappa delle misure
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far * del check-up (`registro.ts`, voci `f_*`).
* 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`.
*/ */
import { clamp, lerp, arrotonda1 } from './curve'; import { clamp, lerp, arrotonda1 } from './curve';
@@ -0,0 +1,129 @@
//
// L'andamento nel tempo di un cliente: una riga per sessione, i punteggi congelati che
// quella sessione ha davvero, e dove segnare che qualcosa nel modo di calcolare è cambiato.
//
// Stessa regola non negoziabile di `refertoDi` (vista.ts): i valori vengono da `leggiScore`
// (punteggi congelati in `score`), MAI da un ricalcolo — un peso cambiato dopo il fatto non
// deve alterare un punto già emesso, tanto quanto non altera un referto già emesso. Qui in
// più c'è la STORIA di più sessioni, quindi la domanda "quale sessione entra nel grafico" ha
// una risposta precisa: solo quelle che hanno almeno una riga in `score`. Una sessione aperta
// (`apriSessione`) ma mai calcolata — capita quando il consenso non è dato, o quando qualcosa
// si è fermato a metà prima del congelamento — non ha punteggi da mostrare, e mostrarla come
// un punto vuoto sarebbe un buco silenzioso nel grafico, non un dato mancante dichiarato.
import type Database from 'better-sqlite3';
import { leggiScore } from './motore';
/**
* Un punto della progressione: una sessione con i suoi punteggi congelati.
* `questVersion` è `null` per un check-up (le sessioni `checkup` non passano dal
* questionario, quindi `sessioni.quest_version` resta NULL — vedi db.ts).
* `assi`/`macro` sono `Record<nome, number|null>`: la chiave è il nome del contenitore
* (es. 'Forza & Struttura', 'PERFORMANCE') così com'è scritto in `score.elemento`, il
* valore è `null` quando lo stato congelato era 'insufficiente' — mai un numero inventato.
* `cambioVersione` è il testo del cambio rispetto al PUNTO PRECEDENTE (non rispetto
* all'ultimo `quest_version` in assoluto, tranne per il caso descritto sotto), `null` sul
* primo punto per costruzione (non c'è un "prima").
*/
export type PuntoProgressione = {
sessioneId: number;
data: string;
tipo: 'checkup' | 'questionario';
questVersion: string | null;
modelVersion: string;
assi: Record<string, number | null>;
macro: Record<string, number | null>;
fitnessAge: number | null;
cambioVersione: string | null;
};
/**
* Le sessioni di un cliente, in ordine cronologico, che hanno almeno una riga in `score`
* — le uniche che `progressioneDi` può mostrare. Stesso criterio d'ordine di
* `misureValideAlla`/`leggiScore` (data ASC, poi id ASC: qui l'ordine è cronologico in
* avanti, l'opposto di quelle due funzioni che cercano "il più recente", ma il criterio
* di spareggio a parità di data è lo stesso — l'id di sessione, non l'ordine con cui
* SQLite restituisce le righe).
*/
function sessioniConScore(db: Database.Database, clientCode: string) {
return db.prepare(
`SELECT id AS sessioneId, data, tipo
FROM sessioni
WHERE client_code = ?
AND EXISTS (SELECT 1 FROM score WHERE score.sessione_id = sessioni.id)
ORDER BY data ASC, id ASC`
).all(clientCode) as { sessioneId: number; data: string; tipo: 'checkup' | 'questionario' }[];
}
/**
* `quest_version`/`model_version` della sessione, letti da `score` (non da `sessioni`:
* `sessioni.quest_version` è quello che il cliente ha compilato, `score.quest_version` è
* quello con cui l'ultimo calcolo congelato ha davvero girato — nel funzionamento normale
* coincidono sempre, ma è `score` la fonte che questo modulo legge per coerenza con
* `leggiScore`, non un'altra tabella). Una sessione può avere più calcoli nel tempo (vedi
* il commento di `leggiScore` su `salvaScore` senza vincolo di unicità): si prende la riga
* più recente per id, lo stesso criterio con cui `leggiScore` sceglie il valore di ciascun
* elemento.
*/
function versioniDi(db: Database.Database, sessioneId: number): { questVersion: string | null; modelVersion: string } {
const riga = db.prepare(
`SELECT quest_version, model_version FROM score WHERE sessione_id = ? ORDER BY id DESC LIMIT 1`
).get(sessioneId) as { quest_version: string | null; model_version: string } | undefined;
// Non dovrebbe mai mancare: sessioniConScore filtra con lo stesso EXISTS. Se manca è un
// bug di questa funzione (query diversa da quella del filtro), non un caso di prodotto —
// meglio un errore esplicito che un punto silenziosamente senza versione.
if (!riga) throw new Error(`sessione ${sessioneId} senza righe in score, nonostante il filtro`);
return { questVersion: riga.quest_version, modelVersion: riga.model_version };
}
/**
* L'andamento nel tempo di un cliente: un punto per sessione calcolata, in ordine
* cronologico. `cambioVersione` marca dove il modo di calcolare è cambiato:
* - il `modelVersion` è diverso da quello del punto precedente (un cambio di curve o pesi
* cambia il significato del numero, non solo il suo valore);
* - oppure il `questVersion` (quando non è `null`) è diverso dall'ultimo `questVersion`
* NON NULLO visto prima — non dal punto immediatamente precedente: un check-up in mezzo
* a due questionari ha `questVersion` null e non deve né innescare un cambio (non ha una
* versione da confrontare) né "nascondere" un cambio vero fra i due questionari che lo
* circondano (il confronto salta il check-up, non si ferma su di lui).
*/
export function progressioneDi(db: Database.Database, clientCode: string): PuntoProgressione[] {
const sessioni = sessioniConScore(db, clientCode);
const punti: PuntoProgressione[] = [];
let modelVersionPrecedente: string | null = null;
let questVersionNonNullaPrecedente: string | null = null;
for (const s of sessioni) {
const punteggi = leggiScore(db, s.sessioneId);
const { questVersion, modelVersion } = versioniDi(db, s.sessioneId);
const assi: Record<string, number | null> = {};
for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null;
const macro: Record<string, number | null> = {};
for (const [nome, p] of Object.entries(punteggi.macro)) macro[nome] = p.stato === 'ok' ? p.valore : null;
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
const cambi: string[] = [];
if (punti.length > 0) {
if (modelVersionPrecedente !== null && modelVersion !== modelVersionPrecedente) {
cambi.push(`modello ${modelVersionPrecedente} → ${modelVersion}`);
}
if (questVersion !== null && questVersionNonNullaPrecedente !== null && questVersion !== questVersionNonNullaPrecedente) {
cambi.push(`questionario ${questVersionNonNullaPrecedente} → ${questVersion}`);
}
}
punti.push({
sessioneId: s.sessioneId, data: s.data, tipo: s.tipo,
questVersion, modelVersion, assi, macro, fitnessAge,
cambioVersione: cambi.length > 0 ? cambi.join('; ') : null,
});
modelVersionPrecedente = modelVersion;
if (questVersion !== null) questVersionNonNullaPrecedente = questVersion;
}
return punti;
}
@@ -93,6 +93,144 @@ const QUESTIONARIO: VoceRegistro[] = [
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, 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 },
// Dismesso il 28/09 (#13): sostituito da f_handgrip_dx_kg/f_handgrip_sx_kg (media dx/sx,
// manuale di Nicola). Mai cancellato: le sessioni passate lo usano ancora (limite #12);
// sottoDominiFisici lo legge come alternativa storica in fisici.ts.
{ 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, attivo_a: '2026-09-28' },
// Etichetta corretta (#13): il manuale prevede push-up sulle ginocchia per le donne.
{ test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime (donne sulle ginocchia)', 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 },
// Dismesso il 28/09 (#13): sostituito da f_mutt_fc_max_bpm - f_mutt_fc_1min_bpm (manuale).
// Mai cancellato, resta come alternativa storica in fisici.ts (hrr).
{ 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, attivo_a: '2026-09-28' },
// Dismesso il 28/09 (#13): sostituito da media f_flamingo_oa_dx/f_flamingo_oa_sx (occhi
// aperti, manuale). Mai cancellato, resta come alternativa storica in fisici.ts (flamingo).
{ 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, attivo_a: '2026-09-28' },
{ 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 },
// Dismesso il 28/09 (#13): sostituito da media f_back_scratch_dx_cm/f_back_scratch_sx_cm.
// Mai cancellato, resta come alternativa storica in fisici.ts (back_scratch).
{ 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, attivo_a: '2026-09-28' },
{ 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 },
// Voci nuove del 28/09 (#13), dal manuale batteria test completo di Nicola (27/09/2026).
// Alcune alimentano sottoDominiFisici (vedi motore/fisici.ts), altre si registrano soltanto
// perche' il manuale non da' una tabella di riferimento per loro (DECISIONI a nell'issue):
// panca manubri, trazioni, plank sulle ginocchia, 6MWT, flamingo occhi chiusi, FC per step
// MUTT, FC a riposo, FC MUTT a 2'.
{ test_id: 'f_altezza_cm', etichetta: 'Altezza', unita: 'cm', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 100, range_max: 230, attivo_da: DA },
{ test_id: 'f_fc_riposo_bpm', etichetta: 'Frequenza cardiaca a riposo', unita: 'bpm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 30, range_max: 130, attivo_da: DA },
{ test_id: 'f_back_scratch_dx_cm', etichetta: 'Back scratch (mobilità spalle), lato destro', unita: 'cm',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
{ test_id: 'f_back_scratch_sx_cm', etichetta: 'Back scratch (mobilità spalle), lato sinistro', unita: 'cm',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
{ test_id: 'f_flamingo_oa_dx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato destro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oa_sx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato sinistro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oc_dx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato destro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oc_sx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato sinistro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_handgrip_dx_kg', etichetta: 'Forza di presa (handgrip), lato destro', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
{ test_id: 'f_handgrip_sx_kg', etichetta: 'Forza di presa (handgrip), lato sinistro', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
{ test_id: 'f_panca_man_carico_kg', etichetta: 'Panca con manubri, carico (per manubrio)', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_panca_man_rip', etichetta: 'Panca con manubri, ripetizioni', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
{ test_id: 'f_panca_bil_carico_kg', etichetta: 'Panca con bilanciere, carico', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA },
{ test_id: 'f_panca_bil_rip', etichetta: 'Panca con bilanciere, ripetizioni', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
{ test_id: 'f_trazioni_rip', etichetta: 'Trazioni alla sbarra, ripetizioni massime', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 60, attivo_da: DA },
// Etichetta corretta (#13): il test dura 1 minuto, non 30 secondi.
{ test_id: 'f_sit_to_stand_rip', etichetta: 'Sit-to-stand, ripetizioni in 1 minuto (sedia 43-45 cm)', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 80, attivo_da: DA },
{ test_id: 'f_plank_ginocchia_sec', etichetta: 'Plank sulle ginocchia, tenuta', unita: 'sec', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 600, attivo_da: DA },
{ test_id: 'f_6mwt_m', etichetta: '6 Minute Walk Test, distanza percorsa', unita: 'm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 50, range_max: 1200, attivo_da: DA },
{ test_id: 'f_walk2km_min', etichetta: 'Camminata 2 km (UKK), tempo', unita: 'min', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 8, range_max: 40, attivo_da: DA },
{ test_id: 'f_walk2km_fc_bpm', etichetta: 'Camminata 2 km (UKK), frequenza cardiaca finale', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 60, range_max: 220, attivo_da: DA },
{ test_id: 'f_milfit_watt', etichetta: 'MILFIT, potenza', unita: 'watt', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 20, range_max: 500, attivo_da: DA },
{ test_id: 'f_mutt_fc_max_bpm', etichetta: 'MUTT, frequenza cardiaca massima', unita: 'bpm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
{ test_id: 'f_mutt_fc_1min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 1\' di recupero', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
{ test_id: 'f_mutt_fc_2min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 2\' di recupero', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
// Gli otto step del MUTT, un km/h alla volta da 7,5 a 14,5: solo registrati (DECISIONI a),
// nessuna tabella di riferimento nel manuale per la FC punto per punto.
...([7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5].map((kmh): VoceRegistro => ({
test_id: `f_mutt_fc_${String(kmh).replace('.', '_')}`,
etichetta: `MUTT, frequenza cardiaca allo step ${kmh} km/h`,
unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio',
range_min: 40, range_max: 230, attivo_da: DA,
}))),
];
export function seedRegistro(db: Database.Database): void { export function seedRegistro(db: Database.Database): void {
const ins = db.prepare( const ins = db.prepare(
`INSERT OR REPLACE INTO registro_test `INSERT OR REPLACE INTO registro_test
@@ -114,6 +252,7 @@ export function seedRegistro(db: Database.Database): void {
} }
}); });
tx(QUESTIONARIO); tx(QUESTIONARIO);
tx(FISICI);
} }
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, // Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
+16 -16
View File
@@ -11,7 +11,7 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { testAttivi, pesiDi, MODEL_VERSION } from './registro'; import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario'; import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore } from './motore'; import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore';
export type CampoQuestionario = { export type CampoQuestionario = {
id: string; 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 * 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 * di `PESI_ASSE` in registro.ts. Non sono chiavi che si leggono da `registro_test.sotto_dominio`
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il * per tutte: quelle del questionario sì, ma quelle fisiche (handgrip, VO2max, plank...) le
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non * produce `sottoDominiFisici` (`motore/fisici.ts`) come chiave del suo risultato — le voci
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa * FISICI del registro non portano un `sotto_dominio` proprio (vedi il commento su `FISICI`
* dove il registro non arriva ancora. * 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<string, string> = { const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
handgrip: 'Forza di presa (handgrip)', 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); ).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: /** I sotto-domini con un punteggio calcolabile per questa sessione — le chiavi di
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali * `sottoDominiDellaSessione`, che a differenza di una query sulla sola tabella `misure`
* test sono stati fatti non è ricalcolare come pesano. */ * 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<string> { function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
const righe = db.prepare( return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId)));
`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));
} }
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse /** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
@@ -0,0 +1,173 @@
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, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
type SezioneCheckup,
} from '../../../../lib/longevity/gestionale';
import { aggiungiVoce, TESTO_MAX as NOTA_TESTO_MAX } from '../../../../lib/longevity/giornale';
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 `<input>` nascosto o un campo dismesso non
* deve rompere il salvataggio del resto.
*/
export function misureDalForm(
form: FormData,
campiAttivi: Pick<VoceRegistro, 'test_id' | 'unita'>[]
): { 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 };
}
/**
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
* `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non
* ha ancora marcato nulla, o un refuso) resta assente dalla mappa: `testIdScartatiDalleAlternative`
* non scarta NULLA per quella sezione, invece di ripiegare sulla prima alternativa e buttare
* via in silenzio campi compilati di un'altra — correzione del passo 3 su #13.
*/
function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> {
const scelte = new Map<string, string>();
for (const s of sezioni) {
if (s.tipo !== 'alternative') continue;
const v = form.get(`alt_${s.chiave}`);
if (typeof v === 'string' && v !== '') scelte.set(s.chiave, v);
}
return scelte;
}
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCodeForm = String(form.get('client_code') ?? '').trim();
const data = String(form.get('data') ?? '').trim();
// Un sessione_id nel form (issue #12) è una correzione: stesso form, stessa API, solo
// l'hidden field in più — checkup.astro lo mette quando arriva con ?sessione=<id>.
const sessioneIdRaw = String(form.get('sessione_id') ?? '').trim();
const sessioneId = sessioneIdRaw !== '' ? Number(sessioneIdRaw) : null;
// 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(clientCodeForm)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
const db = getLongevityDb();
// In correzione il client_code per validazioni e redirect è SEMPRE quello letto dalla
// sessione nel database, mai quello del form (stesso principio di verifica.ts): un
// sessione_id non è un fascicolo che si possa dirottare scrivendo un altro codice cliente.
let clientCode = clientCodeForm;
if (sessioneId !== null) {
if (!Number.isInteger(sessioneId)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione non valida.')}`);
}
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sessione) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione inesistente.')}`);
}
clientCode = sessione.client_code;
if (clientCodeForm !== clientCode) {
return redirect(
`/longevity/gestionale/cliente/${clientCode}?errore=${encodeURIComponent('Codice cliente non coerente con la sessione.')}`
);
}
}
const erroreAllaScheda = (testo: string) => {
const query = new URLSearchParams({ errore: testo });
if (sessioneId !== null) query.set('sessione', String(sessioneId));
return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?${query.toString()}`);
};
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 campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_'));
const parse = misureDalForm(form, campiAttivi);
if (!parse.ok) return erroreAllaScheda(parse.errore);
// Sezioni ad alternative (#13): si tengono solo i campi dell'alternativa scelta dalla
// radio, i compilati di altre alternative della stessa sezione si scartano — anche se il
// trainer li ha riempiti (JS disattivato, o un fieldset lasciato visibile per errore).
const { sezioni } = strutturaCheckup(db, data);
const scartati = testIdScartatiDalleAlternative(sezioni, sceltePerAlternative(sezioni, form));
const misure = parse.misure.filter((m) => !scartati.has(m.test_id));
// 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);
// Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro),
// quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge.
const nota = String(form.get('nota') ?? '').trim();
// Validata PRIMA di salvare (correzione del passo 3 su #13): se aggiungiVoce fallisse DOPO
// il check-up (nota troppo lunga), il check-up resterebbe salvato ma la nota andrebbe persa
// in silenzio — stesso limite di aggiungiVoce (giornale.ts), non un numero duplicato qui.
if (nota.length > NOTA_TESTO_MAX) {
return erroreAllaScheda(`Il testo della nota non può superare ${NOTA_TESTO_MAX} caratteri.`);
}
if (sessioneId !== null) {
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
}
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
// Voce di giornale SOLO dopo il salvataggio riuscito (#13): la lunghezza è già validata
// sopra, la data è la stessa già validata per il check-up — aggiungiVoce qui non dovrebbe
// più rifiutare nulla.
if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota });
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
// Passo 3 → 4 del flusso guidato (#15): solo un check-up NUOVO (mai una correzione, che
// non passa da questo ramo) con flusso=nuovo prosegue verso il questionario.
const flusso = String(form.get('flusso') ?? '').trim();
if (flusso === 'nuovo') params.set('flusso', 'questionario');
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
};
@@ -0,0 +1,58 @@
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,
});
// Dopo l'apertura del fascicolo comincia il flusso guidato (#15, passo 1 → 2): prima
// consulenza, poi check-up, poi questionario — non più direttamente alla scheda.
return redirect(`/longevity/gestionale/cliente/${code}/consulenza?flusso=nuovo`);
};
@@ -0,0 +1,64 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { salvaConsulenza } from '../../../../lib/longevity/consulenza';
export const prerender = false;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
const REGEX_CODE = /^ISL-\d{4}$/;
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
/**
* Salva la scheda di prima consulenza (#15). Autore SEMPRE da `locals.user`, mai dal form —
* stesso principio di giornale.ts e checkup.ts. Il flusso guidato (`flusso=nuovo`, passo 1
* → 2) prosegue verso il check-up; fuori dal flusso torna alla scheda cliente, sezione
* #consulenza dove il form e la lettura vivono.
*/
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCode = String(form.get('client_code') ?? '').trim();
const flusso = String(form.get('flusso') ?? '').trim();
if (!REGEX_CODE.test(clientCode)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
// Il ritorno in caso di errore resta sulla stessa pagina del form, conservando `flusso`:
// altrimenti un errore fuori dal flusso guidato riporterebbe dentro il flusso, o viceversa.
const query = new URLSearchParams();
if (flusso !== '') query.set('flusso', flusso);
const tornaAlModulo = (testo: string) => {
const q = new URLSearchParams(query);
q.set('errore', testo);
return redirect(`/longevity/gestionale/cliente/${clientCode}/consulenza?${q.toString()}`);
};
const db = getLongevityDb();
const autore = locals.user!.username;
const esito = salvaConsulenza(db, {
clientCode,
autore,
campi: {
livello: String(form.get('livello') ?? ''),
obiettivoPrimario: String(form.get('obiettivo_primario') ?? ''),
obiettivoSpecifico: String(form.get('obiettivo_specifico') ?? ''),
dataTarget: String(form.get('data_target') ?? ''),
noteMediche: String(form.get('note_mediche') ?? ''),
storiaSportiva: String(form.get('storia_sportiva') ?? ''),
limitazioni: String(form.get('limitazioni') ?? ''),
disponibilitaGiorni: String(form.get('disponibilita_giorni') ?? ''),
fasciaOraria: String(form.get('fascia_oraria') ?? ''),
noteScreening: String(form.get('note_screening') ?? ''),
},
});
if (!esito.ok) return tornaAlModulo(esito.errore);
if (flusso === 'nuovo') {
// Passo 2 → 3 del flusso guidato (#15): dopo la consulenza si va al check-up.
return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?flusso=nuovo`);
}
return redirect(`/longevity/gestionale/cliente/${clientCode}?consulenza=salvata#consulenza`);
};
@@ -0,0 +1,67 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { aggiungiVoce, cancellaVoce } from '../../../../lib/longevity/giornale';
export const prerender = false;
const REGEX_CODE = /^ISL-\d{4}$/;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
/**
* Aggiunge o cancella una voce del giornale di bordo (#16, solo staff): un unico endpoint
* per le due azioni, come verifica.ts fa per conferma/scarto — un campo `azione` invece di
* due rotte. Autore e ruolo SEMPRE da `locals.user`, mai dal form (stesso principio di
* checkup.ts e verifica.ts: chi scrive la richiesta non decide chi ne è l'autore).
*/
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const db = getLongevityDb();
const autore = locals.user!.username;
const ruolo = locals.user!.role;
const azione = form.get('azione');
if (azione === 'aggiungi') {
const clientCode = String(form.get('client_code') ?? '').trim();
// Senza un codice valido non c'è una scheda a cui tornare — stesso trattamento di
// checkup.ts: l'unico ritorno sensato è l'indice del gestionale.
if (!REGEX_CODE.test(clientCode)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
const data = String(form.get('data') ?? '').trim();
const testo = String(form.get('testo') ?? '');
const esito = aggiungiVoce(db, { clientCode, data, autore, testo });
if (!esito.ok) {
return tornaA(clientCode, `?errore=${encodeURIComponent(esito.errore)}`);
}
return tornaA(clientCode, '?giornale=aggiunta');
}
if (azione === 'cancella') {
const idRaw = form.get('id');
const id = typeof idRaw === 'string' ? Number(idRaw) : NaN;
if (!Number.isInteger(id)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Voce non valida.')}`);
}
const esito = cancellaVoce(db, { id, utente: autore, ruolo });
if (!esito.ok) {
// Nessuna voce da cui leggere il client_code (id inesistente, o non c'era già): non
// c'è una scheda su cui tornare, come per un id di misura inesistente in verifica.ts.
return redirect(`/longevity/gestionale?errore=${encodeURIComponent(esito.errore)}`);
}
return tornaA(esito.clientCode, '?giornale=cancellata');
}
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Azione non valida.')}`);
};
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
// Il redirect torna sempre alla sezione #giornale della scheda, dove il form e l'elenco
// vivono — così chi aggiunge o cancella una nota si ritrova esattamente dove era.
const tornaA = (clientCode: string, query: string): Response =>
redirect(`/longevity/gestionale/cliente/${clientCode}${query}#giornale`);
@@ -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}`);
};
@@ -0,0 +1,287 @@
---
// 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 } from '../../../../lib/longevity/gestionale';
import { misureDaVerificare } from '../../../../lib/longevity/misure';
import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista';
import { formattaNumero } from '../../../../lib/longevity/formato';
import { vociDelGiornale } from '../../../../lib/longevity/giornale';
import { consulenzaDi, OBIETTIVI } from '../../../../lib/longevity/consulenza';
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 daVerificare = misureDaVerificare(db, cliente.client_code);
const ultimaId = ultimaSessioneDi(db, cliente.client_code);
const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null;
const voci = vociDelGiornale(db, cliente.client_code); // #16: giornale di bordo, solo staff
const consulenza = consulenzaDi(db, cliente.client_code); // #15: prima consulenza, solo staff
const ETICHETTA_OBIETTIVO = new Map(OBIETTIVI.map((o) => [o.chiave, o.etichetta]));
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 corretto = params.get('corretto'); // #12: redirect di correggiCheckup dopo una correzione
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;
const giornaleEsito = params.get('giornale'); // #16: 'aggiunta' | 'cancellata', dal redirect dell'API
const MESSAGGIO_GIORNALE: Record<'aggiunta' | 'cancellata', string> = {
aggiunta: 'Nota aggiunta al giornale di bordo.',
cancellata: 'Nota cancellata dal giornale di bordo.',
};
const messaggioGiornale =
giornaleEsito === 'aggiunta' || giornaleEsito === 'cancellata' ? MESSAGGIO_GIORNALE[giornaleEsito] : null;
const consulenzaEsito = params.get('consulenza'); // #15: 'salvata', dal redirect dell'API consulenza
// Passo 4 del flusso guidato per un cliente nuovo (#15): dopo il check-up si torna qui con
// ?flusso=questionario — il questionario resta del cliente, quindi qui c'è solo l'indicazione.
const flusso = params.get('flusso');
---
<Longevity
title={`${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`]}
variante="gestionale"
>
<div class="lg-head lg-head--azioni">
<div>
<h1>{cliente.cognome} {cliente.nome}</h1>
<p>
{cliente.client_code} · {eta !== null ? `${eta} anni` : 'età non registrata'} ·
accesso {cliente.ha_accesso ? 'attivo' : 'non ancora attivato'}
</p>
</div>
<div>
{!cliente.ha_accesso && (
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
)}
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup`}>Nuovo check-up</a>
</div>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
{corretto && <p class="lg-card">Check-up corretto (sessione #{corretto}): punteggi ricalcolati.</p>}
{messaggioVerifica && <p class="lg-card">{messaggioVerifica}</p>}
{messaggioGiornale && <p class="lg-card">{messaggioGiornale}</p>}
{consulenzaEsito === 'salvata' && <p class="lg-card">Prima consulenza salvata.</p>}
{flusso === 'questionario' && (
<div class="lg-card">
<p class="lg-score__label">Passo 4 di 4 — Questionario</p>
{cliente.ha_accesso ? (
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro.</p>
) : (
<>
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro: crealo per continuare.</p>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
</>
)}
</div>
)}
{fuoriCount && (
<p class="lg-misura--fuori-range">
{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di
plausibilità: verificale più sotto.
</p>
)}
<div class="lg-card">
<p class="lg-score__label">Anagrafica</p>
<p>Email: {cliente.email ?? '—'}</p>
<p>Telefono: {cliente.telefono ?? '—'}</p>
<p>Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}</p>
</div>
<div class="lg-card" id="consulenza">
<div class="lg-head--azioni">
<p class="lg-score__label">Prima consulenza</p>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/consulenza`}>
{consulenza ? 'Modifica' : 'Compila'}
</a>
</div>
{!consulenza ? (
<p class="lg-muted">Non ancora compilata.</p>
) : (
<>
<p>Livello: {consulenza.livello ?? '—'}</p>
<p>Obiettivo primario: {consulenza.obiettivo_primario ? ETICHETTA_OBIETTIVO.get(consulenza.obiettivo_primario) ?? consulenza.obiettivo_primario : '—'}</p>
<p>Obiettivo specifico: {consulenza.obiettivo_specifico ?? '—'}</p>
<p>Data target: {consulenza.data_target ? formattaData(consulenza.data_target) : '—'}</p>
<p>Note mediche: {consulenza.note_mediche ?? '—'}</p>
<p>Storia sportiva: {consulenza.storia_sportiva ?? '—'}</p>
<p>Limitazioni fisiche attuali: {consulenza.limitazioni ?? '—'}</p>
<p>Disponibilità: {consulenza.disponibilita_giorni ?? '—'} giorni/settimana</p>
<p>Fascia oraria preferita: {consulenza.fascia_oraria ?? '—'}</p>
<p>Note screening: {consulenza.note_screening ?? '—'}</p>
</>
)}
</div>
<div class="lg-card" id="giornale">
<p class="lg-score__label">Giornale di bordo</p>
<p class="lg-muted">Solo lo staff lo vede. Una nota sbagliata si cancella e si riscrive: non si modifica.</p>
<form class="lg-form" method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="aggiungi" />
<input type="hidden" name="client_code" value={cliente.client_code} />
<label class="lg-field">
<span>Data</span>
<input type="date" name="data" value={oggi} max={oggi} required />
</label>
<label class="lg-field">
<span>Nota</span>
<textarea name="testo" rows="3" required></textarea>
</label>
<div class="lg-form__nav">
<button type="submit">Aggiungi nota</button>
</div>
</form>
{voci.length === 0 ? (
<p class="lg-muted">Nessuna nota ancora.</p>
) : (
<table class="lg-table">
<thead><tr><th>Data</th><th>Autore</th><th>Nota</th><th></th></tr></thead>
<tbody>
{voci.map((v) => (
<tr>
<td>{formattaData(v.data)}</td>
<td>{v.autore}</td>
<td style="white-space: pre-wrap;">{v.testo}</td>
<td>
{(v.autore === Astro.locals.user!.username || Astro.locals.user!.role === 'admin') && (
<form method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="cancella" />
<input type="hidden" name="id" value={v.id} />
<button type="submit">Cancella</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{!referto && (
<div class="lg-card lg-card--incompleto">
<p>Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
</div>
)}
<div class="lg-card">
<p class="lg-score__label">Sessioni</p>
{sessioni.length === 0 ? (
<p class="lg-muted">Nessuna sessione.</p>
) : (
<table class="lg-table">
<thead>
<tr><th>Data</th><th>Tipo</th><th>Operatore</th><th>Misure</th><th>Fitness Age</th></tr>
</thead>
<tbody>
{sessioni.map((s) => (
<tr>
<td>
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/sessione/${s.id}`}>{formattaData(s.data)}</a>
</td>
<td>{ETICHETTA_TIPO[s.tipo]}</td>
<td>{s.operatore ?? '—'}</td>
<td>{s.numeroMisure}</td>
<td>{s.fitnessAge !== null ? formattaNumero(s.fitnessAge) : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{daVerificare.length > 0 && (
<div class="lg-card">
<p class="lg-score__label">Misure da verificare</p>
<p class="lg-muted">
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.
</p>
<table class="lg-table">
<thead><tr><th>Data</th><th>Test</th><th>Valore</th><th>Range atteso</th><th>Decisione</th></tr></thead>
<tbody>
{daVerificare.map((m) => (
<tr>
<td>{formattaData(m.data)}</td>
<td>{m.etichetta}</td>
<td class="lg-misura--fuori-range">
{m.valore_num !== null ? formattaNumero(m.valore_num) : '—'}{m.unita ? ` ${m.unita}` : ''}
</td>
<td>{m.range_min ?? '—'}–{m.range_max ?? '—'}</td>
<td>
<form method="post" action="/api/longevity/gestionale/verifica">
<input type="hidden" name="misura_id" value={m.id} />
<button type="submit" name="esito" value="confermata">Conferma: il dato è vero</button>
</form>
<form method="post" action="/api/longevity/gestionale/verifica">
<input type="hidden" name="misura_id" value={m.id} />
<button type="submit" name="esito" value="scartata">Scarta: è un errore</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Longevity>
@@ -0,0 +1,86 @@
---
// 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;
}
---
<Longevity title="Crea accesso" crumbs={['Gestionale', code, 'Accesso']} variante="gestionale">
{!fascicolo && (
<div class="lg-card">
<p>Nessun fascicolo con codice <strong>{code}</strong>.</p>
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></p>
</div>
)}
{fascicolo && fascicolo.userId !== null && (
<div class="lg-card">
<p>Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.</p>
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></p>
</div>
)}
{fascicolo && fascicolo.userId === null && creato && (
<div class="lg-card">
<p><strong>Accesso creato.</strong> Comunicala al cliente: non verrà mostrata di nuovo.</p>
<p>Utente: <strong>{creato.username}</strong></p>
<p>Password: <strong>{creato.password}</strong></p>
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></p>
</div>
)}
{fascicolo && fascicolo.userId === null && !creato && (
<form method="post" class="lg-form lg-form--accesso">
{errore && <p class="lg-form__errore">{errore}</p>}
<div class="lg-field">
<span>Nome utente</span>
<input
type="text"
name="username"
required
minlength="3"
maxlength="64"
pattern="[a-z0-9._@-]+"
value={fascicolo.email ?? ''}
/>
</div>
<div class="lg-form__nav">
<a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a>
<button type="submit">Crea accesso</button>
</div>
</form>
)}
<style>
/* .lg-field in longevity.css stila solo input[type="number"] e textarea (li usa il
questionario): qui serve un campo di testo, quindi lo stile arriva da qui. */
.lg-form--accesso .lg-field input[type="text"] {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
}
</style>
</Longevity>
@@ -0,0 +1,163 @@
---
// 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.
//
// Con ?sessione=<id> (#12) lo stesso form diventa una correzione: data e campi
// precompilati dalla sessione, hidden sessione_id in più, campi attivi alla data DELLA
// SESSIONE (non a oggi) — un test dismesso da allora resterebbe comunque compilabile.
import Longevity from '../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import {
misureDellaSessione, strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup,
} 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 db = getLongevityDb();
const oggi = new Date().toISOString().slice(0, 10);
const sessioneParam = Astro.url.searchParams.get('sessione');
const sessioneId = sessioneParam !== null ? Number(sessioneParam) : null;
const sessione = sessioneId !== null && Number.isInteger(sessioneId) ? misureDellaSessione(db, sessioneId) : null;
// Una sessione di un altro cliente o non di tipo checkup non è un modulo modificabile da
// questa scheda: stesso trattamento di un codice cliente inesistente.
if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client_code || sessione.tipo !== 'checkup')) {
return new Response('Sessione non trovata', { status: 404 });
}
const dataForm = sessione?.data ?? oggi;
const modulo = strutturaCheckup(db, dataForm);
const valori = new Map(sessione?.misure.map((m) => [m.test_id, m.valore_num]) ?? []);
// Preselezione (#13): in correzione le misure della sessione, in un check-up nuovo l'ultimo
// check-up del cliente — altrimenti la prima alternativa (il livello neofita).
const valoriPreselezione = sessione
? new Map([...valori.entries()].filter((kv): kv is [string, number] => kv[1] !== null))
: valoriUltimoCheckup(db, cliente.client_code);
const errore = Astro.url.searchParams.get('errore');
// Passo 3 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): solo per un
// check-up NUOVO — in correzione (?sessione=<id>) non si è mai dentro il flusso guidato.
const flusso = !sessione ? Astro.url.searchParams.get('flusso') ?? '' : '';
---
<Longevity
title={`${sessione ? 'Modifica' : 'Nuovo'} check-up — ${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, sessione ? 'Modifica check-up' : 'Nuovo check-up']}
variante="gestionale"
>
<div class="lg-head">
<h1>{sessione ? 'Modifica check-up' : 'Nuovo check-up'}</h1>
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato.</p>
{flusso === 'nuovo' && <p class="lg-muted">Passo 3 di 4 — Scheda Rapida</p>}
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
<form class="lg-form" method="post" action="/api/longevity/gestionale/checkup">
<input type="hidden" name="client_code" value={cliente.client_code} />
{sessione && <input type="hidden" name="sessione_id" value={sessione.id} />}
{flusso === 'nuovo' && <input type="hidden" name="flusso" value="nuovo" />}
<label class="lg-field">
<span>Data</span>
<input type="date" name="data" value={dataForm} required />
</label>
{modulo.sezioni.map((s) => (
<section class="lg-form__blocco">
<h2>{s.titolo}</h2>
{s.tipo === 'campi' ? s.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
)) : (
<div class="lg-form__alternative" data-alt-sezione={s.chiave}>
{s.alternative.map((alt) => (
<fieldset class="lg-form__alternativa" data-alt-fieldset={alt.chiave}>
<legend>
<label>
<input
type="radio" name={`alt_${s.chiave}`} value={alt.chiave}
checked={alt.chiave === alternativaSelezionata(s, valoriPreselezione)}
/>
{alt.nome}
</label>
</legend>
{alt.soglia && <p class="lg-form__soglia">{alt.soglia}</p>}
{alt.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
{alt.dettagli && alt.dettagli.length > 0 && (
<details>
<summary>Frequenza cardiaca per step (7,5-14,5 km/h)</summary>
{alt.dettagli.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</details>
)}
</fieldset>
))}
</div>
)}
</section>
))}
{modulo.altreMisure.length > 0 && (
<section class="lg-form__blocco">
<h2>Altre misure</h2>
{modulo.altreMisure.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</section>
)}
{!sessione && (
<section class="lg-form__blocco">
<h2>Note operatore</h2>
<label class="lg-field">
<span>Nota per il giornale di bordo (facoltativa)</span>
<textarea name="nota" rows="4" maxlength="5000"></textarea>
</label>
</section>
)}
<div class="lg-form__nav">
{flusso === 'nuovo' ? (
<a href={`/longevity/gestionale/cliente/${cliente.client_code}?flusso=questionario`}>Salta</a>
) : (
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
)}
<button type="submit">{sessione ? 'Salva correzione' : 'Salva check-up'}</button>
</div>
</form>
</Longevity>
<script>
// Nasconde i fieldset delle alternative non scelte (#13): senza JS restano tutti visibili
// e il form funziona lo stesso — lo scarto vero è server-side (checkup.ts), questo è solo
// per non far compilare al trainer due protocolli della stessa sotto-metrica per sbaglio.
document.querySelectorAll<HTMLElement>('[data-alt-sezione]').forEach((sezioneEl) => {
const aggiorna = () => {
const scelta = sezioneEl.querySelector<HTMLInputElement>('input[type=radio]:checked')?.value;
sezioneEl.querySelectorAll<HTMLElement>('[data-alt-fieldset]').forEach((fs) => {
fs.hidden = fs.dataset.altFieldset !== scelta;
});
};
sezioneEl.addEventListener('change', aggiorna);
aggiorna();
});
</script>
@@ -0,0 +1,113 @@
---
// La scheda di prima consulenza (#15): livello, obiettivo, note dello screening iniziale.
// Precompilata se esiste già — correggere e salvare SOVRASCRIVE, nessuno storico (decisione
// di Adriano, 27/09/2026). L'invio va all'API del gestionale, che fa la validazione vera
// (pages/api/longevity/gestionale/consulenza.ts) — qui c'è solo il form.
//
// Passo 2 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): il parametro passa
// di pagina in pagina fino al questionario, e ogni passo ha un link «Salta».
import Longevity from '../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import { consulenzaDi, OBIETTIVI } from '../../../../../lib/longevity/consulenza';
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 scheda = consulenzaDi(db, cliente.client_code);
const errore = Astro.url.searchParams.get('errore');
const flusso = Astro.url.searchParams.get('flusso') ?? '';
const query = flusso ? `?flusso=${encodeURIComponent(flusso)}` : '';
---
<Longevity
title={`Prima consulenza — ${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Prima consulenza']}
variante="gestionale"
>
<div class="lg-head">
<h1>Prima consulenza</h1>
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Tutti i campi sono facoltativi.</p>
{flusso === 'nuovo' && <p class="lg-muted">Passo 2 di 4 — Prima consulenza</p>}
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
<form class="lg-form" method="post" action="/api/longevity/gestionale/consulenza">
<input type="hidden" name="client_code" value={cliente.client_code} />
<input type="hidden" name="flusso" value={flusso} />
<section class="lg-form__blocco">
<fieldset class="lg-field lg-field--consenso">
<legend>Livello</legend>
{[1, 2, 3].map((n) => (
<label>
<input type="radio" name="livello" value={n} checked={scheda?.livello === n} /> {n}
</label>
))}
</fieldset>
<fieldset class="lg-field lg-field--consenso">
<legend>Obiettivo primario</legend>
{OBIETTIVI.map((o) => (
<label>
<input
type="radio" name="obiettivo_primario" value={o.chiave}
checked={scheda?.obiettivo_primario === o.chiave}
/> {o.etichetta}
</label>
))}
</fieldset>
<label class="lg-field">
<span>Obiettivo specifico concordato</span>
<textarea name="obiettivo_specifico" rows="3">{scheda?.obiettivo_specifico ?? ''}</textarea>
</label>
<label class="lg-field">
<span>Data target</span>
<input type="date" name="data_target" value={scheda?.data_target ?? ''} />
</label>
</section>
<section class="lg-form__blocco">
<h2>Screening</h2>
<label class="lg-field">
<span>Note mediche, allergie, infortuni pregressi</span>
<textarea name="note_mediche" rows="3">{scheda?.note_mediche ?? ''}</textarea>
</label>
<label class="lg-field">
<span>Storia sportiva</span>
<textarea name="storia_sportiva" rows="3">{scheda?.storia_sportiva ?? ''}</textarea>
</label>
<label class="lg-field">
<span>Limitazioni fisiche attuali</span>
<textarea name="limitazioni" rows="3">{scheda?.limitazioni ?? ''}</textarea>
</label>
<label class="lg-field">
<span>Disponibilità (giorni/settimana)</span>
<input type="text" inputmode="numeric" name="disponibilita_giorni" value={scheda?.disponibilita_giorni ?? ''} />
</label>
<label class="lg-field">
<span>Fascia oraria preferita</span>
<input type="text" name="fascia_oraria" value={scheda?.fascia_oraria ?? ''} />
</label>
<label class="lg-field">
<span>Note screening</span>
<textarea name="note_screening" rows="3">{scheda?.note_screening ?? ''}</textarea>
</label>
</section>
<div class="lg-form__nav">
{flusso === 'nuovo' ? (
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup${query}`}>Salta</a>
) : (
<a href={`/longevity/gestionale/cliente/${cliente.client_code}#consulenza`}>Annulla</a>
)}
<button type="submit">Salva</button>
</div>
</form>
</Longevity>
@@ -0,0 +1,96 @@
---
// Il dettaglio di una sessione (#12): data, tipo, operatore, misure con valore/unità e
// stato di fuori-range/verifica. Da qui si apre la correzione di un check-up
// (checkup.astro?sessione=<id>) — un questionario resta di sola lettura, la sua correzione
// non è questa issue. Un id che non esiste, o di un altro cliente, è un 404: non c'è niente
// da mostrare per una sessione che non è la sua.
import Longevity from '../../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../../lib/longevity/anagrafica';
import { misureDellaSessione, campiDelCheckup, type MisuraDiSessione } from '../../../../../../lib/longevity/gestionale';
import { formattaNumero } from '../../../../../../lib/longevity/formato';
export const prerender = false;
const { code, id } = Astro.params;
const cliente = code ? clienteDaCodice(code) : null;
if (!cliente) return new Response('Cliente non trovato', { status: 404 });
const sessioneId = id ? Number(id) : NaN;
const db = getLongevityDb();
const sessione = Number.isInteger(sessioneId) ? misureDellaSessione(db, sessioneId) : null;
if (!sessione || sessione.client_code !== cliente.client_code) {
return new Response('Sessione non trovata', { status: 404 });
}
const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = {
checkup: 'Check-up', questionario: 'Questionario',
};
const ETICHETTA_VERIFICA: Record<'confermata' | 'scartata', string> = {
confermata: 'Confermata', scartata: 'Scartata',
};
const formattaData = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
// Un check-up si raggruppa per asse come il modulo che lo ha compilato (campiDelCheckup);
// un questionario non ha un raggruppamento del genere nel codice — resta un'unica sezione,
// già ordinata per asse da misureDellaSessione.
type Sezione = { asse: string | null; misure: MisuraDiSessione[] };
let sezioni: Sezione[];
if (sessione.tipo === 'checkup') {
const perTestId = new Map(sessione.misure.map((m) => [m.test_id, m]));
sezioni = campiDelCheckup(db, sessione.data)
.map((g) => ({
asse: g.asse,
misure: g.campi.map((c) => perTestId.get(c.id)).filter((m): m is MisuraDiSessione => m !== undefined),
}))
.filter((s) => s.misure.length > 0);
} else {
sezioni = [{ asse: null, misure: sessione.misure }];
}
---
<Longevity
title={`Sessione del ${formattaData(sessione.data)} — ${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, `Sessione del ${formattaData(sessione.data)}`]}
variante="gestionale"
>
<div class="lg-head lg-head--azioni">
<div>
<h1>{ETICHETTA_TIPO[sessione.tipo]} del {formattaData(sessione.data)}</h1>
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code} · operatore {sessione.operatore ?? '—'}</p>
</div>
<div>
{sessione.tipo === 'checkup' && (
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup?sessione=${sessione.id}`}>Modifica</a>
)}
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Torna alla scheda</a>
</div>
</div>
{sezioni.map((s) => (
<div class="lg-card">
{s.asse && <p class="lg-score__label">{s.asse}</p>}
<table class="lg-table">
<thead><tr><th>Test</th><th>Valore</th><th>Stato</th></tr></thead>
<tbody>
{s.misure.map((m) => (
<tr>
<td>{m.etichetta}</td>
<td class:list={{ 'lg-misura--fuori-range': m.fuori_range === 1 }}>
{m.valore_num !== null ? formattaNumero(m.valore_num) : (m.valore_txt ?? '—')}
{m.unita ? ` ${m.unita}` : ''}
</td>
<td>
{m.fuori_range === 0
? '—'
: m.verifica
? ETICHETTA_VERIFICA[m.verifica]
: 'Da verificare'}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</Longevity>
@@ -0,0 +1,71 @@
---
// L'elenco dei clienti del gestionale: la prima pagina che il trainer apre. Ogni riga
// porta anche data e tipo dell'ultima sessione — utile per capire a colpo d'occhio chi
// non si presenta da tempo, senza aprire ogni scheda.
import Longevity from '../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../lib/longevity/db';
import { elencoClienti, etaDelCliente } from '../../../lib/longevity/anagrafica';
import { sessioniDi } from '../../../lib/longevity/gestionale';
export const prerender = false;
const db = getLongevityDb();
const oggi = new Date().toISOString().slice(0, 10);
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' });
// Una query in più per ogni cliente (sessioniDi, tenendo solo [0]): va bene finché i
// fascicoli sono pochi — non è pensata per scalare a centinaia di clienti.
const righe = elencoClienti().map((c) => ({
...c,
eta: etaDelCliente(c.client_code, oggi),
ultima: sessioniDi(db, c.client_code)[0] ?? null,
}));
---
<Longevity title="Clienti" crumbs={['Clienti']} variante="gestionale">
<div class="lg-head lg-head--azioni">
<div>
<h1>Clienti</h1>
<p>{righe.length} {righe.length === 1 ? 'fascicolo' : 'fascicoli'}.</p>
</div>
<a class="lg-btn" href="/longevity/gestionale/nuovo">Nuovo cliente</a>
</div>
{righe.length === 0 ? (
<p class="lg-muted">Nessun cliente ancora. <a href="/longevity/gestionale/nuovo">Apri il primo fascicolo</a>.</p>
) : (
<table class="lg-table">
<thead>
<tr>
<th>Codice</th>
<th>Cognome e nome</th>
<th>Età</th>
<th>Accesso</th>
<th>Ultima sessione</th>
</tr>
</thead>
<tbody>
{righe.map((c) => (
<tr>
<td><a href={`/longevity/gestionale/cliente/${c.client_code}`}>{c.client_code}</a></td>
<td>{c.cognome} {c.nome}</td>
<td>{c.eta ?? '—'}</td>
<td>{c.ha_accesso ? 'Sì' : 'No'}</td>
<td>
{c.ultima ? (
<>{formattaData(c.ultima.data)} — {ETICHETTA_TIPO[c.ultima.tipo]}</>
) : (
<span class="lg-muted">Nessuna</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Longevity>
@@ -0,0 +1,52 @@
---
// Il modulo per aprire un fascicolo nuovo: posta all'API del gestionale, che fa la
// validazione vera (pages/api/longevity/gestionale/clienti.ts). Qui c'è solo il form.
import Longevity from '../../../layouts/Longevity.astro';
export const prerender = false;
const errore = Astro.url.searchParams.get('errore');
---
<Longevity title="Nuovo cliente" crumbs={['Clienti', 'Nuovo cliente']} variante="gestionale">
<div class="lg-head">
<h1>Nuovo cliente</h1>
<p>Nome, cognome e sesso bastano per aprire il fascicolo: il resto si completa dopo.</p>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
<form class="lg-form" method="post" action="/api/longevity/gestionale/clienti">
<section class="lg-form__blocco">
<label class="lg-field">
<span>Nome</span>
<input type="text" name="nome" required />
</label>
<label class="lg-field">
<span>Cognome</span>
<input type="text" name="cognome" required />
</label>
<fieldset class="lg-field lg-field--consenso">
<legend>Sesso</legend>
<label><input type="radio" name="sesso" value="F" required /> F</label>
<label><input type="radio" name="sesso" value="M" required /> M</label>
</fieldset>
<label class="lg-field">
<span>Data di nascita</span>
<input type="date" name="data_nascita" />
</label>
<label class="lg-field">
<span>Email</span>
<input type="email" name="email" />
</label>
<label class="lg-field">
<span>Telefono</span>
<input type="tel" name="telefono" />
</label>
</section>
<div class="lg-form__nav">
<a href="/longevity/gestionale">Annulla</a>
<button type="submit">Apri il fascicolo</button>
</div>
</form>
</Longevity>
@@ -1,7 +1,12 @@
--- ---
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al // Punto di ingresso della piattaforma: smista per ruolo, non è un indice proprio.
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath // Il trainer e l'admin lavorano nel gestionale (Unità 2b, #2); il cliente resta sulla
// e canAccessAdminPath in src/lib/auth.ts). // 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; 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'); return Astro.redirect('/longevity/io');
--- ---
+13 -2
View File
@@ -22,7 +22,17 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}> <Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
{!codice && ( {!codice && (
<div class="lg-card"> <div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p> {user.role === 'admin' || user.role === 'trainer' ? (
<p>
Il tuo account è da staff: non ha un fascicolo personale.
<a href="/longevity/gestionale">Torna al gestionale</a>.
</p>
) : (
<p>
Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.
<a href="/piattaforme">Torna alle piattaforme</a>.
</p>
)}
</div> </div>
)} )}
@@ -64,7 +74,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
<p> <p>
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
raccontano come cambi, non solo come stai oggi. raccontano come cambi, non solo come stai oggi.
<a href="/longevity/questionario">Compila di nuovo il questionario</a>. <a href="/longevity/progressione">Guarda il tuo andamento nel tempo</a> o
<a href="/longevity/questionario">compila di nuovo il questionario</a>.
</p> </p>
</section> </section>
</div> </div>
@@ -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) : [];
---
<Longevity title="Andamento nel tempo" crumbs={['Andamento nel tempo']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && punti.length < 2 && (
<div class="lg-card lg-card--incompleto">
<p>Serve almeno un secondo check-up o questionario per vedere un andamento.</p>
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
</div>
)}
{codice && punti.length >= 2 && (
<div class="lg-referto">
<section class="lg-card">
<h2>Il tuo andamento</h2>
<Progressione client:load punti={punti} />
</section>
<section class="lg-card">
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
</section>
</div>
)}
</Longevity>
+118 -1
View File
@@ -43,6 +43,11 @@
top: 0; top: 0;
z-index: 10; z-index: 10;
background: var(--lg-surface); background: var(--lg-surface);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 20px;
} }
.lg-top p { margin: 0; } .lg-top p { margin: 0; }
.lg-top__crumbs { .lg-top__crumbs {
@@ -50,6 +55,12 @@
text-transform: uppercase; color: var(--c-text-light); text-transform: uppercase; color: var(--c-text-light);
} }
.lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; } .lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
.lg-top__links { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.lg-top__link {
font-family: var(--font-heading); font-size: .74rem; font-weight: 600; letter-spacing: .1em;
text-transform: uppercase; color: var(--c-text-light); text-decoration: none;
}
.lg-top__link:hover { color: var(--c-heading); }
.lg-main { flex: 1; padding: 34px 32px 70px; } .lg-main { flex: 1; padding: 34px 32px 70px; }
@@ -100,6 +111,22 @@
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } .lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__lista li { margin-bottom: 2px; } .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 --- */ /* --- le carte dei macro-score --- */
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; } .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; } .lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
@@ -116,6 +143,10 @@
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; } .lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
.lg-field > span { color: var(--c-heading); font-weight: 500; } .lg-field > span { color: var(--c-heading); font-weight: 500; }
.lg-field input[type="number"], .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 { .lg-field textarea {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface); border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
} }
@@ -123,7 +154,18 @@
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; } .lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); } .lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; } /* Bersaglio touch >= 44px (#15, tablet del centro): l'input radio/checkbox resta piccolo,
ma l'intera label (dove il dito tocca davvero) ha l'altezza minima del criterio WCAG. */
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; min-height: 44px; }
/* Sezioni ad alternative del check-up (#13): un fieldset per protocollo, radio in legenda. */
.lg-form__alternative { display: flex; flex-direction: column; gap: 12px; }
.lg-form__alternativa { border: 1px solid var(--lg-line); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
.lg-form__alternativa legend { padding: 0 4px; }
.lg-form__alternativa legend label { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--c-heading); min-height: 44px; } /* bersaglio touch (#15) */
.lg-form__soglia { margin: 0; font-size: .8rem; color: var(--c-text-light); }
.lg-form__alternativa details { font-size: .85rem; }
.lg-form__alternativa summary { cursor: pointer; color: var(--c-text-light); }
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; } .lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
@@ -132,9 +174,84 @@
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em; font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading); text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
background: transparent; color: var(--c-heading); cursor: pointer; background: transparent; color: var(--c-heading); cursor: pointer;
min-height: 44px; /* bersaglio touch (#15) */
} }
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; } .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; } .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 <button> di form) */
.lg-btn {
display: inline-flex; align-items: center; font-family: var(--font-heading); font-size: .76rem; font-weight: 600;
letter-spacing: .06em; text-transform: uppercase; padding: 10px 20px; border: 1px solid var(--c-accent);
background: var(--c-accent); color: var(--lg-surface); text-decoration: none;
min-height: 44px; box-sizing: border-box; /* bersaglio touch (#15) */
}
.lg-btn:hover { background: var(--c-accent-dark); border-color: var(--c-accent-dark); }
/* tabelle del gestionale: elenco clienti, sessioni, misure fuori range */
.lg-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.lg-table th, .lg-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--lg-line); }
.lg-table th {
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--c-text-light);
}
.lg-table tbody tr:hover { background: var(--c-bg-alt); }
.lg-table a { color: var(--c-heading); font-weight: 600; text-decoration: none; }
.lg-table a:hover { color: var(--c-accent-dark); }
.lg-menu-btn { display: none; }
@media (max-width: 900px) {
.lg[data-variante="gestionale"] { display: block; }
.lg-menu-btn {
display: block; position: sticky; top: 0; z-index: 20; cursor: pointer;
background: var(--lg-surface); color: var(--c-heading); border-bottom: 1px solid var(--lg-line);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; padding: 14px 20px;
}
.lg-side { display: none; width: auto; flex: none; height: auto; position: static; }
.lg-menu-toggle:checked ~ .lg-side { display: flex; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; } .lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
+15 -5
View File
@@ -34,13 +34,20 @@ describe('utenti e sessioni di quest\'app', () => {
for (const finto of ['campus', 'superuser', 'user', 'Admin', '', 'cliente ']) expect(isRole(finto)).toBe(false); for (const finto of ['campus', 'superuser', 'user', 'Admin', '', 'cliente ']) expect(isRole(finto)).toBe(false);
}); });
it('landingFor manda ognuno a casa propria, e nessuno su /admin', () => { it('dopo il login si atterra sulla scelta della piattaforma, qualunque sia il ruolo', () => {
expect(landingFor('cliente')).toBe('/longevity/io'); // Deciso da Adriano il 03/09/2026: si entra e si sceglie, anche quando la scelta e' una
expect(landingFor('trainer')).toBe('/longevity/gestionale'); // sola. Prima ogni ruolo saltava direttamente nella sua sezione.
expect(landingFor('piattaforme')).toBe('/piattaforme'); for (const ruolo of ROLES) expect(landingFor(ruolo)).toBe('/piattaforme');
expect(landingFor('admin')).toBe('/piattaforme');
for (const ruolo of ROLES) expect(landingFor(ruolo)).not.toMatch(/^\/admin/); for (const ruolo of ROLES) expect(landingFor(ruolo)).not.toMatch(/^\/admin/);
}); });
it('⚠️ la pagina di atterraggio è aperta a TUTTI i ruoli, o è un ciclo di redirect', () => {
// landingFor e' anche il "torna a casa" del middleware quando un ruolo apre una sezione
// che non gli spetta: se la sua destinazione fosse a sua volta negata, il middleware
// rimanderebbe li' all'infinito. E' il guasto che il sito ha rischiato lo stesso giorno
// con gli account emigrati, in un punto diverso del codice.
for (const ruolo of ROLES) expect(canAccessPath(ruolo, landingFor(ruolo))).toBe(true);
});
}); });
describe('canAccessPath — le tre sezioni', () => { describe('canAccessPath — le tre sezioni', () => {
@@ -65,6 +72,8 @@ describe('canAccessPath — le tre sezioni', () => {
expect(canAccessPath('cliente', '/api/longevity/gestionale/misure')).toBe(false); expect(canAccessPath('cliente', '/api/longevity/gestionale/misure')).toBe(false);
expect(canAccessPath('cliente', '/campus')).toBe(false); expect(canAccessPath('cliente', '/campus')).toBe(false);
expect(canAccessPath('cliente', '/piattaforme/stress-index')).toBe(false); expect(canAccessPath('cliente', '/piattaforme/stress-index')).toBe(false);
// ...ma l'indice sì: è la pagina di scelta, ed è la sua casa dopo il login.
expect(canAccessPath('cliente', '/piattaforme')).toBe(true);
}); });
it('trainer: entrambe le parti di Longevity, niente Campus né Stress Index', () => { it('trainer: entrambe le parti di Longevity, niente Campus né Stress Index', () => {
@@ -73,6 +82,7 @@ describe('canAccessPath — le tre sezioni', () => {
expect(canAccessPath('trainer', '/api/longevity/gestionale/misure')).toBe(true); expect(canAccessPath('trainer', '/api/longevity/gestionale/misure')).toBe(true);
expect(canAccessPath('trainer', '/campus')).toBe(false); expect(canAccessPath('trainer', '/campus')).toBe(false);
expect(canAccessPath('trainer', '/piattaforme/stress-index')).toBe(false); expect(canAccessPath('trainer', '/piattaforme/stress-index')).toBe(false);
expect(canAccessPath('trainer', '/piattaforme')).toBe(true);
}); });
it('admin passa sempre', () => { it('admin passa sempre', () => {
@@ -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<typeof createIdentityDb>, utenti: ReturnType<typeof createDb>): 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(');
});
});
@@ -0,0 +1,156 @@
// Test B (#13): l'API checkup.ts scarta i campi di un'alternativa non scelta e aggiunge una
// voce di giornale dalla nota operatore. File separato (stesso motivo di
// correzione-checkup-api.test.ts): l'endpoint usa il singleton getLongevityDb(), e
// LONGEVITY_DB_PATH va impostata PRIMA che lo apra — non nello stesso file di altri test che
// lo inizializzano prima. A differenza di correzione-checkup-api.test.ts (un solo test) qui
// ce ne sono diversi: il singleton si apre UNA volta per tutto il file (beforeAll/afterAll,
// non chiuso e riaperto a ogni test — getLongevityDb() lo terrebbe chiuso dopo il primo
// close()), e ogni test usa un client_code proprio per non leggere le voci di giornale di un
// altro test.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { vociDelGiornale } from '../../src/lib/longevity/giornale';
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
// La data d'oggi vera del sistema che esegue i test (non una finta): un checkup con questa
// data non è "nel futuro" per la validazione dell'API.
const OGGI = new Date().toISOString().slice(0, 10);
let dir: string;
let primaLg: string | undefined;
let lg: ReturnType<typeof getLongevityDb>;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'lg-api-alt-test-'));
primaLg = process.env.LONGEVITY_DB_PATH;
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
lg = getLongevityDb();
seedRegistro(lg);
seedPesi(lg, MODEL_VERSION);
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005', 'ISL-0006']) {
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
}
});
afterAll(() => {
lg.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
});
describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () => {
it('scarta i campi di un\'alternativa non scelta, anche se compilati', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0001');
form.set('data', OGGI);
form.set('alt_spinta', 'pushup'); // scelta: push-up
form.set('f_pushup_rip', '15'); // dell'alternativa scelta: deve restare
form.set('f_panca_bil_carico_kg', '60'); // di un'altra alternativa (bilanciere): va scartato
form.set('f_panca_bil_rip', '5');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/salvato=/);
const sessioneId = Number(new URL(loc, 'http://localhost').searchParams.get('salvato'));
const righe = lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[];
const ids = righe.map((r) => r.test_id);
expect(ids).toContain('f_pushup_rip');
expect(ids).not.toContain('f_panca_bil_carico_kg');
expect(ids).not.toContain('f_panca_bil_rip');
});
it('senza radio inviata (nessun alt_*), non si scarta nulla: un campo di un\'alternativa non-prima resta salvato (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0002');
form.set('data', OGGI);
// nessun alt_core: 'ginocchia' è la prima alternativa di "Core", f_plank_sec è 'pieno'
// (la seconda) — prima della correzione questo campo veniva scartato in silenzio.
form.set('f_plank_sec', '90');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
const sessioneId = Number(new URL(res.headers.get('Location')!, 'http://localhost').searchParams.get('salvato'));
const ids = (lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[])
.map((r) => r.test_id);
expect(ids).toContain('f_plank_sec');
});
it('nota operatore non vuota: aggiunge una voce di giornale con la data del check-up e l\'operatore come autore', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0003');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', 'Cliente motivato, riprende dopo la pausa estiva.');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const voci = vociDelGiornale(lg, 'ISL-0003');
expect(voci.length).toBe(1);
expect(voci[0].testo).toBe('Cliente motivato, riprende dopo la pausa estiva.');
expect(voci[0].data).toBe(OGGI);
expect(voci[0].autore).toBe('nicola');
});
it('nota operatore vuota (o assente): nessuna voce di giornale', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0004');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', ' '); // solo spazi: trim -> vuoto
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(vociDelGiornale(lg, 'ISL-0004').length).toBe(0);
});
it('una correzione (sessione_id nel form) non porta il campo nota: nessuna voce anche se la sessione ha una data valida', async () => {
const esito = salvaCheckup(lg, {
client_code: 'ISL-0005', data: OGGI, eta: 30, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 70 }],
});
if (!esito.ok) throw new Error(esito.errore);
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0005');
form.set('sessione_id', String(esito.sessioneId));
form.set('data', OGGI);
form.set('f_peso_kg', '71');
// niente campo "nota": la pagina non lo mostra in correzione
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0);
});
it('nota oltre 5000 caratteri: errore PRIMA di salvare, nessuna sessione né voce di giornale (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0006');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', 'x'.repeat(5001));
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
expect(res.headers.get('Location')).toMatch(/errore=/);
const sessioni = lg.prepare(`SELECT COUNT(*) AS n FROM sessioni WHERE client_code = ?`).get('ISL-0006') as { n: number };
expect(sessioni.n).toBe(0); // niente scritto: né la sessione né la misura f_peso_kg
expect(vociDelGiornale(lg, 'ISL-0006').length).toBe(0);
});
});
@@ -0,0 +1,281 @@
// Issue #15: prima consulenza per cliente (solo staff, una scheda sola — correggere e
// salvare sovrascrive, nessuno storico), e il flusso guidato per un cliente nuovo.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb, getLongevityDb } from '../../src/lib/longevity/db';
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
import { consulenzaDi, salvaConsulenza, OBIETTIVI } from '../../src/lib/longevity/consulenza';
function dbPronto() {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('salvaConsulenza e consulenzaDi', () => {
it('inserisce e legge la scheda', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, {
clientCode: 'ISL-0001', autore: 'nicola',
campi: { livello: '2', obiettivoPrimario: 'dimagrimento', dataTarget: '2027-01-01', disponibilitaGiorni: '3' },
});
expect(esito.ok).toBe(true);
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda).not.toBeNull();
expect(scheda?.livello).toBe(2);
expect(scheda?.obiettivo_primario).toBe('dimagrimento');
expect(scheda?.data_target).toBe('2027-01-01');
expect(scheda?.disponibilita_giorni).toBe(3);
expect(scheda?.aggiornata_da).toBe('nicola');
});
it('null se non compilata', () => {
const db = dbPronto();
expect(consulenzaDi(db, 'ISL-0001')).toBeNull();
});
it('una seconda chiamata sovrascrive la prima: una riga sola, nessuno storico', () => {
const db = dbPronto();
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } });
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'donata', campi: { livello: '3' } });
const righe = (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
expect(righe).toBe(1);
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda?.livello).toBe(3);
expect(scheda?.aggiornata_da).toBe('donata');
});
it('stringhe vuote diventano NULL', () => {
const db = dbPronto();
salvaConsulenza(db, {
clientCode: 'ISL-0001', autore: 'nicola',
campi: { obiettivoSpecifico: ' ', noteMediche: '', fasciaOraria: undefined },
});
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda?.obiettivo_specifico).toBeNull();
expect(scheda?.note_mediche).toBeNull();
expect(scheda?.fascia_oraria).toBeNull();
});
it('tutti i campi sono facoltativi: una scheda vuota è valida', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: {} });
expect(esito.ok).toBe(true);
});
});
describe('salvaConsulenza: rifiuti, nessuna scrittura', () => {
const righe = (db: ReturnType<typeof dbPronto>) =>
(db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
it('rifiuta un cliente inesistente', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-9999', autore: 'nicola', campi: {} });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un livello fuori 1-3', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '4' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un livello non intero', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2.5' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un obiettivo primario fuori elenco', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: 'volare' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('accetta ogni chiave dell elenco OBIETTIVI', () => {
const db = dbPronto();
for (const o of OBIETTIVI) {
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: o.chiave } });
expect(esito.ok, o.chiave).toBe(true);
}
});
it('rifiuta una data target malformata', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { dataTarget: '01-01-2027' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta una disponibilita fuori 0-7', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '8' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta una disponibilita non intera', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '2,5' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un testo libero troppo lungo', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { noteMediche: 'x'.repeat(5001) } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
});
describe('schema: la tabella consulenza si crea su un longevity.db preesistente senza di essa', () => {
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
expect(salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }).ok).toBe(true);
expect(() => createLongevityDb(':memory:')).not.toThrow();
});
});
// Le API sotto (consulenza.ts e checkup.ts) chiamano SEMPRE getLongevityDb(), il singleton
// di db.ts: aprirlo e chiuderlo dentro ogni singolo test (come fa correzione-checkup-api.test.ts
// da solo nel suo file) romperebbe i test successivi dello STESSO file, perché il singleton
// resta quello chiuso finché il modulo non si ricarica. Qui si apre una volta sola per
// tutte le API, con beforeAll/afterAll, e ogni test usa un client_code diverso per non
// pestarsi i piedi — stesso principio, applicato a un file con più test sullo stesso
// singleton invece di uno solo.
describe('API del flusso guidato (consulenza.ts, checkup.ts)', () => {
let dir: string;
let db: ReturnType<typeof getLongevityDb>;
let primaLg: string | undefined;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'lg-api-flusso-test-'));
primaLg = process.env.LONGEVITY_DB_PATH;
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
db = getLongevityDb();
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004']) {
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
}
});
afterAll(() => {
db.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
});
it('consulenza.ts: salva con autore da locals.user (mai dal form) e torna alla scheda cliente, fuori dal flusso', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-0001');
form.set('livello', '2');
form.set('autore', 'un-impostore'); // ignorato: deve vincere locals.user
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0001?consulenza=salvata#consulenza');
const riga = db.prepare(`SELECT aggiornata_da FROM consulenza WHERE client_code = 'ISL-0001'`).get() as
{ aggiornata_da: string };
expect(riga.aggiornata_da).toBe('nicola');
});
it('consulenza.ts: con flusso=nuovo prosegue verso il check-up', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-0002');
form.set('flusso', 'nuovo');
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0002/checkup?flusso=nuovo');
});
it('consulenza.ts: client_code inesistente, errore, torna al modulo conservando il flusso', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-9999');
form.set('flusso', 'nuovo');
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-9999\/consulenza\?/);
expect(loc).toMatch(/flusso=nuovo/);
expect(loc).toMatch(/errore=/);
});
it('checkup.ts: un check-up NUOVO con flusso=nuovo redirige con flusso=questionario', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0003');
form.set('data', '2026-09-26');
form.set('flusso', 'nuovo');
form.set('f_peso_kg', '80'); // salvaCheckup rifiuta un check-up senza nessuna misura
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0003\?/);
expect(loc).toMatch(/salvato=/);
expect(loc).toMatch(/flusso=questionario/);
});
it('checkup.ts: una correzione (sessione_id) ignora flusso, nessun flusso nel redirect', async () => {
const esito = salvaCheckup(db, {
client_code: 'ISL-0004', data: '2026-09-20', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0004');
form.set('data', '2026-09-20');
form.set('sessione_id', String(esito.sessioneId));
form.set('f_peso_kg', '82');
form.set('flusso', 'nuovo'); // ignorato: una correzione non è mai nel flusso guidato
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0004\?/);
expect(loc).toMatch(/corretto=/);
expect(loc).not.toMatch(/flusso=/);
});
});
describe('clienti.ts: dopo la creazione il flusso guidato comincia dalla consulenza', () => {
it('redirige a .../consulenza?flusso=nuovo, non piu direttamente alla scheda', () => {
const fs = require('node:fs') as typeof import('node:fs');
const path = require('node:path') as typeof import('node:path');
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/api/longevity/gestionale/clienti.ts'), 'utf8');
expect(src).toMatch(/\/consulenza\?flusso=nuovo/);
});
});
describe('nessuna pagina cliente importa la consulenza', () => {
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a consulenza.ts', () => {
const fs = require('node:fs') as typeof import('node:fs');
const path = require('node:path') as typeof import('node:path');
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
for (const p of pagine) {
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
expect(src, `${p} non deve importare consulenza.ts`).not.toMatch(/consulenza/i);
}
});
});
@@ -0,0 +1,62 @@
// Issue #12: la correzione di un check-up passa dalla stessa API di salvaCheckup, distinta
// da un sessione_id nel form. File separato da gestionale.test.ts apposta: l'endpoint usa il
// singleton getLongevityDb() (db.ts), e gestionale.test.ts ha già un test (apriFascicolo)
// che lo inizializza — nello stesso file la variabile d'ambiente impostata qui arriverebbe
// troppo tardi. Stesso trucco di questionario-pagina.test.ts: LONGEVITY_DB_PATH va impostata
// PRIMA che il singleton apra il suo longevity.db. Il setup usa lo STESSO getLongevityDb()
// che poi chiamerà POST (una sola connessione, non due sullo stesso file): così si può
// chiuderla nel finally, altrimenti rmSync fallisce con EPERM su Windows a handle aperto.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
describe('API checkup.ts, correzione (#12): il client_code del redirect e delle validazioni viene sempre dalla sessione', () => {
it('un sessione_id di un altro cliente rispetto al client_code del form: errore, niente scritto', async () => {
const dir = mkdtempSync(join(tmpdir(), 'lg-api-checkup-test-'));
const lgPath = join(dir, 'longevity.db');
const primaLg = process.env.LONGEVITY_DB_PATH;
let lg: ReturnType<typeof getLongevityDb> | undefined;
try {
process.env.LONGEVITY_DB_PATH = lgPath;
lg = getLongevityDb();
seedRegistro(lg);
seedPesi(lg, MODEL_VERSION);
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
const esito = salvaCheckup(lg, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('sessione_id', String(esito.sessioneId));
form.set('client_code', 'ISL-0002'); // la sessione è di ISL-0001: incoerente
form.set('data', '2026-09-26');
form.set('f_peso_kg', '999'); // se scrivesse, sarebbe questo il valore
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'donata' } } } as any);
expect(res.status).toBe(303);
expect(res.headers.get('Location')).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0001\?/); // redirect sul cliente VERO, non su quello del form
expect(res.headers.get('Location')).toMatch(/errore=/);
const riga = lg.prepare(
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
).get(esito.sessioneId) as { valore_num: number };
expect(riga.valore_num).toBe(80); // invariato: nessuna scrittura
} finally {
lg?.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,112 @@
// Test B (#13): la struttura del modulo di check-up (strutturaCheckup) e la preselezione
// delle alternative (alternativaSelezionata) — tutto in memoria, nessun singleton. I test
// dell'API (campi scartati, nota->giornale) sono in checkup-alternative-api.test.ts: stesso
// motivo di correzione-checkup-api.test.ts, l'endpoint usa il singleton getLongevityDb().
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import {
strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup, salvaCheckup,
type SezioneCheckup,
} from '../../src/lib/longevity/gestionale';
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('strutturaCheckup: ordine e contenuto delle sezioni', () => {
it('le sezioni compaiono nell\'ordine del manuale: Pre-test, Mobilità, Equilibrio, Presa, Core, Forza spinta, Forza trazione, Arti inferiori, Aerobico, Strumenti', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
expect(modulo.sezioni.map((s) => s.titolo)).toEqual([
'Pre-test', 'Mobilità', 'Equilibrio', 'Presa', 'Core', 'Forza spinta',
'Forza trazione', 'Arti inferiori', 'Aerobico', 'Strumenti (Wellness Tower, Stress Index)',
]);
});
it('pressione e SpO2 non compaiono in nessuna sezione né in Altre misure', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const tuttiGliId = modulo.sezioni.flatMap((s) =>
s.tipo === 'campi' ? s.campi.map((c) => c.id)
: s.alternative.flatMap((a) => [...a.campi.map((c) => c.id), ...(a.dettagli?.map((c) => c.id) ?? [])])
);
for (const id of ['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']) {
expect(tuttiGliId).not.toContain(id);
expect(modulo.altreMisure.map((c) => c.id)).not.toContain(id);
}
});
it('su una data vecchia (prima del 28/09) un test dismesso finisce in Altre misure, mai perso', () => {
const db = dbPronto();
// 2026-09-27: i vecchi test_id storici sono ancora attivi (attivo_a: 2026-09-28),
// e non compaiono in nessuna sezione di STRUTTURA_RAW (che usa solo i nuovi id).
const modulo = strutturaCheckup(db, '2026-09-27');
const altreIds = modulo.altreMisure.map((c) => c.id);
expect(altreIds).toContain('f_handgrip_kg');
expect(altreIds).toContain('f_back_scratch_cm');
expect(altreIds).toContain('f_flamingo_cadute');
expect(altreIds).toContain('f_hrr_1min_bpm');
});
it('su una data recente (dopo il 28/09) i test dismessi non sono più attivi: spariscono anche da Altre misure', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const altreIds = modulo.altreMisure.map((c) => c.id);
expect(altreIds).not.toContain('f_handgrip_kg');
});
it('il MUTT porta i suoi otto step FC come dettagli, non come campi normali', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const aerobico = modulo.sezioni.find((s) => s.titolo === 'Aerobico');
expect(aerobico?.tipo).toBe('alternative');
if (aerobico?.tipo !== 'alternative') throw new Error('atteso alternative');
const mutt = aerobico.alternative.find((a) => a.chiave === 'mutt')!;
expect(mutt.dettagli?.length).toBe(8);
expect(mutt.dettagli?.map((c) => c.id)).toContain('f_mutt_fc_7_5');
expect(mutt.campi.map((c) => c.id)).not.toContain('f_mutt_fc_7_5');
});
});
describe('alternativaSelezionata: quale alternativa preselezionare', () => {
it('senza corrispondenza in valori, vince la prima alternativa elencata (neofita)', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
expect(alternativaSelezionata(spinta, new Map())).toBe('manubri');
});
it('con un valore di un\'alternativa più avanti nell\'elenco, quella vince', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
const valori = new Map([['f_panca_bil_carico_kg', 60]]);
expect(alternativaSelezionata(spinta, valori)).toBe('bilanciere');
});
it('valoriUltimoCheckup: vuota se il cliente non ha ancora un check-up, altrimenti le misure dell\'ultimo', () => {
const db = dbPronto();
expect(valoriUltimoCheckup(db, 'ISL-0001').size).toBe(0);
salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-01-10', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_panca_bil_carico_kg', valore_num: 50 }, { test_id: 'f_panca_bil_rip', valore_num: 5 }],
});
const ultima = valoriUltimoCheckup(db, 'ISL-0001');
expect(ultima.get('f_panca_bil_carico_kg')).toBe(50);
// un secondo check-up più recente sovrascrive quale sia "l'ultimo"
salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-03-10', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_pushup_rip', valore_num: 12 }],
});
const piuRecente = valoriUltimoCheckup(db, 'ISL-0001');
expect(piuRecente.get('f_pushup_rip')).toBe(12);
expect(piuRecente.has('f_panca_bil_carico_kg')).toBe(false);
});
});
@@ -0,0 +1,481 @@
// 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, correggiCheckup, misureDellaSessione } from '../../src/lib/longevity/gestionale';
import { misureDaVerificare, verificaMisura } from '../../src/lib/longevity/misure';
import { leggiScore } from '../../src/lib/longevity/motore';
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 misureDaVerificare', () => {
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 = misureDaVerificare(db, 'ISL-0001');
expect(elenco.length).toBe(1);
expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore_num: 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('misureDellaSessione', () => {
it('null se la sessione non esiste; altrimenti le misure ordinate per asse come campiDelCheckup', () => {
const db = dbPronto();
expect(misureDellaSessione(db, 999999)).toBeNull();
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 dettaglio = misureDellaSessione(db, esito.sessioneId);
expect(dettaglio?.tipo).toBe('checkup');
expect(dettaglio?.operatore).toBe('nicola');
// 'Forza & Struttura' precede 'Composizione Corporea' nei pesi (PESI_ASSE, registro.ts):
// stesso ordine che userebbe campiDelCheckup per raggruppare il modulo.
expect(dettaglio?.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg', 'f_peso_kg']);
expect(dettaglio?.misure[1]).toMatchObject({ etichetta: 'Peso corporeo', valore_num: 80, verifica: null });
});
});
describe('correggiCheckup: sovrascrittura, nessuno storico delle misure (#12)', () => {
it('corregge un valore: misura aggiornata, score della sessione ricalcolato; la successiva viene ricalcolata, la precedente resta intatta', () => {
const db = dbPronto();
const precedente = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
const target = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-10', eta: 38, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 82 }],
});
const successiva = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-20', eta: 38, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 83 }],
});
expect(precedente.ok && target.ok && successiva.ok).toBe(true);
if (!precedente.ok || !target.ok || !successiva.ok) throw new Error('setup fallito');
const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n;
const primaPrecedente = contaScore(precedente.sessioneId);
const primaTarget = contaScore(target.sessioneId);
const primaSuccessiva = contaScore(successiva.sessioneId);
const esito = correggiCheckup(db, {
sessioneId: target.sessioneId, data: '2026-09-10', eta: 38, operatore: 'donata',
misure: [{ test_id: 'f_peso_kg', valore_num: 90 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
expect(esito.sessioneId).toBe(target.sessioneId);
const riga = db.prepare(
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
).get(target.sessioneId) as { valore_num: number };
expect(riga.valore_num).toBe(90);
const sessioneAggiornata = db.prepare(`SELECT operatore FROM sessioni WHERE id = ?`).get(target.sessioneId) as { operatore: string };
expect(sessioneAggiornata.operatore).toBe('donata'); // l'operatore diventa chi corregge
// score ricalcolato (righe nuove in append): target e successiva sì, precedente no.
expect(contaScore(target.sessioneId)).toBeGreaterThan(primaTarget);
expect(contaScore(successiva.sessioneId)).toBeGreaterThan(primaSuccessiva);
expect(contaScore(precedente.sessioneId)).toBe(primaPrecedente);
});
it('campo svuotato cancella la misura; campo nuovo la inserisce con fonte manuale', () => {
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 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const corretto = correggiCheckup(db, {
sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola',
// f_peso_kg non c'è più (campo svuotato nel form), f_handgrip_kg è nuovo.
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
});
expect(corretto.ok).toBe(true);
const righe = db.prepare(`SELECT test_id, fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as
{ test_id: string; fonte: string }[];
expect(righe).toEqual([{ test_id: 'f_handgrip_kg', fonte: 'manuale' }]);
});
it('valore invariato conserva la verifica; valore cambiato azzera la verifica e ricalcola fuori_range', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
// 500 kg è oltre il range di plausibilità (max 250): fuori range da subito.
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 45 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const misuraPeso = db.prepare(
`SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
).get(esito.sessioneId) as { id: number };
const conferma = verificaMisura(db, { misuraId: misuraPeso.id, esito: 'confermata', operatore: 'nicola' });
expect(conferma.ok).toBe(true);
// Correzione: f_peso_kg invariato (500), f_handgrip_kg cambiato (45 -> 50).
const corretto = correggiCheckup(db, {
sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 50 }],
});
expect(corretto.ok).toBe(true);
const pesoDopo = db.prepare(
`SELECT verifica, fuori_range FROM misure WHERE id = ?`
).get(misuraPeso.id) as { verifica: string | null; fuori_range: number };
expect(pesoDopo.verifica).toBe('confermata'); // invariato: la verifica resta
expect(pesoDopo.fuori_range).toBe(1);
const handgripDopo = db.prepare(
`SELECT valore_num, fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_handgrip_kg'`
).get(esito.sessioneId) as { valore_num: number; fuori_range: number };
expect(handgripDopo.valore_num).toBe(50);
expect(handgripDopo.fuori_range).toBe(0); // 50 è nel range (0-100): ricalcolato, non ereditato
});
it('cambio data che sposta la sessione dopo un\'altra: entrambe ricalcolate', () => {
const db = dbPronto();
const a = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
const b = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-15', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
});
expect(a.ok && b.ok).toBe(true);
if (!a.ok || !b.ok) throw new Error('setup fallito');
const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n;
const primaA = contaScore(a.sessioneId);
const primaB = contaScore(b.sessioneId);
// Sposta A dopo B: la posizione minima (min fra vecchia e nuova data di A) resta
// 2026-09-01, quindi la ricalcolata copre sia A sia B.
const esito = correggiCheckup(db, {
sessioneId: a.sessioneId, data: '2026-09-20', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(esito.ok).toBe(true);
expect(contaScore(a.sessioneId)).toBeGreaterThan(primaA);
expect(contaScore(b.sessioneId)).toBeGreaterThan(primaB);
});
it('rifiuti: sessione inesistente, sessione questionario, nessuna misura, test non f_* — nessuna scrittura', () => {
const db = dbPronto();
const inesistente = correggiCheckup(db, {
sessioneId: 999999, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(inesistente.ok).toBe(false);
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);
if (!q.ok) throw new Error('setup questionario fallito');
const suQuestionario = correggiCheckup(db, {
sessioneId: q.sessioneId, data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(suQuestionario.ok).toBe(false);
const checkup = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(checkup.ok).toBe(true);
if (!checkup.ok) throw new Error('setup checkup fallito');
const nessunaMisura = correggiCheckup(db, {
sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [],
});
expect(nessunaMisura.ok).toBe(false);
const nonFisico = correggiCheckup(db, {
sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }],
});
expect(nonFisico.ok).toBe(false);
// Nessuna delle chiamate rifiutate ha scritto: la misura del checkup è ancora quella originale.
const rigaFinale = db.prepare(
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
).get(checkup.sessioneId) as { valore_num: number };
expect(rigaFinale.valore_num).toBe(80);
});
});
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<string, string>): 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);
});
});
@@ -0,0 +1,150 @@
// Issue #16: giornale di bordo per cliente (solo staff, nessuna voce automatica, si
// aggiunge e si cancella, non si modifica).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { vociDelGiornale, aggiungiVoce, cancellaVoce } from '../../src/lib/longevity/giornale';
function dbPronto() {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('aggiungiVoce e vociDelGiornale', () => {
it('aggiunge e legge le voci, più recente prima (data DESC, a parità id DESC), a capo conservati', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Prima nota' });
const b = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'donata', testo: 'Riga uno\nRiga due' });
const c = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'nicola', testo: 'Stessa data, dopo' });
expect(a.ok).toBe(true);
expect(b.ok).toBe(true);
expect(c.ok).toBe(true);
const voci = vociDelGiornale(db, 'ISL-0001');
expect(voci.map((v) => v.testo)).toEqual(['Stessa data, dopo', 'Riga uno\nRiga due', 'Prima nota']);
expect(voci[1].testo).toBe('Riga uno\nRiga due'); // a capo preservati
expect(voci[0].autore).toBe('nicola');
});
});
describe('aggiungiVoce: rifiuti, nessuna riga scritta', () => {
const contaRighe = (db: ReturnType<typeof dbPronto>) =>
(db.prepare(`SELECT COUNT(*) n FROM giornale`).get() as { n: number }).n;
it('rifiuta testo vuoto', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: '' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta testo di soli spazi', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: ' \n ' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta testo troppo lungo', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'x'.repeat(5001) });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta una data nel futuro', () => {
const db = dbPronto();
const domani = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: domani, autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta una data malformata', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '01-09-2026', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta un cliente inesistente', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-9999', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
});
describe('cancellaVoce', () => {
it('l\'autore può cancellare la propria voce', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'nicola', ruolo: 'trainer' });
expect(esito.ok).toBe(true);
if (esito.ok) expect(esito.clientCode).toBe('ISL-0001');
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
});
it('un admin può cancellare la voce di un altro', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'adriano', ruolo: 'admin' });
expect(esito.ok).toBe(true);
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
});
it('un altro trainer non può cancellare la voce di un collega', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'donata', ruolo: 'trainer' });
expect(esito.ok).toBe(false);
expect(vociDelGiornale(db, 'ISL-0001').length).toBe(1); // nessuna scrittura
});
it('rifiuta un id inesistente', () => {
const db = dbPronto();
const esito = cancellaVoce(db, { id: 999999, utente: 'nicola', ruolo: 'trainer' });
expect(esito.ok).toBe(false);
});
});
describe('schema: la tabella giornale si crea su un longevity.db preesistente senza di essa', () => {
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
// db.ts crea la tabella con CREATE TABLE IF NOT EXISTS nello stesso blocco SCHEMA_LONGEVITY
// delle altre tabelle: su un file che le ha già (come questo, aperto due volte) la seconda
// apertura è un'operazione a vuoto, esattamente come per soggetti o sessioni — non serve
// una migra() dedicata perché non c'è una tabella preesistente da alterare, solo una nuova
// da creare la prima volta che manca.
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(true);
// Riaprire lo stesso schema (qui, un nuovo :memory:, ma stesso SCHEMA_LONGEVITY) non deve
// fallire: la tabella è già presente, IF NOT EXISTS salta la creazione.
expect(() => createLongevityDb(':memory:')).not.toThrow();
});
});
describe('nessuna pagina cliente importa il giornale', () => {
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a giornale.ts', () => {
// Stile di anagrafica.ts: controllo testuale sui file, non sull'albero delle import —
// basta a catturare un'importazione diretta, che è l'unico modo in cui il cliente
// potrebbe finire per vedere il giornale di bordo (solo staff, #16).
const fs = require('node:fs') as typeof import('node:fs');
const path = require('node:path') as typeof import('node:path');
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
for (const p of pagine) {
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
expect(src, `${p} non deve importare giornale.ts`).not.toMatch(/giornale/i);
}
});
});
@@ -0,0 +1,179 @@
// La regola dei gruppi (issue #13, manuale batteria test di Nicola del 27/09/2026): per
// alcune sotto-metriche (handgrip, spinta, arti_inferiori, vo2max, hrr, flamingo,
// back_scratch) sottoDominiFisici sceglie fra piu' alternative COMPLETE quella la cui
// misura piu' recente vince (`recenza`, opzionale). Questo file copre la regola da sola,
// con `sottoDominiFisici` chiamata direttamente (mappe di misure a mano, non un DB): i
// valori attesi si calcolano con le STESSE funzioni di test-fisici.ts (gia' verificate
// contro l'oracolo in motore-fisici.test.ts), mai a mano.
import { describe, it, expect } from 'vitest';
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
import * as F from '../../src/lib/longevity/motore/test-fisici';
describe('un caso per ogni alternativa che da punteggio', () => {
it('handgrip: media dx/sx (manuale)', () => {
const m = new Map([['f_handgrip_dx_kg', 45], ['f_handgrip_sx_kg', 51]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBe(F.scoreHandgrip(48, 38, 'M')); // media (45+51)/2 = 48
});
it('handgrip: storico f_handgrip_kg (ancora letto)', () => {
const m = new Map([['f_handgrip_kg', 44]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBe(F.scoreHandgrip(44, 38, 'M'));
});
it('spinta: panca con bilanciere (carico, ripetizioni, peso)', () => {
const m = new Map([['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
});
it('spinta: push-up (invariato)', () => {
const m = new Map([['f_pushup_rip', 24]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
});
it('arti_inferiori: sit-to-stand (ripetizioni, eta, sesso, bmi da peso/altezza)', () => {
const m = new Map([['f_sit_to_stand_rip', 14], ['f_peso_kg', 65], ['f_altezza_cm', 160]]);
const r = sottoDominiFisici(m, 65, 'F');
const bmi = 65 / (160 / 100) ** 2;
expect(r.arti_inferiori).toBe(F.scoreSitToStand(14, 65, 'F', bmi));
});
it('vo2max: MILFIT (potenza, peso)', () => {
const m = new Map([['f_milfit_watt', 150], ['f_peso_kg', 80]]);
const r = sottoDominiFisici(m, 40, 'M');
const vo2 = F.vo2maxDaMilfit(150, 80);
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
});
it('vo2max: camminata 2 km UKK (tempo, fc, eta, bmi)', () => {
const m = new Map([
['f_walk2km_min', 18], ['f_walk2km_fc_bpm', 130], ['f_peso_kg', 80], ['f_altezza_cm', 180],
]);
const r = sottoDominiFisici(m, 40, 'M');
const bmi = 80 / (180 / 100) ** 2;
const vo2 = F.vo2maxDa2kmWalk(18, 130, 40, bmi);
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
});
it('hrr: MUTT (FC max - FC dopo 1 minuto, manuale)', () => {
const m = new Map([['f_mutt_fc_max_bpm', 180], ['f_mutt_fc_1min_bpm', 140]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(40)); // calo = 180 - 140
});
it('hrr: storico f_hrr_1min_bpm (gia un calo, ancora letto)', () => {
const m = new Map([['f_hrr_1min_bpm', 18]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(18));
});
it('flamingo: media dx/sx occhi aperti (manuale)', () => {
const m = new Map([['f_flamingo_oa_dx', 3], ['f_flamingo_oa_sx', 5]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBe(F.scoreFlamingo(4)); // media (3+5)/2 = 4
});
it('flamingo: storico f_flamingo_cadute (ancora letto)', () => {
const m = new Map([['f_flamingo_cadute', 6]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBe(F.scoreFlamingo(6));
});
it('back_scratch: media dx/sx (manuale)', () => {
const m = new Map([['f_back_scratch_dx_cm', -8], ['f_back_scratch_sx_cm', -4]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBe(F.scoreBackScratch(-6, 45, 'F')); // media (-8-4)/2 = -6
});
it('back_scratch: storico f_back_scratch_cm (ancora letto)', () => {
const m = new Map([['f_back_scratch_cm', -10]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBe(F.scoreBackScratch(-10, 45, 'F'));
});
it('la cascata del vo2max per una sessione con solo f_vo2max resta uguale', () => {
const m = new Map([['f_vo2max', 45]]);
const r = sottoDominiFisici(m, 40, 'M');
expect(r.vo2max).toBe(F.scoreVo2max(45, 40, 'M'));
});
});
describe('un lato solo, senza storico, non da punteggio', () => {
it('handgrip solo destro: nessun punteggio (nessuna media possibile, nessuno storico)', () => {
const m = new Map([['f_handgrip_dx_kg', 45]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBeUndefined();
});
it('flamingo occhi aperti solo sinistro: nessun punteggio', () => {
const m = new Map([['f_flamingo_oa_sx', 4]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBeUndefined();
});
it('back_scratch solo sinistro: nessun punteggio', () => {
const m = new Map([['f_back_scratch_sx_cm', -6]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBeUndefined();
});
});
describe('la regola dei gruppi sceglie per recenza, non solo per ordine di priorita', () => {
it('pushup a gennaio + panca a marzo: vale panca (piu recente)', () => {
const m = new Map([
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
]);
// recenza: posizione 0 = piu recente. Panca (marzo) piu recente del pushup (gennaio).
const recenza = new Map([
['f_panca_bil_carico_kg', 0], ['f_panca_bil_rip', 0], ['f_pushup_rip', 1],
]);
const r = sottoDominiFisici(m, 38, 'M', recenza);
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
});
it('panca a gennaio + pushup a marzo: vale pushup (piu recente) — il contrario del caso sopra', () => {
const m = new Map([
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
]);
const recenza = new Map([
['f_pushup_rip', 0], ['f_panca_bil_carico_kg', 1], ['f_panca_bil_rip', 1],
]);
const r = sottoDominiFisici(m, 38, 'M', recenza);
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
});
it('alternativa incompleta ma piu recente (sit-to-stand senza altezza) non entra: vale lo squat precedente', () => {
const m = new Map([
['f_sit_to_stand_rip', 14], // manca f_altezza_cm: bmi non calcolabile, alternativa incompleta
['f_squat_carico_kg', 100], ['f_squat_rip', 5], ['f_peso_kg', 80],
]);
// sit-to-stand risulterebbe il piu recente SE fosse completo — ma non lo e', quindi
// resta escluso dal gruppo a prescindere dalla recenza (regola: solo le alternative
// complete entrano).
const recenza = new Map([
['f_sit_to_stand_rip', 0], ['f_squat_carico_kg', 1], ['f_squat_rip', 1],
]);
const r = sottoDominiFisici(m, 65, 'M', recenza);
expect(r.arti_inferiori).toBe(F.scoreSollevamentoSuPeso(100, 80, 5, F.TIERS_SQUAT_M, F.TIERS_SQUAT_F, 'M'));
});
});
describe('i test "solo registrati" (nessuna tabella nel manuale) non producono chiavi', () => {
it('panca manubri, trazioni, plank ginocchia, 6MWT, flamingo occhi chiusi: nessuna chiave nel risultato', () => {
const m = new Map([
['f_panca_man_carico_kg', 12], ['f_panca_man_rip', 10],
['f_trazioni_rip', 8],
['f_plank_ginocchia_sec', 90],
['f_6mwt_m', 550],
['f_flamingo_oc_dx', 10], ['f_flamingo_oc_sx', 12],
['f_mutt_fc_2min_bpm', 110],
['f_fc_riposo_bpm', 60],
]);
const r = sottoDominiFisici(m, 38, 'M');
// nessuna di queste misure alimenta una funzione di scoring: il risultato resta {}
expect(r).toEqual({});
});
});
@@ -0,0 +1,277 @@
// 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';
import * as F from '../../src/lib/longevity/motore/test-fisici'; // per i valori attesi della regola dei gruppi (#13)
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('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 });
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('la regola dei gruppi (#13) su un DB vero: due sessioni, lo score usa il test piu recente', () => {
it('pushup a gennaio, panca a marzo: il check-up di marzo usa la panca', () => {
const db = dbPronto('ISL-0030', 'M');
const sGennaio = apriSessione(db, { client_code: 'ISL-0030', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sGennaio, 'manuale', [
{ test_id: 'f_peso_kg', valore_num: 80 },
{ test_id: 'f_pushup_rip', valore_num: 24 },
]);
const sMarzo = apriSessione(db, { client_code: 'ISL-0030', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sMarzo, 'manuale', [
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
]);
// misureValideAlla porta avanti ANCHE il pushup di gennaio (mai sovrascritto): al check-up
// di marzo il gruppo "spinta" vede entrambe le alternative complete, e vince la piu recente.
const sd = sottoDominiDellaSessione(db, sMarzo);
expect(sd.spinta).toBeCloseTo(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'), 5);
});
it('panca a gennaio, pushup a marzo: il check-up di marzo usa il pushup (il contrario del caso sopra)', () => {
const db = dbPronto('ISL-0031', 'M');
const sGennaio = apriSessione(db, { client_code: 'ISL-0031', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sGennaio, 'manuale', [
{ test_id: 'f_peso_kg', valore_num: 80 },
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
]);
const sMarzo = apriSessione(db, { client_code: 'ISL-0031', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sMarzo, 'manuale', [{ test_id: 'f_pushup_rip', valore_num: 24 }]);
const sd = sottoDominiDellaSessione(db, sMarzo);
expect(sd.spinta).toBeCloseTo(F.scorePushup(24, 38, 'M'), 5);
});
});
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);
});
});
+88 -1
View File
@@ -1,6 +1,6 @@
// tests/longevity/pagine.test.ts // tests/longevity/pagine.test.ts
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8'); const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
@@ -13,6 +13,19 @@ describe('il guscio della piattaforma', () => {
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
}); });
it('nessuna pagina Longevity e senza uscita (#10): il referto porta sempre a Piattaforme, e a Gestionale per lo staff', () => {
const l = leggi('src/layouts/Longevity.astro');
expect(l).toMatch(/href=["']\/piattaforme["']/);
expect(l).toMatch(/href=["']\/longevity\/gestionale["']/);
// "Il mio referto" nel gestionale va mostrato solo a chi ha davvero un fascicolo.
expect(l).toMatch(/haFascicolo &&/);
});
it('io.astro offre un uscita al gestionale per chi non ha un fascicolo (staff)', () => {
const src = leggi('src/pages/longevity/io.astro');
expect(src).toMatch(/href=["']\/longevity\/gestionale["']/);
});
it('il foglio di stile e globale e prefissato, non scoped', () => { it('il foglio di stile e globale e prefissato, non scoped', () => {
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true); expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
const css = leggi('src/styles/longevity.css'); const css = leggi('src/styles/longevity.css');
@@ -93,4 +106,78 @@ describe('il guscio della piattaforma', () => {
const src = readFileSync(p, 'utf8'); const src = readFileSync(p, 'utf8');
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
}); });
it('la pagina dell andamento non interroga il database e usa codicePerUtenteLoggato', () => {
// Rete secondaria alla stessa prova generica di referto.test.ts ("nessuna pagina
// interroga il database da sola", che scandisce TUTTE le .astro sotto longevity/):
// qui si controlla in piu' che il codice cliente arrivi dalla via autorizzata, non da
// un'altra query o da un parametro della richiesta.
const p = join(process.cwd(), 'src/pages/longevity/progressione.astro');
expect(existsSync(p)).toBe(true);
const src = readFileSync(p, 'utf8');
expect(src).not.toMatch(/\.prepare\(/);
expect(src).toMatch(/codicePerUtenteLoggato/);
// Stessa rete secondaria di anagrafica.test.ts: nessuna giunzione identity/longevity
// in un file fuori dai due moduli autorizzati.
expect(src).not.toContain('Identity');
});
});
describe('il gestionale del trainer (Unità 2b)', () => {
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',
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
];
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['"`]/);
});
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/);
}
});
}); });
@@ -0,0 +1,148 @@
// tests/longevity/progressione.test.ts
// L'andamento nel tempo: progressioneDi deve leggere SOLO ciò che è congelato in `score`
// (mai un ricalcolo, stessa regola di refertoDi) e marcare correttamente dove il modo di
// calcolare è cambiato (quest_version/model_version), senza farsi confondere da un
// check-up in mezzo a due questionari (quest_version NULL per un check-up).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaDalForm } from '../../src/lib/longevity/vista';
import { apriSessione } from '../../src/lib/longevity/misure';
import { calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
import { progressioneDi } from '../../src/lib/longevity/progressione';
const RISPOSTE_COMPLETE = {
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
};
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
/** Una sessione di check-up, calcolata e congelata come farebbe l'operatore in sala:
* nessuna domanda del questionario, quindi `quest_version` resta NULL (vedi db.ts). */
function aggiungiCheckup(db: ReturnType<typeof dbPronto>, data: string) {
const sessioneId = apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 35 });
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
return sessioneId;
}
/** Un questionario compilato per intero, via salvaDalForm — la stessa funzione che
* chiama l'endpoint reale (vedi salvataggio-e2e.test.ts): calcola e congela da sola. */
function aggiungiQuestionario(db: ReturnType<typeof dbPronto>, data: string) {
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data, eta: 35,
risposte: RISPOSTE_COMPLETE, consensoSanitario: 'Si', liberi: {},
});
if (!esito.ok) throw new Error(esito.errore);
return esito.sessioneId;
}
describe('progressioneDi: due questionari e un check-up', () => {
it('produce tre punti, in ordine cronologico', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const s2 = aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(3);
expect(punti.map((p) => p.sessioneId)).toEqual([s1, s2, s3]);
expect(punti.map((p) => p.data)).toEqual(['2026-01-10', '2026-02-10', '2026-03-10']);
expect(punti[0].tipo).toBe('questionario');
expect(punti[1].tipo).toBe('checkup');
});
it('i valori coincidono con leggiScore, mai un ricalcolo', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
const punteggi = leggiScore(db, s1);
for (const [nome, p] of Object.entries(punteggi.assi)) {
expect(punti[0].assi[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
for (const [nome, p] of Object.entries(punteggi.macro)) {
expect(punti[0].macro[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
expect(punti[0].fitnessAge).toBe(punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null);
});
it('un asse senza dati sufficienti resta null, mai un numero inventato', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
// 'Forza & Struttura' non ha nessuna misura fisica in questa sessione (solo questionario).
expect(punti[0].assi['Forza & Struttura']).toBeNull();
});
it('quest_version NULL per il check-up', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[1].questVersion).toBeNull();
});
it('nessun cambio di versione: cambioVersione resta null su ogni punto dopo il primo', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[0].cambioVersione).toBeNull(); // primo punto: mai un "prima" da confrontare
expect(punti[1].cambioVersione).toBeNull(); // il check-up in mezzo
expect(punti[2].cambioVersione).toBeNull(); // il secondo questionario, stessa versione
});
it('un check-up fra due questionari alla stessa versione non produce un cambio', () => {
// Stesso scenario del test sopra, isolato: qui si verifica in particolare che il
// check-up (quest_version NULL) non "nasconda" né inventi un cambio fra i due
// questionari v1.0 che lo circondano.
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
const puntoCheckup = punti.find((p) => p.tipo === 'checkup')!;
const puntoSecondoQuest = punti.find((p) => p.sessioneId === s3)!;
expect(puntoCheckup.cambioVersione).toBeNull();
expect(puntoSecondoQuest.cambioVersione).toBeNull();
});
it('un cambio di quest_version produce il testo del cambio', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
// Nessuna seconda versione delle domande esiste davvero oggi (QUEST_VERSION è una sola
// costante): si simula il cambio scrivendo direttamente la riga congelata, come farebbe
// un domani un vero v1.1 del questionario.
db.prepare(`UPDATE score SET quest_version = 'v1.1' WHERE sessione_id = ?`).run(s3);
const punti = progressioneDi(db, 'ISL-0001');
const punto = punti.find((p) => p.sessioneId === s3)!;
expect(punto.cambioVersione).toMatch(/questionario v1\.0 → v1\.1/);
});
it('una sessione senza righe in score resta esclusa dalla progressione', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
// Sessione aperta ma mai calcolata: nessuna riga in score (consenso non dato, o un
// calcolo mai lanciato — non un caso teorico, vedi il commento in salvaDalForm).
apriSessione(db, { client_code: 'ISL-0001', data: '2026-02-01', tipo: 'checkup', eta_alla_data: 35 });
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(1);
});
});
@@ -0,0 +1,51 @@
// Il registro dei test è dato di prodotto, non dato di un cliente: un database nuovo deve
// nascere con dentro le domande e i pesi. Fino al 03/09/2026 seedRegistro e seedPesi erano
// chiamati SOLO dai test, quindi ogni suite passava e il primo avvio vero apriva un
// questionario senza domande. Questo test guarda il database come lo trova l'applicazione.
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { campiDelQuestionario } from '../../src/lib/longevity/vista';
import { MODEL_VERSION, seedRegistro, seedPesi } from '../../src/lib/longevity/registro';
const OGGI = new Date().toISOString().slice(0, 10);
describe('un database longevity appena creato', () => {
it('ha il registro dei test già seminato, non vuoto', () => {
const db = createLongevityDb(':memory:');
const n = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
expect(n).toBeGreaterThan(0);
});
it('mostra le domande del questionario senza che nessuno semini a mano', () => {
const db = createLongevityDb(':memory:');
const campi = campiDelQuestionario(db, OGGI);
expect(campi.length).toBeGreaterThan(0);
// I tre sotto-domini che il questionario alimenta oggi: se un giorno il registro
// perdesse una famiglia di domande, il referto continuerebbe a "funzionare" con un
// asse in meno e nessuno se ne accorgerebbe.
const sottoDomini = new Set(campi.map((c) => c.sottoDominio));
expect(sottoDomini).toContain('questionario_sonno');
expect(sottoDomini).toContain('questionario_energia_stress');
expect(sottoDomini).toContain('questionario_lifestyle');
});
it('ha i pesi del modello corrente, per assi e macro', () => {
const db = createLongevityDb(':memory:');
const perLivello = db.prepare(
'SELECT livello, COUNT(*) AS n FROM pesi WHERE model_version = ? GROUP BY livello'
).all(MODEL_VERSION) as { livello: string; n: number }[];
const mappa = Object.fromEntries(perLivello.map((r) => [r.livello, r.n]));
expect(mappa.asse).toBeGreaterThan(0);
expect(mappa.macro).toBeGreaterThan(0);
expect(mappa.fitness_age).toBeGreaterThan(0);
});
it('rilanciare la creazione non duplica niente (INSERT OR REPLACE)', () => {
const db = createLongevityDb(':memory:');
const prima = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
// Stessa semina, stesso database: è ciò che accade a ogni riavvio del container.
seedRegistro(db); seedPesi(db, MODEL_VERSION);
const dopo = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
expect(dopo).toBe(prima);
});
});
@@ -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);
});
});
-2
View File
@@ -14,8 +14,6 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY scripts ./scripts COPY scripts ./scripts
# Contenuti della sezione Campus (generati con `npm run sync-campus` e committati)
COPY campus-content ./campus-content
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=4321 \ PORT=4321 \
+23
View File
@@ -206,3 +206,26 @@ Gli articoli del blog non viaggiano con il codice: per caricarli in produzione,
## Licenza ## Licenza
Proprietà di InsanityLab. Proprietà di InsanityLab.
## Deploy (dopo la separazione del 03/09/2026)
Il progetto compose si chiama **`insanitylab`** (`name:` esplicito nel compose), non piu'
`src` come quando la cartella era `/opt/docker/insanitylab/src`.
```
cd /opt/docker/insanitylab/src/apps/sito
docker compose build # il sito resta in piedi: non tocca il container
docker compose up -d # sostituisce il container
```
⚠️ **`.env` non e' in git** (e non deve esserlo): resta dov'e' ed e' l'unico file che un
`git merge` non porta con se'. Il 04/09/2026 lo spostamento sotto `apps/sito/` ha lasciato
`.env` alla vecchia radice, l'`up` e' fallito **dopo** che il `down` aveva gia' tolto il
container, e il sito e' rimasto giu' **9,7 secondi**. Prima di ogni swap:
```
docker compose config >/dev/null # legge env_file e fallisce SUBITO se manca qualcosa
```
⚠️ Un `docker run` sull'immagine **non** e' una prova sufficiente del deploy: non legge
`env_file`, quindi passa anche quando il compose fallirebbe.
+6 -1
View File
@@ -32,7 +32,12 @@ services:
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321 - traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex # Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`) # ⚠️ `insanitylab.tielogic.xyz` tolto dal DNS da Adriano il 03/09/2026: il record non
# esiste piu'. Lasciarlo qui non e' inerte — il router chiede un certificato per i
# nomi della sua regola, e una sfida ACME su un nome che non risolve fallisce a ogni
# tentativo, che e' esattamente cio' che Let's Encrypt limita. Il vecchio dominio ha
# smesso di essere raggiungibile: da qui in poi vale solo il redirect di `www`.
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`)
- traefik.http.routers.insanitylab-redir.tls=true - traefik.http.routers.insanitylab-redir.tls=true
- traefik.http.routers.insanitylab-redir.entrypoints=websecure - traefik.http.routers.insanitylab-redir.entrypoints=websecure
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
@@ -487,6 +487,31 @@ parte.
--- ---
## 13. Il gestionale in produzione (26/09)
Costruito e messo in linea il 26/09/2026 su `piattaforme.tielogic.xyz` (issue
`gitea:Adriano/InsanityLab-web` #1 #2 #5 #7 #8 #9 #10, merge in `main` 12b2e91 e 8f95a38).
Chiude o supera diversi punti delle §11 e §12:
| Punto | Com'è stato chiuso |
|---|---|
| §12.1 conferma delle misure fuori range | colonne `verifica` (`confermata`/`scartata`), `verificata_da`, `verificata_at` su `misure`, aggiunte da `migra()` sui database esistenti; il trainer decide dalla scheda cliente; una conferma ricalcola quella sessione e le successive (#7) |
| §12.6 e 12-bis, test fisici irraggiungibili | 24 test `f_` nel registro e `motore/fisici.ts` (`sottoDominiFisici(misure, eta, sesso)`), stessa mappatura della funzione dimostrativa dell'oracolo; fuori perimetro spirometria, milfit, 2 km walk, sit-to-stand, trazione isometrica (#1) |
| §8 vista trainer | `/longevity/gestionale`: clienti, nuovo cliente, scheda, check-up, accesso del cliente al referto (#2 #8) |
| §6 progressione e cambio di versione | `/longevity/progressione`, con il cambio di `model_version` segnato sul grafico (#5) |
| §11.1 consenso art. 9 | firmato su carta prima dell'accesso, fuori dalla piattaforma: decisione di Adriano, 26/09 (#6) |
**Una regola nuova, non prevista dalla §7:** check-up e questionario sono sessioni distinte. Il
calcolo di una sessione usa, per ogni test, la **misura valida più recente del cliente fino alla
data della sessione** (`misureValideAlla`); «valida» vuol dire dentro il range oppure confermata.
Oggi una misura non scade (`FINESTRA_GIORNI = null`).
🔲 **Ancora aperti, di Nicola:** la finestra di validità delle misure; il medico supervisore
(§11.2); i file reali di Wellness Tower e dinamometro per gli import (#3); la definizione dei
percentili interni (#4); le domande di 12-bis su `hrv` e sul plank.
---
## 12. Decisioni prese, e da chi ## 12. Decisioni prese, e da chi
| Decisione | Chi | Quando | | Decisione | Chi | Quando |
@@ -502,3 +527,6 @@ parte.
| Registro di misure invece di colonne fisse | Adriano | 21/08 | | Registro di misure invece di colonne fisse | Adriano | 21/08 |
| Il progetto è unico: un framework sui dati del cliente | Adriano | 21/08 | | Il progetto è unico: un framework sui dati del cliente | Adriano | 21/08 |
| Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 | | Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 |
| Piattaforme separate dal sito, su `piattaforme.tielogic.xyz` | Adriano | 03/09 |
| Gestionale trainer in produzione | Adriano | 26/09 |
| Consenso ai dati sanitari su carta, nessun registro in piattaforma | Adriano | 26/09 |
+4 -1
View File
@@ -12,7 +12,10 @@ const path = Astro.url.pathname;
// ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo // ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo
// sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che // sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta. // esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
const AREA_RISERVATA = 'https://apps.insanitylab.it'; // ⚠️ L'indirizzo vero, oggi: apps.insanitylab.it NON esiste (manca il record su Aruba) e
// puntarci scriverebbe nel menu del sito un link morto. Quando quel record ci sara', qui
// si cambia una riga — ed e' l'unico posto del sito che nomina l'area riservata.
const AREA_RISERVATA = 'https://piattaforme.tielogic.xyz';
const areaHref = user ? landingFor(user.role) : '/admin'; const areaHref = user ? landingFor(user.role) : '/admin';
--- ---
<header class="hdr" id="site-header"> <header class="hdr" id="site-header">
+96
View File
@@ -503,6 +503,102 @@ export const contentSeed: SeedEntry[] = [
T('bundle.faq.4.q', 'Il pagamento è sicuro?'), T('bundle.faq.4.q', 'Il pagamento è sicuro?'),
H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'), H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
// --- Promo settembre (edizione `settembre-2026`, 5 → 19 settembre) ---
// Testi ripresi dall'edizione di agosto, che il cliente ha già approvato: cambia solo ciò che
// le date o le nuove creative rendono falso. I prezzi e le voci degli elenchi vengono dalle
// sei creative consegnate il 03/09.
T('settembre.hero.badge', 'insanity.lab · bologna savena'),
T('settembre.hero.title1', 'Allenati con metodo.'),
T('settembre.hero.accent', 'Scegli come'),
T('settembre.hero.title2', 'vuoi farlo.'),
H('settembre.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('settembre.hero.cta', 'Le nostre proposte', []),
T('settembre.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
T('settembre.launch.title', 'Il nostro metodo nasce per durare.'),
T('settembre.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('settembre.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte — e una valutazione singola, per chi vuole partire dai propri numeri."),
T('settembre.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
T('settembre.buy-label', 'Acquista', []),
T('settembre.card.1.eyebrow', 'Personal training', []),
T('settembre.card.1.title', 'One to One'),
T('settembre.card.1.price', '250 €', []),
T('settembre.card.1.unit', '5 incontri', []),
T('settembre.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.2.eyebrow', 'Personal training', []),
T('settembre.card.2.title', 'Personalized Training Group'),
T('settembre.card.2.price', '150 €', []),
T('settembre.card.2.unit', '5 incontri', []),
T('settembre.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.3.eyebrow', 'Pilates class', []),
T('settembre.card.3.title', 'Reformer Class'),
T('settembre.card.3.price', '130 €', []),
T('settembre.card.3.unit', '5 incontri', []),
T('settembre.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('settembre.card.3.feature.2', '40 minuti, massimo 5 utenti'),
T('settembre.card.3.feature.3', 'Lavoro a basso impatto sulle articolazioni'),
T('settembre.card.4.eyebrow', 'Holistic class', []),
T('settembre.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('settembre.card.4.price', '80 €', []),
T('settembre.card.4.unit', '5 incontri', []),
T('settembre.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('settembre.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('settembre.card.4.feature.3', 'Adatto a tutti i livelli'),
// Il Check-up avanzato non è un percorso: si compra una volta e si fa in un appuntamento.
// Per questo l'unità dice "1 valutazione" e non "5 incontri".
T('settembre.card.5.eyebrow', 'Valutazione', []),
T('settembre.card.5.title', 'Check-up avanzato'),
T('settembre.card.5.price', '70 €', []),
T('settembre.card.5.unit', '1 valutazione', []),
T('settembre.card.5.feature.1', 'Analisi completa di composizione corporea, e analisi HRV'),
T('settembre.card.5.feature.2', 'Spirometria e scansione posturale 3D'),
T('settembre.card.5.feature.3', 'Report dettagliato e consigli personalizzati'),
// La deadline è lo stesso istante in cui finisce l'edizione in `promo-edizioni.ts`: un test
// lo verifica, perché un countdown che scade a un'ora diversa dallo scambio fa contraddire
// il sito.
T('settembre.countdown.label', 'La promo scade tra', []),
T('settembre.countdown.deadline', '2026-09-19T23:59:59+02:00', []),
T('settembre.countdown.unit.days', 'giorni', []),
T('settembre.countdown.unit.hours', 'ore', []),
T('settembre.countdown.unit.minutes', 'minuti', []),
T('settembre.countdown.unit.seconds', 'secondi', []),
T('settembre.validity-note', 'Promo valida dal 5 al 19 settembre 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
T('settembre.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('settembre.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
H('settembre.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona quello che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('settembre.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
H('settembre.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
// Ad agosto qui c'era scritto "gli orari saranno pubblicati entro Settembre": adesso è
// settembre e il calendario esiste, quindi la frase andava riscritta o diventava falsa.
T('settembre.faq.2.q', 'Come funziona il percorso Reformer Class o Holistic Class?'),
H('settembre.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class li trovi nel <span class="hl">calendario</span>, che puoi consultare e scaricare in PDF dalla pagina <span class="hl">Servizi</span> del sito. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('settembre.faq.7.q', "Che cos'è il Check-up avanzato?"),
H('settembre.faq.7.a', 'È una <span class="hl">valutazione singola</span>, non un percorso a incontri: analisi completa della composizione corporea e analisi HRV, spirometria e scansione posturale 3D. Al termine ricevi un report dettagliato con i consigli che ne derivano. Puoi farlo da solo, oppure come punto di partenza prima di scegliere un percorso. Per fissare l\'appuntamento scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span>.'),
T('settembre.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('settembre.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer Class</span> o <span class="hl">Pilates Mat, Mobility, Yoga</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno. Il <span class="hl">Check-up avanzato</span> è a sé: è una valutazione singola e non un pacchetto di incontri.'),
T('settembre.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('settembre.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('settembre.faq.5.q', 'Il pagamento è sicuro?'),
H('settembre.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
T('settembre.faq.6.q', 'Posso chiedere un rimborso?'),
H('settembre.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
]; ];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+35 -7
View File
@@ -20,6 +20,9 @@ export interface CardSpec {
featured?: boolean; featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */ /** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean; unit?: boolean;
/** Prezzo pieno barrato accanto a quello promo. Senza, la card mostra il solo prezzo di
* vendita: un listino barrato non dichiarato dal cliente sarebbe un numero inventato. */
priceOld?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */ /** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean; question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */ /** Paragrafo descrittivo sotto l'elenco. */
@@ -52,10 +55,10 @@ export const edizioni: Edizione[] = [
to: '2026-08-19T23:59:59+02:00', to: '2026-08-19T23:59:59+02:00',
launch: true, launch: true,
cards: [ cards: [
{ id: 1, features: 4, unit: true }, { id: 1, features: 4, unit: true, priceOld: true },
{ id: 2, features: 4, unit: true, featured: true }, { id: 2, features: 4, unit: true, priceOld: true, featured: true },
{ id: 3, features: 3, unit: true }, { id: 3, features: 3, unit: true, priceOld: true },
{ id: 4, features: 3, unit: true }, { id: 4, features: 3, unit: true, priceOld: true },
], ],
faq: [0, 1, 2, 3, 4, 5, 6], faq: [0, 1, 2, 3, 4, 5, 6],
}, },
@@ -67,14 +70,39 @@ export const edizioni: Edizione[] = [
from: '2026-08-20T00:00:00+02:00', from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00', to: '2026-09-04T23:59:59+02:00',
cards: [ cards: [
{ id: 1, features: 3, question: true, desc: true, note: true }, { id: 1, features: 3, question: true, desc: true, note: true, priceOld: true },
{ id: 2, features: 2, question: true, desc: true, note: true }, { id: 2, features: 2, question: true, desc: true, note: true, priceOld: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli // Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli
// dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato. // dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true }, { id: 3, features: 3, question: true, desc: true, note: true, priceOld: true },
], ],
faq: [0, 1, 2, 3, 4], faq: [0, 1, 2, 3, 4],
}, },
{
// Promo di settembre: subentra ai bundle il 5 e chiude il 19, il giorno dell'inaugurazione.
// Torna la forma dei pacchetti da 5 incontri, con in più il Check-up avanzato, che è una
// valutazione singola e non un percorso — per questo ha "1 valutazione" al posto di
// "5 incontri".
// Nessun prezzo barrato: le creative del cliente danno il solo prezzo promo, e un listino
// pieno ripreso dall'edizione di agosto sarebbe un numero che nessuno ha confermato.
id: 'settembre-2026',
prefix: 'settembre',
from: '2026-09-05T00:00:00+02:00',
to: '2026-09-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
// Nessuna card "in evidenza": cinque proposte per esigenze diverse, non cinque livelli
// dello stesso prodotto.
{ id: 5, features: 3, unit: true },
],
// La 7 è la domanda nuova sul Check-up: va letta dopo le altre due "come funziona", non
// in fondo dopo il rimborso, quindi l'ordine di comparsa non segue la numerazione.
faq: [0, 1, 2, 7, 3, 4, 5, 6],
},
]; ];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */ /** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
@@ -38,7 +38,7 @@ const pomeriggio: Gruppo[] = [
--- ---
<Base <Base
title="In-Sanity Hour — 19 settembre 2026" title="In-Sanity Hour — 19 settembre 2026"
description="L'inaugurazione di InsanityLab: venerdì 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti." description="L'inaugurazione di InsanityLab: sabato 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
> >
<section class="ev-hero"> <section class="ev-hero">
<div class="ev-hero__inner" data-reveal> <div class="ev-hero__inner" data-reveal>
+27 -1
View File
@@ -114,7 +114,7 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />} {c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
<div class="price-row"> <div class="price-row">
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" /> <T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
<T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" /> {c.priceOld && <T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />}
</div> </div>
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />} {c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
<ul> <ul>
@@ -296,6 +296,16 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); } .lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
.lp .cards-3 .card { display: flex; flex-direction: column; } .lp .cards-3 .card { display: flex; flex-direction: column; }
.lp .cards-3 .card .buy { margin-top: auto; } .lp .cards-3 .card .buy { margin-top: auto; }
/* CARD DELLE EDIZIONI A CINQUE (promo settembre). Cinque non si divide in una griglia a due
o a tre colonne senza lasciare un buco in fondo: la griglia è a sei colonne e ogni card ne
occupa due, così le ultime due si spostano al centro invece di restare allineate a sinistra
con un vuoto accanto. */
.lp .cards-5 { grid-template-columns: repeat(6, 1fr); }
.lp .cards-5 .card { grid-column: span 2; display: flex; flex-direction: column; }
.lp .cards-5 .card .buy { margin-top: auto; }
.lp .cards-5 .card:nth-child(4) { grid-column: 2 / span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 4 / span 2; }
.lp .card .question { .lp .card .question {
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
} }
@@ -343,9 +353,25 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
.lp .faq-block.is-open .faq-a { display: block; } .lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; } .lp .faq-a .hl { color: #8A6E3F; }
/* Sotto i 1000px tre colonne diventano troppo strette per un prezzo e un elenco: le cinque
card passano a due per riga (griglia a quattro, span due), con la quinta al centro.
Nessun `min-width` a chiudere la fascia in basso: a viewport frazionari (uno zoom del
browser dà larghezze come 700,5px) fra 700 e 701 non varrebbe nessuna delle due regole e
le card tornerebbero a sei colonne. Ci pensa il blocco ≤700px, che viene dopo e a pari
specificità vince. */
@media (max-width: 1000px) {
.lp .cards-5 { grid-template-columns: repeat(4, 1fr); }
.lp .cards-5 .card:nth-child(4) { grid-column: span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 2 / span 2; }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; } .lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
.lp .cards { grid-template-columns: 1fr; } .lp .cards { grid-template-columns: 1fr; }
/* A una colonna gli span e le collocazioni delle ultime due card lascerebbero righe vuote. */
.lp .cards-5 .card,
.lp .cards-5 .card:nth-child(4),
.lp .cards-5 .card:nth-child(5) { grid-column: auto; }
.lp .countdown .cd-boxes { gap: 6px; } .lp .countdown .cd-boxes { gap: 6px; }
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; } .lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
.lp .countdown .cd-num { font-size: 22px; } .lp .countdown .cd-num { font-size: 22px; }
@@ -0,0 +1,24 @@
// Il sito nomina l'area riservata in UN posto solo (l'header). Dopo la separazione quel
// link esce dal dominio, quindi non lo protegge piu' nessuna rotta interna: se punta a un
// host che non esiste, il menu del sito porta a un errore di DNS e nessun test se ne
// accorge. Qui si verifica che sia l'host in uso, non quello previsto per il futuro.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const header = readFileSync(join(process.cwd(), 'src/components/Header.astro'), 'utf8');
describe("il link all'area riservata", () => {
it('punta a un host che esiste davvero oggi', () => {
expect(header).toContain('https://piattaforme.tielogic.xyz');
// apps.insanitylab.it e' l'indirizzo definitivo, ma il record A su Aruba non c'e'
// ancora: finche' non c'e', qui sarebbe un link morto dentro al menu del sito.
expect(header).not.toContain('https://apps.insanitylab.it');
});
it('non prova piu a costruire un menu delle piattaforme dentro al sito', () => {
// Le tre piattaforme non vivono piu' qui: un menu che le elenca sarebbe un elenco di
// rotte inesistenti, servite dal sito come 404.
expect(header).not.toContain("data/piattaforme");
});
});
+8 -3
View File
@@ -8,11 +8,12 @@ describe('edizioneAttiva', () => {
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => { it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026'); expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura'); expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))?.id).toBe('settembre-2026');
}); });
it('fuori da ogni finestra non c\'è edizione attiva', () => { it('fuori da ogni finestra non c\'è edizione attiva', () => {
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-09-25T12:00:00+02:00'))).toBeNull();
}); });
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora // Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
@@ -22,13 +23,16 @@ describe('edizioneAttiva', () => {
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026'); expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura'); expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura'); expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))?.id).toBe('settembre-2026');
expect(edizioneAttiva(d('2026-09-19T23:59:59+02:00'))?.id).toBe('settembre-2026');
expect(edizioneAttiva(d('2026-09-20T00:00:00+02:00'))).toBeNull();
}); });
it('l\'ordine dell\'elenco non cambia il risultato', () => { it('l\'ordine dell\'elenco non cambia il risultato', () => {
const rovescio = [...edizioni].reverse(); const rovescio = [...edizioni].reverse();
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026'); expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura'); expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'), rovescio)?.id).toBe('settembre-2026');
}); });
}); });
@@ -80,7 +84,8 @@ describe('i testi di ogni edizione esistono', () => {
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds']; 'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text'); if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
for (const c of e.cards) { for (const c of e.cards) {
base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`, `card.${c.id}.price-old`); base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`);
if (c.priceOld) base.push(`card.${c.id}.price-old`);
if (c.featured) base.push(`card.${c.id}.badge`); if (c.featured) base.push(`card.${c.id}.badge`);
if (c.unit) base.push(`card.${c.id}.unit`); if (c.unit) base.push(`card.${c.id}.unit`);
if (c.question) base.push(`card.${c.id}.question`); if (c.question) base.push(`card.${c.id}.question`);