abd0626ab0
Nuovi test_id nel registro (altezza, FC riposo, handgrip/back scratch/flamingo dx-sx, panca manubri/bilanciere, trazioni, sit-to-stand, plank ginocchia, 6MWT, 2km walk UKK, MILFIT, MUTT FC max/1'/2'/step) con attivo_da, e i quattro test_id storici sostituiti (handgrip_kg, back_scratch_cm, flamingo_cadute, hrr_1min_bpm) marcati attivo_a 2026-09-28 senza cancellarli. motore/index.ts: nuova recenzaValideAlla (posizione di recenza per test_id, stesso ORDER BY di misureValideAlla, fattorizzata la query condivisa). motore/fisici.ts: regola dei gruppi in sottoDominiFisici (recenza opzionale) - per ogni sotto-metrica con piu' test alternativi si sceglie l'alternativa COMPLETA la cui misura piu' recente vince; senza recenza resta l'ordine di priorita' di oggi. Handgrip/back_scratch/flamingo ora mediano dx/sx; spinta e arti_inferiori aggiungono panca bilanciere e sit-to-stand; vo2max aggiunge MILFIT e 2km walk alla cascata esistente. Refs #13 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
458 lines
22 KiB
TypeScript
458 lines
22 KiB
TypeScript
/**
|
|
* 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<string, number> {
|
|
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
|
|
|
|
const mappa = new Map<string, number>();
|
|
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<string, number> {
|
|
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
|
|
|
|
const mappa = new Map<string, number>();
|
|
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<string, number> {
|
|
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<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 daCurva: Record<string, number> = {};
|
|
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<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` (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<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 };
|
|
}
|