longevity: registro e motore per la nuova batteria test (manuale Nicola 27/09)

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>
This commit is contained in:
2026-09-27 22:45:26 +02:00
parent 2231a476c7
commit abd0626ab0
3 changed files with 299 additions and 38 deletions
@@ -162,21 +162,37 @@ export const FINESTRA_GIORNI: number | null = null;
* successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta
* esclusa come prima.
*/
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
/** 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
`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
{ test_id: string; valore_num: number; data: string }[];
).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) {
@@ -193,6 +209,39 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map
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
@@ -209,6 +258,9 @@ export function sottoDominiDellaSessione(db: Database.Database, sessioneId: numb
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.
@@ -234,7 +286,7 @@ export function sottoDominiDellaSessione(db: Database.Database, sessioneId: numb
// 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) : {};
const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso, recenza) : {};
return { ...daCurva, ...daFisici };
}