# 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','x10_inv','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')), elemento 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-bis. Cosa ha scoperto la costruzione del motore (22/08) Il motore è stato costruito il 22/08 (ramo `feat/longevity`, 6 task, 308 test). Il porting è fedele: eseguendo la funzione dimostrativa del cliente sui due motori, i sette assi, i tre macro-score e la Fitness Age **combaciano numero per numero**. ### Due divergenze deliberate dall'oracolo, entrambe volute 1. **Un punteggio insufficiente non espone un valore.** Nell'originale la funzione di aggregazione restituisce il numero anche quando dichiara l'elemento insufficiente; qui il tipo non ha proprio il campo. È ciò che rende impossibile mostrare un numero dove i dati non bastano. 2. **La Fitness Age esclude gli assi insufficienti**, mentre l'oracolo li include comunque. ⚠️ **Cambia il numero, e parecchio**: sullo stesso profilo parziale, 25,0 contro 34,8 — quasi dieci anni. Chi confrontasse il nostro referto con il Python del cliente su un profilo incompleto concluderebbe che il porting è rotto. **Da dire a Nicola.** ### ⚠️ Le curve dei test fisici non sono collegate, e agganciarle non sarà «solo dati» Le venti curve dei test misurati sono portate e verificate, ma **irraggiungibili dal motore**: il vocabolario delle curve del registro non ha nomi per loro, e la funzione che applica una curva non può ricevere età e sesso, che quasi tutte richiedono. 📌 Questo contraddice la §5, dove il registro è presentato come dato che si estende senza toccare il codice. **È vero per il questionario, non per i test fisici**: agganciarli richiede di estendere il vocabolario e la firma. Il piano degli import deve saperlo, perché oggi crede di trovare la presa già montata. ### Il confine dove la barriera finisce Il tipo protegge il codice; la tabella `score` ha ora anche un vincolo che rifiuta un valore scritto sotto uno stato che lo nega. ⚠️ Ma quel vincolo entra solo nei database **creati da qui in avanti**: lo schema gira con `CREATE TABLE IF NOT EXISTS`, che su un file esistente non fa nulla. Oggi nessun database esiste ancora — diventa un problema il giorno del primo deploy, ed è quello il momento di ricordarsene. ### La storia dei punteggi si tiene Un ricalcolo **aggiunge** righe invece di sovrascriverle, coerentemente col congelamento del §6: sovrascrivere cancellerebbe proprio ciò che il congelamento protegge. La lettura passa da una funzione tipata che restituisce l'ultimo calcolo per sessione — la dashboard non deve inventarsi una query sua. ### 🔲 Una domanda per Nicola, sulla voce più pesante del modello `hrv` pesa **0,50 su due assi** — *Recupero* ed *Energia* — ed è la singola voce più pesante di tutto il modello. Nel motore è un dato solo, condiviso. Ma nella funzione dimostrativa dell'oracolo i due assi ricevono **numeri diversi sotto lo stesso nome** (68 e 65). O è la stessa misura riusata su due assi — come il plank, che Nicola ha già confermato — e i due numeri sono illustrativi; oppure sono due metriche distinte, e allora le stiamo fondendo per sbaglio. Diventa concreto appena si collega Stress Index, che è la sorgente di quel dato. --- ## 12. Cosa l'implementazione ha scoperto — da chiudere prima del motore Lo strato dati è stato costruito il 21/08 (ramo `feat/longevity`, 19 commit, 241 test). Costruirlo ha fatto emergere sei punti che questo documento non aveva previsto o su cui si contraddiceva. **Nessuno è bloccante per lo strato dati; tutti lo diventano per il motore di calcolo.** 1. **La conferma delle misure fuori range non ha dove stare.** La §9 dice che una misura marcata «non entra nello score finché qualcuno non la conferma», ma lo schema della §5 non prevede nessuna colonna che registri quella conferma. Le due sezioni si contraddicono: va aggiunta la colonna o riscritta la regola. 2. **`registraMisure` valida l'esistenza del test, non il valore.** Oggi accetta una misura senza nessun valore, un testo dove il registro dichiara un numero, e un `NaN` (che viene scritto come nullo e **non** marcato fuori range, perché ogni confronto con NaN è falso). Una riga vuota conta come «test presente» ai fini della copertura del 40%. 3. **`seedRegistro` rilanciato disfa il registro.** Usa `INSERT OR REPLACE` e riazzera `attivo_a` e le note: disattivare un test e poi rilanciare il seed lo riattiva. È esattamente l'opposto della promessa della §5, dove disattivare un test è valorizzare `attivo_a`. 4. **`MODEL_VERSION` non esiste.** `QUEST_VERSION` sta in `questionario.ts`; la gemella che versiona curve e pesi non è dichiarata da nessuna parte, e `seedPesi` la riceve come parametro che solo i test valorizzano. Finché non esiste, il congelamento del §6 ha metà del suo significato. 5. **La soglia del 40% è una costante di codice**, non un parametro nel registro come chiede la §11.3 — e nessuno la consuma ancora. 6. **Registro e pesi sono disallineati per costruzione.** Il registro contiene oggi i 20 campi del questionario; i pesi citano handgrip, vo2max, plank, flamingo — sotto-domini che nessun test ancora alimenta. Con i dati attuali sei assi su sette resterebbero permanentemente «insufficiente». È atteso, perché i test fisici arrivano col piano degli import, ma va saputo: il primo che lancia il motore lo legge come un difetto. ⚠️ **Una trappola già armata per il piano delle API.** Nelle regole di accesso, tutto ciò che sta sotto `/api/longevity/` e non sotto `/api/longevity/gestionale/` è raggiungibile da **ogni** cliente. Oggi è innocuo perché non esiste nessun endpoint, ma significa che la scelta dei nomi delle rotte è una questione di sicurezza, non di stile: un domani `/api/longevity/clienti` sarebbe l'elenco di tutti, aperto a tutti. ⚠️ **Due limiti noti dell'anagrafica**, accettati con cognizione: se fallisse anche la cancellazione compensativa su identity, l'errore diagnostico verrebbe mascherato e resterebbe un cliente senza pendant clinico; e i codici sono ordinati come stringhe, quindi oltre `ISL-9999` l'ordinamento sbaglia e un codice potrebbe essere riusato. 📌 **Da dire a Nicola, non ancora detto:** il plank pesa su due assi — `core` in *Forza & Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20). È lo stesso doppio conteggio che lui dichiara per l'handgrip nella Fitness Age, ma questo non è dichiarato da nessuna parte. --- ## 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 |