From 02d0224e5a7942b97e6fd4b897ef97673ceb49c4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 18:41:06 +0200 Subject: [PATCH] docs: design della piattaforma IN-SANITY LONGEVITY PROJECT Fascicolo unico del cliente dentro il sito esistente, ramo separato e nessun deploy. Tre database: sito, identity (codice <-> nome) e longevity (misure, mai un nome) - cosi' l'export per le statistiche e' gia' pseudonimizzato per costruzione, come chiesto da Nicola il 21/08. Le misure sono un registro (una riga per valore) e i test sono dato, non codice: togliere l'agilita' o passare l'HRR da 2' a 1' non e' una migrazione. I pesi stanno in tabella versionata, cosi' il congelamento degli score storici ha davvero qualcosa dietro. Punteggi congelati con due versioni distinte (domande e modello di calcolo). Il tipo di ritorno del motore rende impossibile mostrare un numero pieno dove la regola vuole il tratteggio. --- docs/specs/2026-08-21-longevity-design.md | 401 ++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/specs/2026-08-21-longevity-design.md diff --git a/docs/specs/2026-08-21-longevity-design.md b/docs/specs/2026-08-21-longevity-design.md new file mode 100644 index 0000000..9dc5e9e --- /dev/null +++ b/docs/specs/2026-08-21-longevity-design.md @@ -0,0 +1,401 @@ +# 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 |