# IN-SANITY LONGEVITY PROJECT — piattaforma dati cliente **Data:** 2026-08-21 **Stato:** design approvato, implementazione non iniziata **Branch:** `feat/longevity` — `main` non viene toccato, nessun deploy **Committente:** In-Sanity Lab (Nicola Antonelli) — sviluppo Tielogic --- ## 1. Cos'è, e come si chiama Il nome esiste già e viene dal cliente: **IN-SANITY LONGEVITY PROJECT** è l'iniziativa — *«servizio premium basato su dati, con monitoraggio longitudinale»* — e **IN-SANITY LONGEVITY SCORE** è il sistema di punteggio che ne è il prodotto di punta. Questa piattaforma è l'implementazione software del primo. Nel codice il nome tecnico è **`longevity`**. Non è una raccolta di applicazioni separate: è **un fascicolo del cliente**. Questionario, check-up in sala, Wellness Tower e VALD non sono quattro sottosistemi, sono **quattro sorgenti che scrivono nello stesso posto**; dashboard, vista trainer e punteggi sono **viste e funzioni derivate** di quel posto. È la differenza fra progettare per dati e progettare per schermate, e determina tutto quello che segue. ## 2. Perimetro **Dentro:** anagrafica cliente, questionario in pagina, registrazione delle misure da tutte le sorgenti, motore di calcolo degli score, dashboard cliente, vista trainer, export per le statistiche. **Fuori, e per ragioni diverse:** - **I percentili interni ISL** — non per scelta: la documentazione del cliente dichiara che sotto 15 campioni sono inutilizzabili e sotto 30 provvisori. Vanno costruiti quando i dati esistono, non prima. - **Gli 8 indicatori posturali** della Wellness Tower — esistono solo nel PDF stampato, non nell'export a 55 colonne. Il cliente li ha già esclusi dallo score. - **Il blocco Trimestre / 2 Mesocicli** — dichiarato aperto dal cliente stesso e ancora in analisi da parte sua. - **Il deploy** — la piattaforma si sviluppa e si prova in locale. Quando mettere qualcosa in produzione è una decisione separata, che non appartiene a questo documento. ## 3. Collocazione La piattaforma vive **dentro l'applicazione Astro esistente**, come già fanno Biohacking Campus e Stress Index. Non è un secondo servizio, non è un secondo container. ``` src/pages/longevity/ rotte visibili src/pages/api/longevity/ endpoint src/lib/longevity/ modello dati, motore, import tests/longevity/ test ``` Il motivo per cui non è un progetto separato è che l'autenticazione, le sessioni e l'anagrafica utenti esistono già qui e funzionano in produzione: rifarle altrove significherebbe mantenerne due. ⚠️ **Il calcolo è in TypeScript, non in Python.** Il motore fornito dal cliente (`isl_scoring_engine.py`, 537 righe) è aritmetica pura — interpolazioni lineari, tabelle di soglie, medie pesate — e importa solo `dataclasses` e `typing`: nessuna libreria numerica. Portarlo in TypeScript costa poco e evita un secondo runtime dentro un'immagine `node:22-slim` a processo unico. La stessa scelta vale per il prototipo del questionario, che calcola già in JavaScript: tenere le formule in due linguaggi è il modo silenzioso di farle divergere. ## 4. I tre database SQLite, tre file distinti, aperti da connessioni distinte. | File | Contiene | Non contiene | |---|---|---| | `insanitylab.db` | il sito: articoli, contenuti, **utenti e sessioni** | niente di clinico | | `identity.db` | `client_code` ↔ `user_id`, nome, cognome, nascita, recapiti | nessuna misura | | `longevity.db` | misure, questionari, punteggi, registro test | **nessun nome, mai** | La proprietà che ne discende è la richiesta esplicita del cliente del 21/08: *«la mappatura codice↔nome va tenuta in un posto separato e più ristretto rispetto all'export usato per le statistiche, così anche chi ha accesso ai dati clinici non può risalire all'identità»*. Qui l'export per le statistiche **è** `longevity.db`: già pseudonimizzato per costruzione, senza una funzione di anonimizzazione che qualcuno debba ricordarsi di chiamare. **Vincolo di implementazione, verificabile con un test:** esiste **un solo modulo** che apre insieme `identity` e `longevity`. Nessun altro punto del codice importa entrambe le connessioni. ## 5. Modello dati ```sql -- ============ identity.db ============ CREATE TABLE clienti ( client_code TEXT PRIMARY KEY, -- es. 'ISL-0007' user_id INTEGER, -- users.id in insanitylab.db (nessuna FK: file diverso) nome TEXT NOT NULL, cognome TEXT NOT NULL, data_nascita TEXT, -- ISO; resta QUI, non passa in longevity email TEXT, telefono TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX idx_clienti_user ON clienti (user_id); -- ============ longevity.db ============ 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 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, -- l'età serve al motore; la data di nascita no operatore TEXT, note TEXT, quest_version TEXT, -- solo per tipo='questionario' created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_sessioni_cliente ON sessioni (client_code, data DESC); 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, -- per le risposte non numeriche unita TEXT, fonte TEXT NOT NULL CHECK (fonte IN ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), fuori_range INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_misure_cliente_test ON misure (client_code, test_id); CREATE INDEX idx_misure_sessione ON misure (sessione_id); CREATE TABLE registro_test ( test_id TEXT PRIMARY KEY, -- 'handgrip_dx', 'plank', 'q_sonno_ore' etichetta TEXT NOT NULL, unita TEXT, tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','decstep','incstep' params TEXT, -- JSON dei parametri della curva asse TEXT, -- uno dei 7 assi; NULL = non entra nello score sotto_dominio TEXT, -- a QUALE sotto-dominio contribuisce; il peso sta in `pesi` range_min REAL, -- atteso, non vincolante: fuori range si marca range_max REAL, attivo_da TEXT NOT NULL, attivo_a TEXT, -- NULL = ancora attivo note TEXT ); CREATE TABLE pesi ( model_version TEXT NOT NULL, livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')), contenitore TEXT NOT NULL, -- l'asse, il macro-score, o 'fitness_age' elemento TEXT NOT NULL, -- il sotto-dominio, o l'asse, o la voce isolata peso REAL NOT NULL, PRIMARY KEY (model_version, livello, contenitore, elemento) ); CREATE TABLE profilo_note ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_code TEXT NOT NULL REFERENCES soggetti(client_code), sessione_id INTEGER REFERENCES sessioni(id), campo_id TEXT NOT NULL, -- 'farmaci', 'problematiche_attuali', 'obiettivi' testo TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE score ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_code TEXT NOT NULL REFERENCES soggetti(client_code), sessione_id INTEGER NOT NULL REFERENCES sessioni(id), tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), nome TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ... valore REAL, -- NULL quando stato='insufficiente' copertura REAL NOT NULL, stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), quest_version TEXT, model_version TEXT NOT NULL, calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_score_cliente ON score (client_code, calcolato_at DESC); ``` ### Perché l'età e non la data di nascita Il motore ha bisogno dell'età — `score_handgrip(kg, eta, sex)`, `score_vo2max(vo2max, eta, sex)`, le bande per decade — ma gli serve **un numero al momento della misura**, non una data. Sesso ed età non identificano nessuno; una data di nascita esatta, combinata con pochi altri campi, quasi sì. Costa una colonna e toglie un quasi-identificatore dal database clinico. ### Perché il peso non sta sulla riga del test Un sotto-dominio può essere coperto da **test alternativi**: la *spinta* si misura con i push-up o con il 5RM di panca, a seconda del livello del cliente, e il peso — 20% dentro *Forza & Struttura* — è del sotto-dominio, non del singolo test. Se il peso stesse sulla riga del test si ripeterebbe su ogni alternativa, e prima o poi due copie divergerebbero. I pesi stanno quindi in una tabella loro, **con la `model_version` nella chiave**. È ciò che rende reale il congelamento del §6: uno score del passato si può rileggere con i pesi che erano in vigore quando è stato calcolato, invece che con quelli di oggi. Senza questo, `model_version` sarebbe un'etichetta senza niente dietro. ⚠️ Nota per l'implementazione: nel motore Python `plank` compare in **due** assi — `core` in *Forza & Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20). È lo stesso doppio conteggio ammesso dal cliente per l'handgrip nella Fitness Age, ma quello del plank **non è dichiarato da nessuna parte**. Questo schema lo rappresenta senza problemi (due righe in `pesi`), però va segnalato al cliente invece che riprodotto in silenzio. ### Perché il registro dei test è dato e non codice Il cliente ha già i test in forma di registro: un foglio di 30 righe con fonte e stato per ciascuno. Qui diventa una tabella, e la conseguenza è che **il set dei test è mobile senza migrazioni**, cosa che è già successa tre volte in un mese: l'agilità è stata rimossa dallo score, la capacità vitale pure, e l'HRR è passato dalla finestra a 2' a quella a 1'. Con questo modello: disattivare un test è valorizzare `attivo_a`; cambiare la finestra dell'HRR è **un `test_id` nuovo che convive col vecchio**, e le misurazioni già fatte restano leggibili e restano marcate per quello che sono, invece di diventare confrontabili per sbaglio con le nuove. ## 6. Versioni e congelamento Decisione del cliente, 21/08: quando cambia una formula i punteggi storici si **congelano**, non si ricalcolano. La ragione è di prodotto ed è corretta — se lo score storico cambia, il cliente non distingue più il proprio miglioramento da un cambio di matematica, e il confronto nel tempo perde valore. L'implementazione ha due conseguenze: 1. **Gli score si salvano**, non si ricalcolano a ogni visualizzazione. La tabella `score` è la memoria di ciò che il cliente ha visto. 2. **Le misure grezze si salvano tutte**, quindi un ricalcolo con formule nuove resta sempre possibile in lettura, per analisi, senza sovrascrivere niente. **Due versioni, non una.** Ogni score porta `quest_version` (quali domande) e `model_version` (quali curve e quali pesi). Il cliente ne ha implementata una sola perché dal suo lato coincidevano, ma i pesi degli assi possono cambiare senza che cambi una domanda: con una versione sola un punteggio del passato non saprebbe dire quale delle due cose è cambiata. ⚠️ **Il congelamento sposta il problema del confronto, non lo elimina.** Due compilazioni con versioni diverse restano non confrontabili anche se nessuno le ha riscritte. Il grafico della progressione deve quindi **dichiarare visivamente il cambio di versione**, altrimenti il cliente vede una linea continua di numeri calcolati con matematiche diverse — che è l'equivoco che la decisione voleva evitare. ## 7. Flusso dei dati ### Ingresso: un solo varco ``` questionario in pagina ─┐ gestionale trainer ─┤ import file (WT, VALD) ─┼─→ registraMisure(sessione, misure[]) ─→ misure API (VALD Hub) ─┘ ↑ valida contro registro_test ``` `registraMisure` è l'unica funzione che scrive in `misure`. Valida ogni riga contro il registro: **un `test_id` sconosciuto viene rifiutato**, non scritto in silenzio. Un valore fuori dal range atteso viene invece **scritto e marcato** (`fuori_range = 1`), perché scartarlo perderebbe un dato vero e clamparlo lo falserebbe — vedi §9. ### Calcolo Il motore legge misure e registro, e produce gli score seguendo la cascata a quattro livelli del cliente: sotto-metrica → asse → macro-score → Fitness Age, con rinormalizzazione dei pesi disponibili a ogni livello e soglia di copertura al **40%**. Il tipo di ritorno impedisce la trappola presente nel motore Python, dove `aggregate()` restituisce il punteggio pieno **anche quando lo dichiara insufficiente**: ```ts type Punteggio = | { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number } // non c'è nessun valore da leggere ``` La regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa così impossibile da violare per distrazione: chi disegna il radar non ha il campo da cui prendere il numero. ### Uscita Dashboard cliente (radar a 7 assi, 3 macro-score, Fitness Age), vista trainer, ed export per le statistiche — che è il contenuto di `longevity.db`, senza passare da nessuna funzione di anonimizzazione. ## 8. Ruoli e accessi Il sito ha oggi `admin | superuser | user | piattaforme`, e nessuno di questi è il cliente finale né il trainer. Se ne aggiungono **due**: `cliente` e `trainer`. | Rotta | Chi entra | |---|---| | `/longevity/io` | `cliente` (solo il proprio fascicolo), `trainer`, `admin` | | `/longevity/gestionale` | `trainer`, `admin` | | `/api/longevity/**` | come sopra, verificato lato server | **Il cliente vede solo i propri dati, e non è una regola di interfaccia:** gli endpoint ricavano il `client_code` dalla sessione e **mai** dalla richiesta. Un client_code che arriva dal browser viene ignorato. ## 9. Errori e casi limite **Misura fuori dal range atteso: si registra e si marca.** Il caso è già successo — la capacità vitale di 5148 mL su un fondoscala dichiarato di 2000-3000 mL non era una prestazione eccezionale, era il range del fornitore sbagliato, e clamparla a 100 produceva un dato apparentemente ottimo. Le misure marcate finiscono in una lista che il trainer vede; non entrano nello score finché qualcuno non le conferma. **Scala non comparabile: si esclude dal registro, non si corregge nel codice.** L'agilità a 548 ms contro i 250 attesi dava 20/100 su Stabilità — falso, e la causa era il protocollo diverso. Un caso così si risolve disattivando il test nel registro, non aggiungendo un fattore di correzione da qualche parte. **Copertura sotto il 40%:** l'elemento è `insufficiente` e non ha un valore. **Test sconosciuto in ingresso:** errore esplicito, la sessione non viene scritta a metà. ## 10. Test Il test principale è il **verificatore contro il motore Python**: gli stessi ingressi devono produrre gli stessi numeri, e ogni curva va confrontata su tutto il suo dominio, non su un caso singolo. È l'unico modo per sapere che il porting non ha spostato niente. Poi: le regole di copertura e rinormalizzazione, il rifiuto dei test sconosciuti, la marcatura del fuori range, l'isolamento dei tre database (nessun modulo, a parte quello designato, apre insieme identity e longevity), e il fatto che un `client_code` proveniente dalla richiesta non venga mai onorato. ⚠️ **I dati reali di Donata e Nicola non entrano nelle fixture.** Sono dati sanitari di due persone identificabili, e le fixture stanno in git, dove restano per sempre e le legge chiunque abbia accesso al repository. I casi di prova si costruiscono sintetici; i referti veri restano dove sono, per il confronto a mano quando serve. ## 11. Punti aperti che il codice non risolve Questi non sono dettagli implementativi: sono decisioni che spettano al cliente, e finché non arrivano il codice si comporterà come qui dichiarato — non come capita. 1. **Base giuridica per i dati sanitari (art. 9 GDPR).** Il campo `consenso` del questionario riguarda le comunicazioni promozionali, non il trattamento di dati di salute, e la finalità statistica è ulteriore rispetto a quella di cura. Serve un consenso esplicito dedicato. **Non è un blocco allo sviluppo, è un blocco alla messa in produzione con clienti veri.** 2. **Chi è il medico supervisore**, nominato nella specifica del questionario. Se è un professionista sanitario, cambia la base giuridica utilizzabile (art. 9.2.h), in meglio. 3. **La regola dei buchi nel questionario.** Oggi esiste in due versioni: il prototipo annulla un sotto-dominio solo se non c'è **nessuna** risposta, il motore usa il 40%. La piattaforma userà **una soglia unica, dichiarata nel registro come parametro**, e in attesa della risposta assume il **40%**, coerente con la regola dei sette assi. 4. **Il cambio di versione sul grafico** della progressione (§6). 5. **Il registro delle versioni**: cosa cambia a ogni incremento di `quest_version` e `model_version`, altrimenti fra sei mesi «v1.3» non dirà niente a nessuno. 6. **Nomi dei Livelli 1/2/3** — SPARK/FLUX/APEX o PULSE/FLOW/PEAK, aperti da sessioni precedenti del cliente. Il livello è già una colonna del profilo. 7. **Export CSV/API di Stress Index**, l'unico canale la cui automazione non è confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale va inserito a mano. ## 12. Decisioni prese, e da chi | Decisione | Chi | Quando | |---|---|---| | Un record per compilazione + export Excel | Nicola | 21/08 | | Versione del questionario su ogni record | Nicola | 21/08 | | Formula cambiata → punteggi storici congelati | Nicola | 21/08 | | Export pseudonimizzato, mappatura separata e ristretta | Nicola | 21/08 | | Curva alcol 0-7 = 100 (soglia NIAAA/WHO), voluta | Nicola | 21/08 | | La piattaforma vive dentro il sito, area riservata | Adriano | 13/08 | | Ramo separato, nessun deploy | Adriano | 21/08 | | Tre database separati | 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 | | Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 |