Separazione: le aree riservate diventano un'app propria (apps/platforms)

Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

Misura che ha guidato il taglio: le tre aree condividevano col sito
SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts.
Layout, librerie e dati erano gia' disgiunti.

Lato piattaforme:
- ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali
- canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non
  elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno)
- tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti
- /api risponde 401/403, non un redirect verso una pagina di login
- tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi

Lato sito:
- ROLES tornano i tre redazionali; via le regole e il login pubblico
- il login del pannello RESPINGE un account emigrato invece di aprirgli una
  sessione: senza, landingFor lo mandava su /admin, il middleware glielo
  negava, e si formava un ciclo di redirect che sembra un guasto del sito
- l'header non mostra piu' un menu delle piattaforme: un link che esce

Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi
i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra
in Campus e viene rimbalzato da Longevity, il cliente il contrario.

Non ancora in produzione: manca il record DNS di apps.insanitylab.it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:21:12 +00:00
parent 510d7ca4ec
commit 6301f521fd
383 changed files with 8477 additions and 350 deletions
@@ -0,0 +1,140 @@
/**
* La cascata a quattro livelli: dalle sotto-metriche normalizzate ai
* punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score
* (Performance/Energy/Recovery), fino alla Fitness Age.
*
* Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e
* `compute_fitness_age` dall'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4).
* Regola di copertura, uguale a ogni livello: se un elemento manca, il suo
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
*
* DUE DIVERGENZE VOLUTE dall'oracolo:
*
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
* dichiara insufficiente (la docstring dice il contrario di cio' che il
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
*
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
* del cliente su un profilo parziale concludera' che il porting e' rotto:
* non lo e', diverge di proposito qui.
*
* Tutto il resto e' fedele all'oracolo.
*/
import { COPERTURA_MINIMA } from '../db';
import { arrotonda1 } from './curve';
/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */
export type Punteggio =
| { stato: 'ok'; valore: number; copertura: number }
| { stato: 'insufficiente'; copertura: number };
/** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */
export type VocePesata = { punteggio: number | null; peso: number };
/**
* Media pesata delle voci disponibili, con i pesi delle voci mancanti
* ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la
* differenza di tipo descritta sopra.
*
* ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia
* usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella
* arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve
* solo al valore esposto nel campo `copertura` del risultato. Confrontare la
* copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura
* grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il
* numero pieno fasullo che il tipo `Punteggio` esiste per impedire.
*/
export function aggrega(voci: VocePesata[]): Punteggio {
const pesoTotale = voci.reduce((s, v) => s + v.peso, 0);
const disponibili = voci.filter((v) => v.punteggio !== null);
const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0);
const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0;
const copertura = arrotonda1(coperturaGrezza, 2);
if (disponibili.length === 0) {
return { stato: 'insufficiente', copertura: 0 };
}
if (coperturaGrezza < COPERTURA_MINIMA) {
return { stato: 'insufficiente', copertura };
}
const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0);
const valore = arrotonda1(sommaPesata / pesoDisponibile);
return { stato: 'ok', valore, copertura };
}
/**
* Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis`
* nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse,
* `punteggi` i valori 0-100 disponibili (assenti = mancanti).
*/
export function calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio {
const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: punteggi[chiave] ?? null,
peso,
}));
return aggrega(voci);
}
/**
* Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery).
* Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra
* col suo valore (che qui non esiste nemmeno), entra come mancante e il suo
* peso si ridistribuisce.
*/
export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio {
const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => {
const p = assi[asse];
return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso };
});
return aggrega(voci);
}
/**
* Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
* insufficiente non c'e' Fitness Age da mostrare.
*
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
* solo questo ultimo livello si ferma.
*/
export function calcolaFitnessAge(
etaAnagrafica: number | null,
pesi: Record<string, number>,
voci: Record<string, number | null>
): { fitnessAge: number | null; composito: Punteggio } {
const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: voci[chiave] ?? null,
peso,
}));
const composito = aggrega(vociPesate);
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
return { fitnessAge: null, composito };
}
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
return { fitnessAge, composito };
}
@@ -0,0 +1,178 @@
/**
* Curve di normalizzazione del questionario Longevity: portano una risposta
* grezza a un punteggio 0-100.
*
* `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau`
* e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`,
* `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e
* `score_direct_x10` dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario:
* curve generiche»). Non toccare quella logica: è verificata riga per riga
* contro `riferimento.json`.
*
* `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python:
* vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`)
* e non hanno un oracolo di riferimento — sono coperte solo dai test scritti
* a mano in `tests/longevity/motore-curve.test.ts`.
*/
/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */
export function clamp(x: number, lo = 0, hi = 100): number {
return Math.max(lo, Math.min(hi, x));
}
/**
* Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a
* [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1].
* Come `_lerp` nell'oracolo.
*/
export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number {
if (x1 === x0) return y0;
let t = (x - x0) / (x1 - x0);
t = Math.max(0, Math.min(1, t));
return y0 + t * (y1 - y0);
}
/**
* Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)`
* in Python: sui pareggi ESATTI (il valore binario del double è esattamente a
* metà, es. 86.25 per un decimale) arrotonda al pari (banker's rounding), non
* sempre in su come farebbe `Math.round`. Alcune curve a gradini producono
* pareggi veri — `lerp` che restituisce 86.25 esatto va a 86.2, non 86.3.
*
* Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un
* valore come 86.35 NON è un pareggio vero a un decimale (il double che gli
* sta dietro è 86.34999999999999431..., quindi Python arrotonda a 86.3, non
* a metà), ma `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 —
* un pareggio fasullo creato dalla moltiplicazione, non presente nel valore
* originale. Una prima versione con tolleranza ci cadeva (dava 86.4). Per
* evitarlo si scompone il double nella sua mantissa ed esponente esatti (bit
* IEEE 754, via `DataView`) e si confrontano `ax*10^decimali` e il pareggio
* con aritmetica razionale su `BigInt`, senza mai passare per una
* moltiplicazione in virgola mobile che potrebbe introdurre o cancellare un
* pareggio.
*
* Unica funzione di arrotondamento del motore: la usano `curve.ts`,
* `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due
* decimali, la copertura), per non avere due implementazioni che sui pareggi
* potrebbero divergere fra loro.
*/
export function arrotonda1(x: number, decimali = 1): number {
if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x;
const negativo = x < 0;
const ax = Math.abs(x);
const potenza = 10n ** BigInt(decimali);
// Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente.
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, ax);
const hi = view.getUint32(0);
const lo = view.getUint32(4);
const expBits = (hi >>> 20) & 0x7ff;
let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
let exponent: number;
if (expBits === 0) {
exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque
} else {
mantissa |= 1n << 52n; // bit implicito
exponent = expBits - 1075;
}
// ax*10^decimali = num/den, con num e den interi esatti: nessuna
// moltiplicazione in virgola mobile, quindi nessun pareggio fasullo
// introdotto qui.
let num: bigint;
let den: bigint;
if (exponent >= 0) {
num = mantissa * (potenza << BigInt(exponent));
den = 1n;
} else {
num = mantissa * potenza;
den = 1n << BigInt(-exponent);
}
const n = num / den; // floor(ax*10^decimali), esatto
const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto
let risultato: bigint;
if (resto2 < den) risultato = n;
else if (resto2 > den) risultato = n + 1n;
else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari
const valore = Number(risultato) / Number(potenza);
return negativo ? -valore : valore;
}
/**
* Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce
* verso low/high ai lati. Come `score_bell_curve` nell'oracolo.
*/
export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number {
if (v >= peakLow && v <= peakHigh) return 100;
if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100)));
return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10)));
}
/**
* Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing`
* nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale
* sopra 100 fuori da [best, worst].
*/
export function curvaDecrescente(v: number, best: number, worst: number): number {
return clamp(arrotonda1(lerp(v, best, worst, 100, 0)));
}
/**
* Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi.
* Come `score_increasing_plateau` nell'oracolo.
*/
export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number {
return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100)));
}
/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */
export function curvaDirettaX10(v: number): number {
return clamp(arrotonda1(v * 10));
}
/**
* Complemento della diretta: valore 0-10, punteggio decrescente. Assente
* nell'oracolo Python (è la quinta curva, quella che al motore del cliente
* manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`,
* cioè (10 - v) * 10.
*/
export function curvaDirettaX10Invertita(v: number): number {
return clamp(arrotonda1((10 - v) * 10));
}
/**
* Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per
* valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML
* del cliente (assente nell'oracolo Python).
*
* Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a
* tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le
* curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo
* gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che
* funge da "infinito").
*/
export function curvaGradini(
v: number,
steps: [number, number][],
zeroVal: number | undefined,
decrescente: boolean
): number {
if (v === 0 && zeroVal !== undefined) return zeroVal;
const ancoraggio: [number, number] = [0, decrescente ? 100 : 20];
const punti: [number, number][] = [ancoraggio, ...steps];
for (let i = 0; i < punti.length - 1; i++) {
const [x0, y0] = punti[i];
const [x1, y1] = punti[i + 1];
if (v <= x1) {
return clamp(arrotonda1(lerp(v, x0, x1, y0, y1)));
}
}
// v oltre l'ultimo gradino: resta sull'ultimo valore.
return punti[punti.length - 1][1];
}
@@ -0,0 +1,307 @@
/**
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
* test, o cambiare un parametro, resta una modifica ai dati.
*
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
* calcolo.
*
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
* panca), poi usa `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
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
* a mano su una colonna nullable.
*
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
* forma negativa; `applicaCurva` valida i parametri della curva prima di
* applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
* (punto aperto in §12).
*/
import type Database from 'better-sqlite3';
import {
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
} from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = {
low?: number; peakLow?: number; peakHigh?: number; high?: number;
best?: number; worst?: number; plateauStart?: number;
steps?: [number, number][]; zeroVal?: number;
};
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
function numeroValido(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x);
}
/**
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
* punteggio pieno, la peggior direzione di guasto possibile per un motore
* clinico.
*/
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
switch (curva) {
case 'bell':
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
case 'lin_dec':
return numeroValido(p.best) && numeroValido(p.worst);
case 'inc_plateau':
return numeroValido(p.worst) && numeroValido(p.plateauStart);
case 'x10':
case 'x10_inv':
return true; // nessun parametro richiesto
case 'decstep':
case 'incstep':
return Array.isArray(p.steps) && p.steps.length > 0 &&
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
default:
return false;
}
}
/**
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
*/
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
if (!voce.curva) return null;
const p = (voce.params ?? {}) as ParamsCurva;
if (!paramsValidi(voce.curva, p)) return null;
switch (voce.curva) {
case 'bell':
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
case 'lin_dec':
return curvaDecrescente(valore, p.best as number, p.worst as number);
case 'inc_plateau':
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
case 'x10':
return curvaDirettaX10(valore);
case 'x10_inv':
return curvaDirettaX10Invertita(valore);
case 'decstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
case 'incstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
default:
return null;
}
}
/**
* 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é
* e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la
* tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il
* composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che
* altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'.
*/
export type RisultatoSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
fitnessAge: number | null;
fitnessAgeCopertura: number;
modelVersion: string;
questVersion: string | null;
};
/**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
*/
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare(
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const attivi = testAttivi(db, sessione.data);
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
// 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.
const nomiAssi = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const assi: Record<string, Punteggio> = {};
for (const nomeAsse of nomiAssi) {
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
}
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
const nomiMacro = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const macro: Record<string, Punteggio> = {};
for (const nomeMacro of nomiMacro) {
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
}
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
// come nell'oracolo, che guarda quei due sotto-domini da soli.
const valoreAsse = (nome: string): number | null => {
const a = assi[nome];
return a && a.stato === 'ok' ? a.valore : null;
};
const vociFitnessAge: Record<string, number | null> = {
cardio: valoreAsse('Cardio-Respiratorio'),
handgrip_isolato: sottoDominio.handgrip ?? null,
hrv_isolato: sottoDominio.hrv ?? null,
forza_resto: valoreAsse('Forza & Struttura'),
composizione: valoreAsse('Composizione Corporea'),
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
};
const { fitnessAge, composito } = calcolaFitnessAge(
sessione.eta_alla_data,
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
vociFitnessAge
);
return {
assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura,
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
};
}
/**
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
*/
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const ins = db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
ins.run(
sessione.client_code, sessioneId, tipo, elemento,
p.stato === 'ok' ? p.valore : null,
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
);
};
const tx = db.transaction(() => {
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null
? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura }
: { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura };
riga('fitness_age', 'fitness_age', fitnessAgePunteggio);
});
tx();
}
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
export type PunteggiSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
fitnessAge: Punteggio | null;
};
/**
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
*
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
* che non è un criterio).
*/
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
const righe = db.prepare(
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
FROM score s
JOIN (
SELECT tipo, elemento, MAX(id) AS ultimo_id
FROM score
WHERE sessione_id = ?
GROUP BY tipo, elemento
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
WHERE s.sessione_id = ?`
).all(sessioneId, sessioneId) as
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
const assi: Record<string, Punteggio> = {};
const macro: Record<string, Punteggio> = {};
let fitnessAge: Punteggio | null = null;
for (const r of righe) {
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
else fitnessAge = aPunteggio(r);
}
return { assi, macro, fitnessAge };
}
@@ -0,0 +1,281 @@
/**
* Curve dei test fisici: da misura grezza (kg al dinamometro, secondi di
* plank, VO2max stimato...) a punteggio 0-100.
*
* Porting letterale delle funzioni dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni «Composizione
* Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza &
* Struttura», «Stabilità & Mobilità»). Coefficienti, soglie e tabelle sono
* copiati tali e quali e verificati contro `riferimento.json` in
* `tests/longevity/motore-fisici.test.ts`. Non "aggiustare" nulla qui dentro:
* l'oracolo è la definizione, non una proposta.
*
* `score_agility_ms` e `score_generic_range_local` NON sono portate: la prima
* perché il cliente ha rimosso l'agilità dallo score il 13/08 (scale non
* comparabili fra protocolli), la seconda perché non è mai chiamata da
* nessun peso.
*
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts`
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`,
* senza modo di passare età e sesso, che quasi tutte queste funzioni
* richiedono. Agganciarle è lavoro del piano degli import, non di questa
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far
* 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';
export type Sesso = 'M' | 'F';
/** Come `sex.strip().lower().startswith("m")` nell'oracolo. */
function eMaschio(sesso: Sesso): boolean {
return sesso.trim().toLowerCase().startsWith('m');
}
// --- Composizione Corporea -------------------------------------------------
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */
export function scoreGrassoPercento(fatPct: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const bands: [number, number, number][] = m
? [[0, 2, 40], [2, 5, 65], [5, 13, 100], [13, 17, 90], [17, 24, 65], [24, 35, 30], [35, 200, 10]]
: [[0, 10, 40], [10, 13, 65], [13, 20, 100], [20, 24, 90], [24, 31, 65], [31, 40, 30], [40, 200, 10]];
for (const [lo, hi, val] of bands) {
if (lo <= fatPct && fatPct < hi) return val;
}
return 50;
}
/** Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device). Come `score_muscle_percent`. */
export function scoreMuscoloPercento(musclePct: number, sesso: Sesso): number {
const [lo, hi] = eMaschio(sesso) ? [42.9, 52.4] : [37.8, 46.2];
if (musclePct >= hi) return 100;
if (musclePct <= lo - 10) return 20;
return arrotonda1(lerp(musclePct, lo - 10, hi, 20, 100));
}
/** Come `score_whr`. */
export function scoreWhr(whr: number, sesso: Sesso): number {
const threshold = eMaschio(sesso) ? 0.9 : 0.85;
if (whr <= threshold - 0.1) return 100;
if (whr <= threshold) return arrotonda1(lerp(whr, threshold - 0.1, threshold, 100, 60));
return clamp(arrotonda1(60 - (whr - threshold) * 300));
}
// --- Cardio-Respiratorio ----------------------------------------------------
/** Queen's College Step Test (McArdle et al. 1972). Come `vo2max_from_step_test`. */
export function vo2maxDaStepTest(hrRecovery: number, sesso: Sesso): number {
if (eMaschio(sesso)) return 111.33 - 0.42 * hrRecovery;
return 65.81 - 0.1847 * hrRecovery;
}
/** UKK 2km Walking Test (Laukkanen/Oja). Come `vo2max_from_2km_walk`. */
export function vo2maxDa2kmWalk(tempoMin: number, hr: number, eta: number, bmi: number): number {
return 116.2 - 2.98 * tempoMin - 0.11 * hr - 0.14 * eta - 0.39 * bmi;
}
/** Formula ACSM su VAM raggiunta al tapis. Teorico/stimato. Come `vo2max_from_mutt`. */
export function vo2maxDaMutt(vamMMin: number): number {
return 0.2 * vamMMin + 3.5;
}
/** Formula ACSM cicloergometro. Teorico/stimato, valida 50-200W. Come `vo2max_from_milfit`. */
export function vo2maxDaMilfit(watt: number, pesoKg: number): number {
return (10.8 * watt) / pesoKg + 7;
}
/** ACSM/FRIEND Registry, bande approssimate per fascia d'età e sesso. Come `score_vo2max`. */
export function scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
const goodThresholdsM: Record<number, number> = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 };
const goodThresholdsF: Record<number, number> = { 20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23 };
const table = m ? goodThresholdsM : goodThresholdsF;
const decade = Math.min(70, Math.max(20, Math.floor(eta / 10) * 10));
const good = table[decade];
const poor = good * 0.65;
const superior = good * 1.35;
if (vo2max <= poor) return clamp(arrotonda1(lerp(vo2max, 0, poor, 10, 40)));
if (vo2max <= good) return arrotonda1(lerp(vo2max, poor, good, 40, 70));
return clamp(arrotonda1(lerp(vo2max, good, superior, 70, 100)));
}
/** Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico. Come `score_spo2`. */
export function scoreSpo2(spo2Pct: number): number {
if (spo2Pct >= 97) return 100;
if (spo2Pct >= 95) return 85;
if (spo2Pct >= 90) return 50;
return 15;
}
// --- Recupero & Sistema Nervoso --------------------------------------------
/** ESC/ESH 2018. Vale la categoria più alta tra sistolica e diastolica. Come `score_blood_pressure`. */
export function scorePressione(sistolica: number, diastolica: number): number {
if (sistolica >= 180 || diastolica >= 110) return 5;
if (sistolica >= 160 || diastolica >= 100) return 25;
if (sistolica >= 140 || diastolica >= 90) return 45;
if (sistolica >= 130 || diastolica >= 85) return 70;
if (sistolica >= 120 || diastolica >= 80) return 90;
return 100;
}
/** Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999). Come `score_hrr`. */
export function scoreRecuperoCardiaco(caloBpm1min: number): number {
if (caloBpm1min >= 12) return clamp(arrotonda1(lerp(caloBpm1min, 12, 30, 70, 100)));
return clamp(arrotonda1(lerp(caloBpm1min, 0, 12, 20, 70)));
}
// --- Forza & Struttura -------------------------------------------------------
/**
* Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'età.
* Come `score_handgrip`.
*
* L'oracolo usa un `for...else` di Python: se il ciclo trova l'intervallo fra
* due ancore, fa `break` con `mean_val` calcolato; se lo esaurisce senza mai
* entrare nel ramo, l'`else` del `for` scatta e vale l'ultima ancora. In
* TypeScript non esiste il for-else: lo si riproduce con un flag.
*/
export function scoreHandgrip(kg: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// ancore (età, valore medio kg) approssimate dalla letteratura citata
const anchorsM: [number, number][] = [[25, 49.7], [40, 46], [60, 38], [75, 30]];
const anchorsF: [number, number][] = [[25, 30], [40, 28], [60, 24], [75, 18.7]];
const anchors = m ? anchorsM : anchorsF;
const etaC = clamp(eta, 25, 75);
let meanVal: number | undefined;
for (let i = 0; i < anchors.length - 1; i++) {
const [aEta, aVal] = anchors[i];
const [bEta, bVal] = anchors[i + 1];
if (aEta <= etaC && etaC <= bEta) {
meanVal = lerp(etaC, aEta, bEta, aVal, bVal);
break;
}
}
if (meanVal === undefined) meanVal = anchors[anchors.length - 1][1];
// media = punteggio 70; +/- 40% della media copre la banda 20-100
const ratio = meanVal ? kg / meanVal : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.5, 20, 100)));
}
/** ACSM/CSEP, bande approssimate per decade. Come `score_pushup`. */
export function scorePushup(reps: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const baseGoodM = 22;
const baseGoodF = 15; // 35-39 anni, 'buono' minimo
const decadeOffset = Math.max(0, Math.floor((eta - 35) / 10)) * 2.5; // calo ~2.5 rip/decade dopo i 35
const good = (m ? baseGoodM : baseGoodF) - decadeOffset;
const superior = good * 1.6;
const poor = good * 0.5;
if (reps <= poor) return clamp(arrotonda1(lerp(reps, 0, poor, 10, 40)));
if (reps <= good) return arrotonda1(lerp(reps, poor, good, 40, 70));
return clamp(arrotonda1(lerp(reps, good, superior, 70, 100)));
}
/** Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite). Come `score_bw_ratio_lift`. */
export function scoreSollevamentoSuPeso(
caricoKg: number,
pesoKg: number,
rip: number,
tiersM: [number, number][],
tiersF: [number, number][],
sesso: Sesso
): number {
const rm1 = (caricoKg * 36) / (37 - rip); // Brzycki
const ratio = rm1 / pesoKg;
const tiers = eMaschio(sesso) ? tiersM : tiersF;
// tiers = [(soglia_ratio, punteggio), ...] crescente
let prevR = 0;
let prevS = 10;
for (const [r, s] of tiers) {
if (ratio <= r) return arrotonda1(lerp(ratio, prevR, r, prevS, s));
prevR = r;
prevS = s;
}
return 100;
}
export const TIERS_BENCH_M: [number, number][] = [[0.5, 30], [1.0, 60], [1.25, 80], [1.5, 100]];
export const TIERS_BENCH_F: [number, number][] = [[0.3, 30], [0.6, 60], [0.75, 80], [1.0, 100]];
export const TIERS_SQUAT_M: [number, number][] = [[0.75, 30], [1.5, 60], [1.75, 80], [2.0, 100]];
export const TIERS_SQUAT_F: [number, number][] = [[0.5, 30], [1.1, 60], [1.3, 80], [1.5, 100]];
export const TIERS_ROW_M: [number, number][] = [[0.45, 30], [0.70, 60], [0.95, 80], [1.20, 100]];
export const TIERS_ROW_F: [number, number][] = [[0.30, 30], [0.45, 60], [0.60, 80], [0.80, 100]];
/** Come `score_flexed_arm_hang`. */
export function scoreTrazioneIsometrica(sec: number, sesso: Sesso): number {
const [good, superior] = eMaschio(sesso) ? [45, 70] : [25, 50];
if (sec <= good * 0.4) return clamp(arrotonda1(lerp(sec, 0, good * 0.4, 10, 40)));
if (sec <= good) return arrotonda1(lerp(sec, good * 0.4, good, 40, 70));
return clamp(arrotonda1(lerp(sec, good, superior, 70, 100)));
}
/** Reference equation adulti 18-95 (Zalewski et al.-style). Come `score_sit_to_stand_1min`. */
export function scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi = 24): number {
const sexCode = eMaschio(sesso) ? 0 : 1;
const predicted = 61.53 - 0.34 * eta - 3.57 * sexCode - 0.33 * bmi;
const ratio = predicted ? reps / predicted : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.3, 20, 100)));
}
/** Come `score_plank`. */
export function scorePlank(sec: number, sesso: Sesso): number {
const bandsM: [number, number][] = [[79, 20], [97, 40], [122, 55], [157, 75], [201, 90]];
const bandsF: [number, number][] = [[35, 20], [63, 40], [84, 55], [108, 75], [142, 90]];
const bands = eMaschio(sesso) ? bandsM : bandsF;
let prevT = 0;
let prevS = 10;
for (const [t, s] of bands) {
if (sec <= t) return arrotonda1(lerp(sec, prevT, t, prevS, s));
prevT = t;
prevS = s;
}
return 100;
}
// --- Stabilità & Mobilità ----------------------------------------------------
/**
* Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
* Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato
* solido), estrapolato linearmente (~4cm/5 anni) per le altre età.
* Approssimazione da dichiarare, non tabella completa. Come `score_back_scratch`.
*/
export function scoreBackScratch(cm: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const anchorEta = 62;
const anchorCm = m ? -8.6 : -1.8;
const declinePerYear = 0.8; // cm peggioramento per anno di età in più
const expected = anchorCm - (eta - anchorEta) * declinePerYear;
// punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
const diff = cm - expected;
return clamp(arrotonda1(lerp(diff, -15, 15, 20, 100)));
}
/** Wellness Tower, riferimento 180 gradi (outreach/buckling). Come `score_shoulder_mobility_wt`. */
export function scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number {
const avg = (outreachDeg + bucklingDeg) / 2;
return clamp(arrotonda1((avg / 180) * 100));
}
/** Decrescente: meno cadute = meglio. Come `score_flamingo`. */
export function scoreFlamingo(cadute: number): number {
if (cadute <= 3) return 100;
if (cadute <= 7) return arrotonda1(lerp(cadute, 3, 7, 100, 80));
if (cadute <= 15) return arrotonda1(lerp(cadute, 7, 15, 80, 50));
return clamp(arrotonda1(lerp(cadute, 15, 30, 50, 10)));
}
/** Come `score_sit_and_reach`. */
export function scoreSitAndReach(cm: number, sesso: Sesso): number {
const median = eMaschio(sesso) ? 24 : 31;
return clamp(arrotonda1(lerp(cm, median - 20, median + 10, 20, 100)));
}