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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user