02d0224e5a
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.
402 lines
19 KiB
Markdown
402 lines
19 KiB
Markdown
# 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 |
|