longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni

Un check-up e un questionario sono sessioni separate: prima di questo
commit calcolaSessione leggeva solo le misure della sessione corrente,
quindi l'una non vedeva mai i dati dell'altra. misureValideAlla (nuova,
motore/index.ts) legge invece, per ogni test_id, la misura valida piu'
recente del cliente fra tutte le sue sessioni fino a quella data.
sottoDominiDellaSessione unisce sotto-domini da curva (questionario) e
sotto-domini fisici (sottoDominiFisici) in un'unica mappa, usata sia da
calcolaSessione (livello 1) sia da vista.ts (sottoDominiPresenti, per
"cosa manca" nel referto — non piu' una query separata sulla tabella
misure). Aggiornati i commenti di seed-demo.ts (il registro ora contiene
anche le voci f_*, anche se questa demo non le compila).

Test nuovi in motore-fisici-sessione.test.ts: il caso demo() dell'oracolo
end-to-end, un ingresso mancante o l'eta assente che lascia il
sotto-dominio fuori, la combinazione fra sessioni (check-up dopo il
questionario, sessione precedente che non vede il futuro, misura fuori
range sostituita dalla precedente valida). 202 test esistenti restano
verdi, 9 nuovi: 211 totali. tsc --noEmit: 0 errori (0 anche su 13d8cdd).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:38:34 +00:00
parent 48910d84ba
commit 4968b75472
4 changed files with 379 additions and 63 deletions
+127 -40
View File
@@ -6,15 +6,25 @@
* 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.
* test senza curva dichiarata (es. un campo testuale, o una misura fisica —
* vedi sotto) non contribuisce a QUESTO livello.
*
* 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.
* 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 è
@@ -41,6 +51,7 @@ import {
} 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 = {
@@ -114,6 +125,109 @@ export function applicaCurva(voce: VoceRegistro, valore: number): number | 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 DESC (a parità di data, l'inserimento
* più tardo vince) — stesso criterio di `leggiScore`. A parità di sessione (una misura
* ri-registrata più volte nella stessa sessione, oggi non capita ma non è vietato) vince
* comunque la più recente per `id` di riga in `misure`, perché la query è ordinata anche
* su quello implicitamente tramite l'ordine della sessione.
*
* 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.
*/
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
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
FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id
WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?))
ORDER BY s2.data DESC, s2.id DESC`
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as
{ test_id: string; valore_num: number; data: string }[];
const mappa = new Map<string, number>();
for (const r of righe) {
if (mappa.has(r.test_id)) continue; // gia' trovato un valore piu' recente per questo test_id
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;
}
/**
* 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);
// 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) : {};
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é
@@ -134,43 +248,16 @@ export type RisultatoSessione = {
/**
* 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`).
* `misure` (tramite `sottoDominiDellaSessione`), 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;
`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`);
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 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(
+16 -16
View File
@@ -11,7 +11,7 @@
import type Database from 'better-sqlite3';
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore } from './motore';
import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore';
export type CampoQuestionario = {
id: string;
@@ -137,11 +137,13 @@ export type MacroVista = { nome: string; valore: number | null; copertura: numbe
/**
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
* dove il registro non arriva ancora.
* di `PESI_ASSE` in registro.ts. Non sono chiavi che si leggono da `registro_test.sotto_dominio`
* per tutte: quelle del questionario sì, ma quelle fisiche (handgrip, VO2max, plank...) le
* produce `sottoDominiFisici` (`motore/fisici.ts`) come chiave del suo risultato — le voci
* FISICI del registro non portano un `sotto_dominio` proprio (vedi il commento su `FISICI`
* in registro.ts) — quindi la traduzione non può venire da una query sul registro: è dato
* locale a questo modulo. Unica eccezione rimasta davvero scoperta: `spirometria`, fuori
* dal perimetro di `sottoDominiFisici` (nessun calcolatore GLI, vedi quel file).
*/
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
handgrip: 'Forza di presa (handgrip)',
@@ -178,17 +180,15 @@ function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
}
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
* test sono stati fatti non è ricalcolare come pesano. */
/** I sotto-domini con un punteggio calcolabile per questa sessione — le chiavi di
* `sottoDominiDellaSessione`, che a differenza di una query sulla sola tabella `misure`
* vede anche le misure valide portate avanti da sessioni precedenti dello stesso cliente
* (`misureValideAlla` in `motore/index.ts`) e i sotto-domini fisici (`sottoDominiFisici`),
* non solo quelli da curva del questionario. Usa lo stesso calcolo che alimenta gli assi,
* non lo duplica: `mancano` si costruisce da qui, mai da un ricalcolo separato che
* potrebbe divergere. */
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
const righe = db.prepare(
`SELECT DISTINCT rt.sotto_dominio AS sd
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND rt.sotto_dominio IS NOT NULL`
).all(sessioneId) as { sd: string }[];
return new Set(righe.map((r) => r.sd));
return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId)));
}
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse