/** * 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, o una misura fisica — * vedi sotto) non contribuisce a QUESTO livello. * * Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola * `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una * curva generica (età, sesso, a volte un'altra misura come il peso * corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini * da curva (questionario) e sotto-domini fisici — in un'unica mappa. * * Un check-up e un questionario sono sessioni SEPARATE: senza altro, una * sessione vedrebbe solo le proprie misure e lascerebbe vuoti gli assi * dell'altra sessione. `misureValideAlla` risolve questo leggendo, per ogni * test_id, la misura valida più recente DEL CLIENTE fra tutte le sue * sessioni fino a quella data — non solo quelle della sessione corrente. * * Livelli 2-4 (`calcolaSessione`): usa `sottoDominiDellaSessione`, poi * `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) salvo conferma esplicita del trainer * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`). */ 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'; import { sottoDominiFisici, type Sesso } from './fisici'; /** 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; } } /** * Sotto questa età (in giorni) una misura smette di contare per una sessione successiva. * `null` = nessun limite: oggi una misura resta valida per sempre, finché non ne arriva * una più recente dello stesso test_id. Decisione aperta per Nicola — quanto "invecchia" * un dato fisico prima di non poter più coprire il questionario di un mese dopo è una * scelta di prodotto, non tecnica; finché non arriva si tiene la più permissiva delle due, * perché la direzione di guasto opposta (scartare una misura buona) è quella che di nuovo * produce assi vuoti che potrebbero non esserlo. */ export const FINESTRA_GIORNI: number | null = null; /** * Per ogni test_id, il valore valido più recente DI QUESTO CLIENTE fra tutte le sue * sessioni fino a quella data compresa — non solo le misure della sessione `sessioneId`. * Risolve il problema del check-up e del questionario come sessioni separate (vedi * l'intestazione del file): un check-up del giorno D+10 vede ancora le risposte del * questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò * che accadrà dopo di lei. * * "Più recente" = data della sessione DESC, poi id di sessione DESC (a parità di data, * l'inserimento più tardo vince) — stesso criterio di `leggiScore`. A parità di SESSIONE * (due misure dello stesso test_id nella stessa sessione: `registraMisure` non lo vieta, * es. una correzione manuale dopo un refuso) l'ORDER BY aggiunge un terzo criterio * esplicito, `m.id` di riga in `misure` DESC: vince quella scritta dopo. Senza questo * terzo criterio le due righe uscirebbero in un ordine NON definito da SQL (nessuna delle * due chiavi precedenti le distingue), non "implicito" come diceva una versione precedente * di questo commento — un ordine che sembra stabile per come SQLite le restituisce oggi, * ma su cui non si può contare. * * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da * una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`, * colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel * momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione * successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta * esclusa come prima. */ /** Una riga grezza di `misure`, con la sessione di provenienza: usata sia da * `misureValideAlla` che da `recenzaValideAlla` (stessa query, stesso ORDER BY). */ type RigaMisuraGrezza = { test_id: string; valore_num: number; data: string; sessione_id: number }; /** Fattorizza la query condivisa fra `misureValideAlla` e `recenzaValideAlla`: stessa * sessione, stesse righe, stesso ordine. Aggiunto `s2.id AS sessione_id` (assente * nella query originale) perche' `recenzaValideAlla` deve sapere DA QUALE sessione viene * ogni riga, non solo la data — due sessioni nello stesso giorno sono comunque due * posizioni di recenza diverse (lo stesso criterio di `ORDER BY s2.id DESC`). */ function righeMisureGrezze( db: Database.Database, sessioneId: number ): { sessione: { client_code: string; data: string; id: number }; righe: RigaMisuraGrezza[] } { const sessione = db.prepare( `SELECT client_code, data, id FROM sessioni WHERE id = ?` ).get(sessioneId) as { client_code: string; data: string; id: number } | undefined; if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); const righe = db.prepare( `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data, s2.id AS sessione_id FROM misure m JOIN sessioni s2 ON s2.id = m.sessione_id WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) ORDER BY s2.data DESC, s2.id DESC, m.id DESC` ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as RigaMisuraGrezza[]; return { sessione, righe }; } export function misureValideAlla(db: Database.Database, sessioneId: number): Map { const { sessione, righe } = righeMisureGrezze(db, sessioneId); const mappa = new Map(); for (const r of righe) { // gia' trovato un valore per questo test_id: e' il piu' recente, per data di sessione, // poi id di sessione, poi (spareggio) id di riga in misure — tutti e tre espliciti // nell'ORDER BY qui sopra, la prima riga per test_id in questo ciclo e' sempre quella giusta. if (mappa.has(r.test_id)) continue; if (FINESTRA_GIORNI !== null) { const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000; if (giorni > FINESTRA_GIORNI) continue; } mappa.set(r.test_id, r.valore_num); } return mappa; } /** * La posizione di recenza di ogni test_id (0 = il piu' recente), sullo STESSO ordine di * `misureValideAlla` (`righeMisureGrezze`: data di sessione DESC, poi id di sessione DESC). * Non e' una data: e' un rango relativo fra le SESSIONI (data, sessione_id) di questo * cliente fino a questa sessione compresa — due test_id misurati nella stessa sessione * hanno la STESSA posizione (sono ugualmente "recenti": la regola dei gruppi di * `sottoDominiFisici`, issue #13, rompe quel pareggio con l'ordine di priorita' del * gruppo, non con l'id di riga in `misure`). Serve a scegliere, fra piu' alternative * complete per una stessa sotto-metrica (es. panca vs push-up), quella la cui misura piu' * recente vince. */ export function recenzaValideAlla(db: Database.Database, sessioneId: number): Map { const { sessione, righe } = righeMisureGrezze(db, sessioneId); const mappa = new Map(); let posizione = -1; let chiaveSessionePrecedente: string | null = null; for (const r of righe) { if (FINESTRA_GIORNI !== null) { const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000; if (giorni > FINESTRA_GIORNI) continue; } if (mappa.has(r.test_id)) continue; // gia' assegnata la sua posizione (la piu' recente) const chiaveSessione = `${r.data}|${r.sessione_id}`; if (chiaveSessione !== chiaveSessionePrecedente) { posizione++; chiaveSessionePrecedente = chiaveSessione; } mappa.set(r.test_id, posizione); } return mappa; } /** * I sotto-domini di una sessione, dalle due fonti che oggi esistono: il questionario * (livello 1, `applicaCurva` sulle misure di `misureValideAlla`) e i test fisici * (`sottoDominiFisici`, sull'età alla data della sessione e il sesso del cliente). In * caso di chiave in conflitto vince il fisico — oggi non capita mai (i sotto-domini da * curva sono tutti `questionario_*`), ma se domani un test_id q_* condividesse la chiave * di un sotto-dominio fisico, un dato misurato in un check-up deve prevalere su * un'autovalutazione. */ export function sottoDominiDellaSessione(db: Database.Database, sessioneId: number): Record { const sessione = db.prepare( `SELECT data, eta_alla_data, client_code FROM sessioni WHERE id = ?` ).get(sessioneId) as { data: string; eta_alla_data: number | null; client_code: string } | undefined; if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); const misurate = misureValideAlla(db, sessioneId); // Recenza per test_id (issue #13): serve alla regola dei gruppi di sottoDominiFisici, // per scegliere fra alternative COMPLETE quella con la misura piu' recente. const recenza = recenzaValideAlla(db, sessioneId); // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per // sotto-dominio — più test nello stesso sotto-dominio contano come media. const attivi = testAttivi(db, sessione.data); const perSottoDominio = new Map(); 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 daCurva: Record = {}; for (const [nome, valori] of perSottoDominio) { daCurva[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); } const soggetto = db.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(sessione.client_code) as { sesso: Sesso } | undefined; // soggetto dovrebbe sempre esistere (sessioni.client_code referenzia soggetti, FK attive // in db.ts): il controllo resta comunque, perche' l'assenza di dato fisico e' un {} // sicuro, mai un sesso indovinato. const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso, recenza) : {}; return { ...daCurva, ...daFisici }; } /** * 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; macro: Record; 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` (tramite `sottoDominiDellaSessione`), i pesi da `pesi` (tramite `pesiDi`). */ export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione { const sessione = db.prepare( `SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?` ).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined; if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); // Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione. const sottoDominio = sottoDominiDellaSessione(db, sessioneId); // 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 = {}; 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 = {}; 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 = { 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; macro: Record; /** `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 = {}; const macro: Record = {}; 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 }; }