6 Commits

Author SHA1 Message Date
Adriano b0f30f9e4c longevity: correzioni passo 3 su #13 unità B — scarto alternative e nota
testIdScartatiDalleAlternative: una sezione senza scelta in `scelte` (radio
assente dal form) non scarta più nulla, invece di ripiegare in silenzio
sulla prima alternativa (perdeva campi come f_plank_sec senza alt_core).

API checkup.ts: la nota operatore si valida (lunghezza max 5000, TESTO_MAX
esportato da giornale.ts) PRIMA di salvare il check-up, non più dopo — una
nota troppo lunga non faceva più fallire aggiungiVoce in silenzio ma
perdeva comunque la nota col check-up già scritto. maxlength sulla
textarea in checkup.astro.

Suite: 314 verdi, 4 rossi di piattaforma (baseline, invariati). tsc e
build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:59:19 +02:00
Adriano 81021d18f3 longevity: test unità B — struttura modulo, alternative, nota su giornale (#13)
gestionale-modulo-checkup.test.ts: ordine sezioni, pressione/SpO2 esclusi,
dismesso in «Altre misure» su data vecchia, dettagli MUTT, preselezione
alternativa (prima/più recente/ultimo check-up). checkup-alternative-api.test.ts:
API scarta i campi dell'alternativa non scelta (radio inviata o assente),
nota operatore aggiunge voce di giornale (data check-up, autore operatore),
nota vuota o assente (correzione) non aggiunge nulla.

Suite completa: 313 verdi, 4 rossi di piattaforma (baseline, invariati).
tsc --noEmit e npm run build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:57:02 +02:00
Adriano c8187ce4db longevity: modulo check-up con sezioni e alternative del manuale (#13)
strutturaCheckup in gestionale.ts (ordine Pre-test..Strumenti, sezioni ad
alternative con soglia testuale, «Altre misure» per i test dismessi in
correzione, pressione/SpO2 esclusi); checkup.astro con radio button e
fieldset (niente <select>); API checkup.ts scarta i campi delle
alternative non scelte e aggiunge una voce di giornale dalla nota
operatore sul check-up nuovo.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:54:26 +02:00
Adriano cffb0a624d longevity: correzione etichette sit-to-stand e push-up (unità A)
sit-to-stand era descritto come 30 secondi, il test dura 1 minuto;
push-up non diceva che le donne lo fanno sulle ginocchia.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:49:24 +02:00
Adriano f57a07a964 longevity: test unitari e di sessione per la regola dei gruppi (#13)
motore-fisici-gruppi.test.ts (nuovo): un caso per ogni alternativa che da
punteggio (handgrip/spinta/arti_inferiori/vo2max/hrr/flamingo/back_scratch,
media dx/sx e storico), un lato solo -> nessun punteggio, recenza fra
alternative (pushup/panca in entrambe le direzioni), alternativa piu recente
ma incompleta esclusa (sit-to-stand senza altezza), i test "solo registrati"
non producono chiavi.

motore-fisici-sessione.test.ts: due nuovi casi end-to-end su DB (due sessioni,
due test diversi per la stessa sotto-metrica) che confermano che lo score usa
il test piu recente, in entrambe le direzioni.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:48:07 +02:00
Adriano abd0626ab0 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>
2026-09-27 22:45:26 +02:00
12 changed files with 1120 additions and 49 deletions
@@ -56,6 +56,201 @@ export function campiDelCheckup(db: Database.Database, alla: string): GruppoChec
return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! }));
}
// --- Struttura del modulo di check-up (#13, manuale di Nicola del 27/09/2026) --------------
// A differenza di campiDelCheckup (raggruppato per asse, invariato: lo usa ancora la pagina
// di dettaglio sessione, sessione/[id].astro, per la vista di sola lettura), qui l'ordine e
// il raggruppamento sono quelli del modulo cartaceo: Pre-test -> Mobilità -> Equilibrio ->
// Presa -> Core -> Forza spinta -> Forza trazione -> Arti inferiori -> Aerobico -> Strumenti.
// Una sezione o e' a campi fissi, o e' "ad alternative" (piu' protocolli per la stessa
// sotto-metrica, uno solo si compila: radio button, mai <select> — tests/longevity/
// referto.test.ts intercetta la parola). test_id qui sono stringhe: si risolvono contro
// testAttivi in strutturaCheckup, cosi' un test dismesso sparisce da solo dal modulo.
type AlternativaRaw = { chiave: string; nome: string; soglia: string; campi: string[]; dettagli?: string[] };
type SezioneRaw =
| { titolo: string; tipo: 'campi'; campi: string[] }
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaRaw[] };
// Pressione e SpO2 NON compaiono nel modulo (decisione di Adriano, 27/09/2026: arrivano
// dall'import Wellness Tower di #3, non da inserimento manuale) — esclusi anche da
// «Altre misure», non solo dalle sezioni: sono attivi nel registro ma il modulo non li mostra.
const ESCLUSI_DAL_MODULO = new Set(['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']);
const STRUTTURA_RAW: SezioneRaw[] = [
{ titolo: 'Pre-test', tipo: 'campi', campi: ['f_fc_riposo_bpm', 'f_peso_kg', 'f_altezza_cm'] },
{ titolo: 'Mobilità', tipo: 'campi', campi: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm', 'f_sit_reach_cm'] },
{ titolo: 'Equilibrio', tipo: 'alternative', chiave: 'equilibrio', alternative: [
{ chiave: 'occhi_aperti', nome: 'Flamingo, occhi aperti',
soglia: 'Soglia: 4-7 cadute a occhi aperti → passa a occhi chiusi.',
campi: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
{ chiave: 'occhi_chiusi', nome: 'Flamingo, occhi chiusi', soglia: '',
campi: ['f_flamingo_oc_dx', 'f_flamingo_oc_sx'] },
] },
{ titolo: 'Presa', tipo: 'campi', campi: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
{ titolo: 'Core', tipo: 'alternative', chiave: 'core', alternative: [
{ chiave: 'ginocchia', nome: 'Plank sulle ginocchia',
soglia: 'Soglia: ≥30-45 s sulle ginocchia → passa a plank pieno.', campi: ['f_plank_ginocchia_sec'] },
{ chiave: 'pieno', nome: 'Plank pieno', soglia: '', campi: ['f_plank_sec'] },
] },
{ titolo: 'Forza spinta', tipo: 'alternative', chiave: 'spinta', alternative: [
{ chiave: 'manubri', nome: 'Panca con manubri (5RM, kg per manubrio)',
soglia: 'Soglia: esegue ≥1 push-up corretto → prova push-up.',
campi: ['f_panca_man_carico_kg', 'f_panca_man_rip'] },
{ chiave: 'pushup', nome: 'Push-up (donne sulle ginocchia)',
soglia: 'Soglia: fascia «Buono» o oltre → passa a panca con bilanciere.', campi: ['f_pushup_rip'] },
{ chiave: 'bilanciere', nome: 'Panca con bilanciere (5RM)', soglia: '',
campi: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
] },
{ titolo: 'Forza trazione', tipo: 'alternative', chiave: 'trazione', alternative: [
{ chiave: 'rematore', nome: 'Rematore (5RM)',
soglia: 'Soglia: ≥1 trazione, o carico 0,95-1,19× peso corporeo → passa a trazioni.',
campi: ['f_row_carico_kg', 'f_row_rip'] },
{ chiave: 'trazioni', nome: 'Trazioni alla sbarra', soglia: '', campi: ['f_trazioni_rip'] },
] },
{ titolo: 'Arti inferiori', tipo: 'alternative', chiave: 'arti_inferiori', alternative: [
{ chiave: 'sit_to_stand', nome: 'Sit-to-stand, 1 minuto (sedia 43-45 cm)',
soglia: 'Soglia: rapporto risultato/predetto ≥1,0 → passa a squat.', campi: ['f_sit_to_stand_rip'] },
{ chiave: 'squat', nome: 'Squat (5RM)', soglia: '', campi: ['f_squat_carico_kg', 'f_squat_rip'] },
] },
{ titolo: 'Aerobico', tipo: 'alternative', chiave: 'aerobico', alternative: [
{ chiave: '6mwt', nome: '6 Minute Walk Test',
soglia: 'Soglia: ≥80% del predetto → passa a Queen\'s College o 2 km.', campi: ['f_6mwt_m'] },
{ chiave: 'queens', nome: 'Queen\'s College Step Test',
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_step_hr_recovery'] },
{ chiave: '2km', nome: 'Camminata 2 km (UKK)',
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
{ chiave: 'mutt', nome: 'MUTT', soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).',
campi: ['f_mutt_vam', 'f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm', 'f_mutt_fc_2min_bpm'],
dettagli: [
'f_mutt_fc_7_5', 'f_mutt_fc_8_5', 'f_mutt_fc_9_5', 'f_mutt_fc_10_5',
'f_mutt_fc_11_5', 'f_mutt_fc_12_5', 'f_mutt_fc_13_5', 'f_mutt_fc_14_5',
] },
{ chiave: 'milfit', nome: 'MILFIT',
soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).', campi: ['f_milfit_watt'] },
{ chiave: 'calibre', nome: 'Calibre (VO2max misurato)', soglia: '', campi: ['f_vo2max'] },
] },
{ titolo: 'Strumenti (Wellness Tower, Stress Index)', tipo: 'campi',
campi: ['f_grasso_pct', 'f_muscolo_pct', 'f_whr', 'f_hrv_score', 'f_spalla_outreach_deg', 'f_spalla_buckling_deg'] },
];
export type AlternativaCheckup = {
chiave: string; nome: string; soglia: string;
campi: CampoQuestionario[]; dettagli?: CampoQuestionario[];
};
export type SezioneCheckup =
| { titolo: string; tipo: 'campi'; campi: CampoQuestionario[] }
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaCheckup[] };
export type ModuloCheckup = { sezioni: SezioneCheckup[]; altreMisure: CampoQuestionario[] };
/**
* Il modulo del check-up nell'ordine del manuale (#13): solo i test `f_*` attivi alla data
* indicata. Un test attivo ma assente da STRUTTURA_RAW (i dismessi, correggendo una sessione
* vecchia) finisce in `altreMisure`, mai perso — tranne pressione e SpO2 (ESCLUSI_DAL_MODULO),
* che non compaiono da nessuna parte qui: restano nel registro, li scrive l'import #3.
*/
export function strutturaCheckup(db: Database.Database, alla: string): ModuloCheckup {
const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_'));
const perId = new Map<string, CampoQuestionario>(fisici.map((v) => [v.test_id, {
id: v.test_id, etichetta: v.etichetta, tipo: v.tipo_valore,
unita: v.unita, min: v.range_min, max: v.range_max, sottoDominio: v.sotto_dominio,
}]));
const usati = new Set<string>(ESCLUSI_DAL_MODULO);
const risolvi = (ids: string[]): CampoQuestionario[] => {
const out: CampoQuestionario[] = [];
for (const id of ids) {
usati.add(id);
const c = perId.get(id);
if (c) out.push(c); // non attivo alla data: si salta, non un campo vuoto
}
return out;
};
const sezioni: SezioneCheckup[] = [];
for (const s of STRUTTURA_RAW) {
if (s.tipo === 'campi') {
const campi = risolvi(s.campi);
if (campi.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'campi', campi });
} else {
const alternative: AlternativaCheckup[] = [];
for (const a of s.alternative) {
const campi = risolvi(a.campi);
const dettagli = a.dettagli ? risolvi(a.dettagli) : undefined;
if (campi.length > 0 || (dettagli && dettagli.length > 0)) {
alternative.push({ chiave: a.chiave, nome: a.nome, soglia: a.soglia, campi, dettagli });
}
}
if (alternative.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'alternative', chiave: s.chiave, alternative });
}
}
const altreMisure = fisici.filter((v) => !usati.has(v.test_id)).map((v) => perId.get(v.test_id)!);
return { sezioni, altreMisure };
}
/**
* Quale alternativa preselezionare in una sezione ad alternative (#13): la prima, in ordine
* di elencazione, che ha almeno uno dei suoi test_id (campi o dettagli) fra `valori` — in
* correzione `valori` sono le misure della sessione, in un check-up nuovo quelle dell'ultimo
* check-up del cliente (`valoriUltimoCheckup`). Senza corrispondenza vince la prima
* dell'elenco (il livello neofita, come dice l'analisi).
*/
export function alternativaSelezionata(
sezione: Extract<SezioneCheckup, { tipo: 'alternative' }>, valori: Map<string, number>
): string {
for (const alt of sezione.alternative) {
const ids = [...alt.campi.map((c) => c.id), ...(alt.dettagli?.map((c) => c.id) ?? [])];
if (ids.some((id) => valori.has(id))) return alt.chiave;
}
return sezione.alternative[0]?.chiave ?? '';
}
/**
* Le misure dell'ultimo check-up del cliente (più recente prima, come `sessioniDi`), per
* precompilare `alternativaSelezionata` in un check-up NUOVO — mappa vuota se il cliente non
* ha ancora un check-up.
*/
export function valoriUltimoCheckup(db: Database.Database, clientCode: string): Map<string, number> {
const riga = db.prepare(
`SELECT id FROM sessioni WHERE client_code = ? AND tipo = 'checkup' ORDER BY data DESC, id DESC LIMIT 1`
).get(clientCode) as { id: number } | undefined;
if (!riga) return new Map();
const dettaglio = misureDellaSessione(db, riga.id);
const out = new Map<string, number>();
for (const m of dettaglio?.misure ?? []) {
if (m.valore_num !== null) out.set(m.test_id, m.valore_num);
}
return out;
}
/**
* I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13):
* `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel
* form). Una sezione ASSENTE da `scelte` (nessuna radio arrivata: form senza JS che non ha
* ancora marcato nulla, o client non-browser) non scarta NIENTE — ripiegare sulla prima
* alternativa butterebbe via in silenzio campi compilati di un'altra (es. f_plank_sec senza
* alt_core), correzione del passo 3 su #13. Una sezione a campi fissi non contribuisce: nulla
* da scartare lì.
*/
export function testIdScartatiDalleAlternative(
sezioni: SezioneCheckup[], scelte: Map<string, string>
): Set<string> {
const scartati = new Set<string>();
for (const s of sezioni) {
if (s.tipo !== 'alternative') continue;
const scelta = scelte.get(s.chiave);
if (scelta === undefined) continue; // nessuna radio per questa sezione: non si scarta nulla
for (const alt of s.alternative) {
if (alt.chiave === scelta) continue;
for (const c of alt.campi) scartati.add(c.id);
for (const c of alt.dettagli ?? []) scartati.add(c.id);
}
}
return scartati;
}
export type EsitoCheckup =
| { ok: true; sessioneId: number; fuoriRange: string[] }
| { ok: false; errore: string };
+3 -1
View File
@@ -7,7 +7,9 @@
import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const TESTO_MAX = 5000;
// Esportato (#13, passo 3): l'API del check-up valida la nota PRIMA di salvare, con lo
// stesso limite di aggiungiVoce — un numero duplicato qui e lì marcirebbe silenziosamente.
export const TESTO_MAX = 5000;
export type VoceGiornale = {
id: number;
+167 -29
View File
@@ -17,60 +17,156 @@
* un valore di ripiego. È lo stesso principio di `applicaCurva`/`paramsValidi`, qui
* applicato a funzioni con più argomenti invece che a una curva coi parametri incompleti.
*
* REGOLA DEI GRUPPI (issue #13, manuale di Nicola del 27/09/2026): alcune sotto-metriche
* hanno più test alternativi (es. handgrip = media dx/sx oggi, o il vecchio f_handgrip_kg
* storico). Per ognuna si considerano solo le alternative COMPLETE (tutti gli ingressi
* presenti, età/peso/altezza comprese quando la funzione le richiede — un'alternativa
* "solo registrata", senza tabella nel manuale, non entra mai nel gruppo), e fra quelle si
* sceglie la cui misura più recente vince (`recenza`, da `recenzaValideAlla` in
* `motore/index.ts`: 0 = più recente). A parità — o quando `recenza` non è passata, cioè il
* comportamento di oggi — vince l'ordine in cui le alternative sono elencate nel gruppo.
* Vedi `sceglieAlternativa` più sotto.
*
* FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore
* GLI che non esiste ancora), milfit, il 2km walk, il sit-to-stand, la trazione isometrica.
* L'oracolo li usa nella demo con segnaposto o non li usa affatto nei pesi correnti;
* restano fuori finché non arriva un test_id e una decisione su come raccoglierli.
* GLI che non esiste ancora), la trazione isometrica. L'oracolo li usa nella demo con
* segnaposto o non li usa affatto nei pesi correnti; restano fuori finché non arriva un
* test_id e una decisione su come raccoglierli. (Milfit, 2km walk e sit-to-stand erano qui
* fino al #13: dal manuale hanno una tabella di riferimento e sono entrati nella regola dei
* gruppi qui sotto.)
*/
import type { Sesso } from './test-fisici';
import { clamp } from './curve';
import {
scoreHandgrip, scorePushup, scoreSollevamentoSuPeso, TIERS_ROW_M, TIERS_ROW_F,
TIERS_SQUAT_M, TIERS_SQUAT_F, scorePlank, scoreGrassoPercento, scoreMuscoloPercento,
scoreWhr, scoreVo2max, vo2maxDaMutt, vo2maxDaStepTest, scoreSpo2, scorePressione,
TIERS_SQUAT_M, TIERS_SQUAT_F, TIERS_BENCH_M, TIERS_BENCH_F, scoreSitToStand, scorePlank,
scoreGrassoPercento, scoreMuscoloPercento, scoreWhr, scoreVo2max, vo2maxDaMutt,
vo2maxDaMilfit, vo2maxDa2kmWalk, vo2maxDaStepTest, scoreSpo2, scorePressione,
scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla,
} from './test-fisici';
export type { Sesso };
/** Un'alternativa possibile per una sotto-metrica di gruppo: `valore` è `undefined` se
* mancava anche un solo ingresso (alternativa incompleta, non entra nel gruppo);
* `idRecenza` sono i test_id "propri" di questa alternativa — non le variabili di contesto
* condivise come peso/età/altezza — usati per confrontarne la freschezza. */
type Alternativa = { valore: number | undefined; idRecenza: string[] };
/**
* Sceglie, fra le alternative COMPLETE di un gruppo, quella con la misura più recente:
* la posizione di un'alternativa è il MASSIMO (il più vecchio) fra le posizioni di
* `recenza` dei suoi `idRecenza` — un'alternativa è fresca quanto il suo ingresso più
* vecchio, non quanto il più nuovo. Senza `recenza` (firma opzionale di
* `sottoDominiFisici`, o nessuno dei suoi id in mappa) vale solo l'ordine di elencazione
* (indice più basso vince): è il comportamento di prima del #13, invariato per i
* chiamanti che non passano `recenza`.
*/
function sceglieAlternativa(alternative: Alternativa[], recenza?: Map<string, number>): number | undefined {
let scelto: { valore: number; posizione: number; indice: number } | undefined;
alternative.forEach((alt, indice) => {
if (alt.valore === undefined) return; // alternativa incompleta: esclusa dal gruppo
let posizione = indice;
if (recenza) {
let massimo = -1;
for (const id of alt.idRecenza) {
const p = recenza.get(id);
if (p !== undefined && p > massimo) massimo = p;
}
if (massimo !== -1) posizione = massimo;
}
if (!scelto || posizione < scelto.posizione || (posizione === scelto.posizione && indice < scelto.indice)) {
scelto = { valore: alt.valore, posizione, indice };
}
});
return scelto?.valore;
}
/** Media di due misure, solo se ENTRAMBE sono presenti — un solo lato non produce mai un
* punteggio (regola dei gruppi, issue #13). */
function mediaCoppia(a: number | undefined, b: number | undefined): number | undefined {
return a !== undefined && b !== undefined ? (a + b) / 2 : undefined;
}
/**
* `m` è la mappa test_id -> valore grezzo più recente e valido (vedi `misureValideAlla` in
* `motore/index.ts`), `eta` l'età alla data della sessione (nullable come in tutto il
* motore: senza età gli score che la richiedono restano assenti, mai calcolati a zero).
* `recenza` (opzionale, `recenzaValideAlla`) alimenta la regola dei gruppi: senza, vale solo
* l'ordine di elencazione di ogni gruppo, cioè il comportamento di prima del #13.
*
* Ordine dei blocchi: uguale a `demo()` nell'oracolo (forza, composizione, cardio,
* recupero, stabilità), così un confronto riga per riga fra questo file e quello resta
* diretto.
*/
export function sottoDominiFisici(
m: Map<string, number>, eta: number | null, sesso: Sesso
m: Map<string, number>, eta: number | null, sesso: Sesso, recenza?: Map<string, number>
): Record<string, number> {
const out: Record<string, number> = {};
// --- Forza & Struttura ----------------------------------------------------
const handgrip = m.get('f_handgrip_kg');
if (handgrip !== undefined && eta !== null) out.handgrip = scoreHandgrip(handgrip, eta, sesso);
const pushup = m.get('f_pushup_rip');
if (pushup !== undefined && eta !== null) out.spinta = scorePushup(pushup, eta, sesso);
const pesoKg = m.get('f_peso_kg');
const altezzaCm = m.get('f_altezza_cm');
// Nessun BMI di ripiego (DECISIONI d nell'issue): senza peso E altezza, resta `undefined`
// e tutto ciò che lo richiede (sit-to-stand, 2km walk) non dà punteggio.
const bmi = (pesoKg !== undefined && altezzaCm !== undefined) ? pesoKg / (altezzaCm / 100) ** 2 : undefined;
// --- Forza & Struttura ----------------------------------------------------
// handgrip: media dx/sx (manuale) se entrambi presenti, altrimenti lo storico f_handgrip_kg.
const handgripMedia = mediaCoppia(m.get('f_handgrip_dx_kg'), m.get('f_handgrip_sx_kg'));
const handgripStorico = m.get('f_handgrip_kg');
if (eta !== null) {
const handgrip = sceglieAlternativa([
{ valore: handgripMedia !== undefined ? scoreHandgrip(handgripMedia, eta, sesso) : undefined,
idRecenza: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
{ valore: handgripStorico !== undefined ? scoreHandgrip(handgripStorico, eta, sesso) : undefined,
idRecenza: ['f_handgrip_kg'] },
], recenza);
if (handgrip !== undefined) out.handgrip = handgrip;
}
// spinta: panca con bilanciere (carico/rip/peso, tabella TIERS_BENCH) oppure push-up.
const pancaBilCarico = m.get('f_panca_bil_carico_kg');
const pancaBilRip = m.get('f_panca_bil_rip');
const pancaValore = (pancaBilCarico !== undefined && pesoKg !== undefined && pancaBilRip !== undefined)
? scoreSollevamentoSuPeso(pancaBilCarico, pesoKg, pancaBilRip, TIERS_BENCH_M, TIERS_BENCH_F, sesso)
: undefined;
const pushup = m.get('f_pushup_rip');
const pushupValore = (pushup !== undefined && eta !== null) ? scorePushup(pushup, eta, sesso) : undefined;
const spinta = sceglieAlternativa([
{ valore: pancaValore, idRecenza: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
{ valore: pushupValore, idRecenza: ['f_pushup_rip'] },
], recenza);
if (spinta !== undefined) out.spinta = spinta;
// trazione: invariata (solo f_row). f_trazioni_rip si registra ma non dà punteggio
// (nessuna tabella di riferimento nel manuale, DECISIONI a).
const rowCarico = m.get('f_row_carico_kg');
const rowRip = m.get('f_row_rip');
if (rowCarico !== undefined && pesoKg !== undefined && rowRip !== undefined) {
out.trazione = scoreSollevamentoSuPeso(rowCarico, pesoKg, rowRip, TIERS_ROW_M, TIERS_ROW_F, sesso);
}
// arti_inferiori: squat (invariato) oppure sit-to-stand (rip, età, sesso, bmi — SENZA
// ripiego: bmi assente = alternativa incompleta).
const squatCarico = m.get('f_squat_carico_kg');
const squatRip = m.get('f_squat_rip');
if (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined) {
out.arti_inferiori = scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso);
}
const squatValore = (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined)
? scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso)
: undefined;
const sitToStandRip = m.get('f_sit_to_stand_rip');
const sitToStandValore = (sitToStandRip !== undefined && eta !== null && bmi !== undefined)
? scoreSitToStand(sitToStandRip, eta, sesso, bmi)
: undefined;
const artiInferiori = sceglieAlternativa([
{ valore: squatValore, idRecenza: ['f_squat_carico_kg', 'f_squat_rip'] },
{ valore: sitToStandValore, idRecenza: ['f_sit_to_stand_rip'] },
], recenza);
if (artiInferiori !== undefined) out.arti_inferiori = artiInferiori;
// Il plank pesa su DUE assi (core in Forza & Struttura, plank in Stabilità & Mobilità):
// confermato da Nicola il 22/08, vedi il commento su PESI_ASSE in registro.ts. Stessa
// misura, stessa funzione, due chiavi nel risultato — non è una duplicazione per errore.
// f_plank_ginocchia_sec (ginocchia) si registra ma non dà punteggio (DECISIONI a).
const plank = m.get('f_plank_sec');
if (plank !== undefined) {
const punteggio = scorePlank(plank, sesso);
@@ -89,16 +185,29 @@ export function sottoDominiFisici(
if (whr !== undefined) out.whr = scoreWhr(whr, sesso);
// --- Cardio-Respiratorio -----------------------------------------------------
// VO2max: diretto se misurato, altrimenti stimato da MUTT, altrimenti dallo step test —
// il primo presente vince, come nella cascata di fonti dell'oracolo (che nella demo usa
// solo MUTT, ma le tre vie coesistono nel resto del file Python).
// vo2max: diretto, MUTT, MILFIT, 2km walk (UKK) o step test — in quest'ordine di
// priorità, la regola dei gruppi sceglie fra le alternative complete quella più recente.
const vo2Diretto = m.get('f_vo2max');
const muttVam = m.get('f_mutt_vam');
const milfitWatt = m.get('f_milfit_watt');
const walk2kmMin = m.get('f_walk2km_min');
const walk2kmFc = m.get('f_walk2km_fc_bpm');
const stepHr = m.get('f_step_hr_recovery');
let vo2max: number | undefined;
if (vo2Diretto !== undefined) vo2max = vo2Diretto;
else if (muttVam !== undefined) vo2max = vo2maxDaMutt(muttVam);
else if (stepHr !== undefined) vo2max = vo2maxDaStepTest(stepHr, sesso);
const vo2MuttValore = muttVam !== undefined ? vo2maxDaMutt(muttVam) : undefined;
const vo2MilfitValore = (milfitWatt !== undefined && pesoKg !== undefined)
? vo2maxDaMilfit(milfitWatt, pesoKg) : undefined;
const vo2WalkValore = (walk2kmMin !== undefined && walk2kmFc !== undefined && eta !== null && bmi !== undefined)
? vo2maxDa2kmWalk(walk2kmMin, walk2kmFc, eta, bmi) : undefined;
const vo2StepValore = stepHr !== undefined ? vo2maxDaStepTest(stepHr, sesso) : undefined;
const vo2max = sceglieAlternativa([
{ valore: vo2Diretto, idRecenza: ['f_vo2max'] },
{ valore: vo2MuttValore, idRecenza: ['f_mutt_vam'] },
{ valore: vo2MilfitValore, idRecenza: ['f_milfit_watt'] },
{ valore: vo2WalkValore, idRecenza: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
{ valore: vo2StepValore, idRecenza: ['f_step_hr_recovery'] },
], recenza);
if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso);
const spo2 = m.get('f_spo2_pct');
@@ -116,18 +225,47 @@ export function sottoDominiFisici(
const diastolica = m.get('f_pa_diastolica');
if (sistolica !== undefined && diastolica !== undefined) out.pressione = scorePressione(sistolica, diastolica);
const hrr = m.get('f_hrr_1min_bpm');
if (hrr !== undefined) out.hrr = scoreRecuperoCardiaco(hrr);
// hrr: MUTT (FC max - FC dopo 1', manuale) oppure lo storico f_hrr_1min_bpm (già un calo).
const muttFcMax = m.get('f_mutt_fc_max_bpm');
const muttFc1min = m.get('f_mutt_fc_1min_bpm');
const hrrMuttValore = (muttFcMax !== undefined && muttFc1min !== undefined)
? scoreRecuperoCardiaco(muttFcMax - muttFc1min) : undefined;
const hrrStorico = m.get('f_hrr_1min_bpm');
const hrrStoricoValore = hrrStorico !== undefined ? scoreRecuperoCardiaco(hrrStorico) : undefined;
const hrr = sceglieAlternativa([
{ valore: hrrMuttValore, idRecenza: ['f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm'] },
{ valore: hrrStoricoValore, idRecenza: ['f_hrr_1min_bpm'] },
], recenza);
if (hrr !== undefined) out.hrr = hrr;
// --- Stabilità & Mobilità Funzionale ------------------------------------------
const flamingo = m.get('f_flamingo_cadute');
if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo);
// flamingo: media dx/sx a occhi APERTI (la tabella Eurofit è a occhi aperti, DECISIONI b;
// occhi chiusi si registra ma non dà punteggio) oppure lo storico f_flamingo_cadute.
const flamingoOaMedia = mediaCoppia(m.get('f_flamingo_oa_dx'), m.get('f_flamingo_oa_sx'));
const flamingoOaValore = flamingoOaMedia !== undefined ? scoreFlamingo(flamingoOaMedia) : undefined;
const flamingoCaduteStorico = m.get('f_flamingo_cadute');
const flamingoStoricoValore = flamingoCaduteStorico !== undefined ? scoreFlamingo(flamingoCaduteStorico) : undefined;
const flamingo = sceglieAlternativa([
{ valore: flamingoOaValore, idRecenza: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
{ valore: flamingoStoricoValore, idRecenza: ['f_flamingo_cadute'] },
], recenza);
if (flamingo !== undefined) out.flamingo = flamingo;
const sitReach = m.get('f_sit_reach_cm');
if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso);
const backScratch = m.get('f_back_scratch_cm');
if (backScratch !== undefined && eta !== null) out.back_scratch = scoreBackScratch(backScratch, eta, sesso);
// back_scratch: media dx/sx (manuale) oppure lo storico f_back_scratch_cm.
const backMedia = mediaCoppia(m.get('f_back_scratch_dx_cm'), m.get('f_back_scratch_sx_cm'));
const backStorico = m.get('f_back_scratch_cm');
if (eta !== null) {
const backScratch = sceglieAlternativa([
{ valore: backMedia !== undefined ? scoreBackScratch(backMedia, eta, sesso) : undefined,
idRecenza: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm'] },
{ valore: backStorico !== undefined ? scoreBackScratch(backStorico, eta, sesso) : undefined,
idRecenza: ['f_back_scratch_cm'] },
], recenza);
if (backScratch !== undefined) out.back_scratch = backScratch;
}
const outreach = m.get('f_spalla_outreach_deg');
const buckling = m.get('f_spalla_buckling_deg');
@@ -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 };
}
+78 -5
View File
@@ -110,9 +110,13 @@ const QUESTIONARIO: VoceRegistro[] = [
const FISICI: VoceRegistro[] = [
{ test_id: 'f_peso_kg', etichetta: 'Peso corporeo', unita: 'kg', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 30, range_max: 250, attivo_da: DA },
// Dismesso il 28/09 (#13): sostituito da f_handgrip_dx_kg/f_handgrip_sx_kg (media dx/sx,
// manuale di Nicola). Mai cancellato: le sessioni passate lo usano ancora (limite #12);
// sottoDominiFisici lo legge come alternativa storica in fisici.ts.
{ test_id: 'f_handgrip_kg', etichetta: 'Forza di presa (handgrip, dinamometro)', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA, attivo_a: '2026-09-28' },
// Etichetta corretta (#13): il manuale prevede push-up sulle ginocchia per le donne.
{ test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime (donne sulle ginocchia)', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 150, attivo_da: DA },
{ test_id: 'f_row_carico_kg', etichetta: 'Trazione/rematore, carico', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA },
@@ -144,18 +148,87 @@ const FISICI: VoceRegistro[] = [
asse: 'Recupero & Sistema Nervoso', range_min: 70, range_max: 250, attivo_da: DA },
{ test_id: 'f_pa_diastolica', etichetta: 'Pressione arteriosa diastolica', unita: 'mmHg', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 40, range_max: 150, attivo_da: DA },
// Dismesso il 28/09 (#13): sostituito da f_mutt_fc_max_bpm - f_mutt_fc_1min_bpm (manuale).
// Mai cancellato, resta come alternativa storica in fisici.ts (hrr).
{ test_id: 'f_hrr_1min_bpm', etichetta: 'Recupero cardiaco a 1 minuto post-sforzo', unita: 'bpm', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA },
asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA, attivo_a: '2026-09-28' },
// Dismesso il 28/09 (#13): sostituito da media f_flamingo_oa_dx/f_flamingo_oa_sx (occhi
// aperti, manuale). Mai cancellato, resta come alternativa storica in fisici.ts (flamingo).
{ test_id: 'f_flamingo_cadute', etichetta: 'Test del fenicottero, numero di cadute', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA },
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA, attivo_a: '2026-09-28' },
{ test_id: 'f_sit_reach_cm', etichetta: 'Sit and reach', unita: 'cm', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: -30, range_max: 50, attivo_da: DA },
// Dismesso il 28/09 (#13): sostituito da media f_back_scratch_dx_cm/f_back_scratch_sx_cm.
// Mai cancellato, resta come alternativa storica in fisici.ts (back_scratch).
{ test_id: 'f_back_scratch_cm', etichetta: 'Back scratch (mobilità spalle)', unita: 'cm', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA },
asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA, attivo_a: '2026-09-28' },
{ test_id: 'f_spalla_outreach_deg', etichetta: 'Wellness Tower spalla, outreach', unita: 'deg', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA },
{ test_id: 'f_spalla_buckling_deg', etichetta: 'Wellness Tower spalla, buckling', unita: 'deg', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA },
// Voci nuove del 28/09 (#13), dal manuale batteria test completo di Nicola (27/09/2026).
// Alcune alimentano sottoDominiFisici (vedi motore/fisici.ts), altre si registrano soltanto
// perche' il manuale non da' una tabella di riferimento per loro (DECISIONI a nell'issue):
// panca manubri, trazioni, plank sulle ginocchia, 6MWT, flamingo occhi chiusi, FC per step
// MUTT, FC a riposo, FC MUTT a 2'.
{ test_id: 'f_altezza_cm', etichetta: 'Altezza', unita: 'cm', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 100, range_max: 230, attivo_da: DA },
{ test_id: 'f_fc_riposo_bpm', etichetta: 'Frequenza cardiaca a riposo', unita: 'bpm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 30, range_max: 130, attivo_da: DA },
{ test_id: 'f_back_scratch_dx_cm', etichetta: 'Back scratch (mobilità spalle), lato destro', unita: 'cm',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
{ test_id: 'f_back_scratch_sx_cm', etichetta: 'Back scratch (mobilità spalle), lato sinistro', unita: 'cm',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
{ test_id: 'f_flamingo_oa_dx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato destro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oa_sx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato sinistro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oc_dx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato destro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_flamingo_oc_sx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato sinistro',
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
{ test_id: 'f_handgrip_dx_kg', etichetta: 'Forza di presa (handgrip), lato destro', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
{ test_id: 'f_handgrip_sx_kg', etichetta: 'Forza di presa (handgrip), lato sinistro', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
{ test_id: 'f_panca_man_carico_kg', etichetta: 'Panca con manubri, carico (per manubrio)', unita: 'kg',
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_panca_man_rip', etichetta: 'Panca con manubri, ripetizioni', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
{ test_id: 'f_panca_bil_carico_kg', etichetta: 'Panca con bilanciere, carico', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA },
{ test_id: 'f_panca_bil_rip', etichetta: 'Panca con bilanciere, ripetizioni', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
{ test_id: 'f_trazioni_rip', etichetta: 'Trazioni alla sbarra, ripetizioni massime', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 60, attivo_da: DA },
// Etichetta corretta (#13): il test dura 1 minuto, non 30 secondi.
{ test_id: 'f_sit_to_stand_rip', etichetta: 'Sit-to-stand, ripetizioni in 1 minuto (sedia 43-45 cm)', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 80, attivo_da: DA },
{ test_id: 'f_plank_ginocchia_sec', etichetta: 'Plank sulle ginocchia, tenuta', unita: 'sec', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 600, attivo_da: DA },
{ test_id: 'f_6mwt_m', etichetta: '6 Minute Walk Test, distanza percorsa', unita: 'm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 50, range_max: 1200, attivo_da: DA },
{ test_id: 'f_walk2km_min', etichetta: 'Camminata 2 km (UKK), tempo', unita: 'min', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 8, range_max: 40, attivo_da: DA },
{ test_id: 'f_walk2km_fc_bpm', etichetta: 'Camminata 2 km (UKK), frequenza cardiaca finale', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 60, range_max: 220, attivo_da: DA },
{ test_id: 'f_milfit_watt', etichetta: 'MILFIT, potenza', unita: 'watt', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 20, range_max: 500, attivo_da: DA },
{ test_id: 'f_mutt_fc_max_bpm', etichetta: 'MUTT, frequenza cardiaca massima', unita: 'bpm', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
{ test_id: 'f_mutt_fc_1min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 1\' di recupero', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
{ test_id: 'f_mutt_fc_2min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 2\' di recupero', unita: 'bpm',
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
// Gli otto step del MUTT, un km/h alla volta da 7,5 a 14,5: solo registrati (DECISIONI a),
// nessuna tabella di riferimento nel manuale per la FC punto per punto.
...([7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5].map((kmh): VoceRegistro => ({
test_id: `f_mutt_fc_${String(kmh).replace('.', '_')}`,
etichetta: `MUTT, frequenza cardiaca allo step ${kmh} km/h`,
unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio',
range_min: 40, range_max: 230, attivo_da: DA,
}))),
];
export function seedRegistro(db: Database.Database): void {
@@ -2,7 +2,11 @@ import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro';
import { salvaCheckup, correggiCheckup } from '../../../../lib/longevity/gestionale';
import {
salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
type SezioneCheckup,
} from '../../../../lib/longevity/gestionale';
import { aggiungiVoce, TESTO_MAX as NOTA_TESTO_MAX } from '../../../../lib/longevity/giornale';
import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false;
@@ -52,6 +56,23 @@ export function misureDalForm(
return { ok: true, misure };
}
/**
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
* `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non
* ha ancora marcato nulla, o un refuso) resta assente dalla mappa: `testIdScartatiDalleAlternative`
* non scarta NULLA per quella sezione, invece di ripiegare sulla prima alternativa e buttare
* via in silenzio campi compilati di un'altra — correzione del passo 3 su #13.
*/
function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> {
const scelte = new Map<string, string>();
for (const s of sezioni) {
if (s.tipo !== 'alternative') continue;
const v = form.get(`alt_${s.chiave}`);
if (typeof v === 'string' && v !== '') scelte.set(s.chiave, v);
}
return scelte;
}
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCodeForm = String(form.get('client_code') ?? '').trim();
@@ -105,22 +126,42 @@ export const POST: APIRoute = async ({ request, locals }) => {
const parse = misureDalForm(form, campiAttivi);
if (!parse.ok) return erroreAllaScheda(parse.errore);
// Sezioni ad alternative (#13): si tengono solo i campi dell'alternativa scelta dalla
// radio, i compilati di altre alternative della stessa sezione si scartano — anche se il
// trainer li ha riempiti (JS disattivato, o un fieldset lasciato visibile per errore).
const { sezioni } = strutturaCheckup(db, data);
const scartati = testIdScartatiDalleAlternative(sezioni, sceltePerAlternative(sezioni, form));
const misure = parse.misure.filter((m) => !scartati.has(m.test_id));
// L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware:
// solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del
// client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta.
const operatore = locals.user!.username;
const eta = etaDelCliente(clientCode, data);
// Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro),
// quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge.
const nota = String(form.get('nota') ?? '').trim();
// Validata PRIMA di salvare (correzione del passo 3 su #13): se aggiungiVoce fallisse DOPO
// il check-up (nota troppo lunga), il check-up resterebbe salvato ma la nota andrebbe persa
// in silenzio — stesso limite di aggiungiVoce (giornale.ts), non un numero duplicato qui.
if (nota.length > NOTA_TESTO_MAX) {
return erroreAllaScheda(`Il testo della nota non può superare ${NOTA_TESTO_MAX} caratteri.`);
}
if (sessioneId !== null) {
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure: parse.misure });
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
}
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure });
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
// Voce di giornale SOLO dopo il salvataggio riuscito (#13): la lunghezza è già validata
// sopra, la data è la stessa già validata per il check-up — aggiungiVoce qui non dovrebbe
// più rifiutare nulla.
if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota });
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
@@ -9,7 +9,9 @@
import Longevity from '../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import { campiDelCheckup, misureDellaSessione } from '../../../../../lib/longevity/gestionale';
import {
misureDellaSessione, strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup,
} from '../../../../../lib/longevity/gestionale';
export const prerender = false;
@@ -30,8 +32,13 @@ if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client
}
const dataForm = sessione?.data ?? oggi;
const gruppi = campiDelCheckup(db, dataForm);
const modulo = strutturaCheckup(db, dataForm);
const valori = new Map(sessione?.misure.map((m) => [m.test_id, m.valore_num]) ?? []);
// Preselezione (#13): in correzione le misure della sessione, in un check-up nuovo l'ultimo
// check-up del cliente — altrimenti la prima alternativa (il livello neofita).
const valoriPreselezione = sessione
? new Map([...valori.entries()].filter((kv): kv is [string, number] => kv[1] !== null))
: valoriUltimoCheckup(db, cliente.client_code);
const errore = Astro.url.searchParams.get('errore');
---
<Longevity
@@ -55,17 +62,73 @@ const errore = Astro.url.searchParams.get('errore');
<input type="date" name="data" value={dataForm} required />
</label>
{gruppi.map((g) => (
{modulo.sezioni.map((s) => (
<section class="lg-form__blocco">
<h2>{g.asse}</h2>
{g.campi.map((c) => (
<h2>{s.titolo}</h2>
{s.tipo === 'campi' ? s.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
)) : (
<div class="lg-form__alternative" data-alt-sezione={s.chiave}>
{s.alternative.map((alt) => (
<fieldset class="lg-form__alternativa" data-alt-fieldset={alt.chiave}>
<legend>
<label>
<input
type="radio" name={`alt_${s.chiave}`} value={alt.chiave}
checked={alt.chiave === alternativaSelezionata(s, valoriPreselezione)}
/>
{alt.nome}
</label>
</legend>
{alt.soglia && <p class="lg-form__soglia">{alt.soglia}</p>}
{alt.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
{alt.dettagli && alt.dettagli.length > 0 && (
<details>
<summary>Frequenza cardiaca per step (7,5-14,5 km/h)</summary>
{alt.dettagli.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</details>
)}
</fieldset>
))}
</div>
)}
</section>
))}
{modulo.altreMisure.length > 0 && (
<section class="lg-form__blocco">
<h2>Altre misure</h2>
{modulo.altreMisure.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</section>
))}
)}
{!sessione && (
<section class="lg-form__blocco">
<h2>Note operatore</h2>
<label class="lg-field">
<span>Nota per il giornale di bordo (facoltativa)</span>
<textarea name="nota" rows="4" maxlength="5000"></textarea>
</label>
</section>
)}
<div class="lg-form__nav">
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
@@ -73,3 +136,19 @@ const errore = Astro.url.searchParams.get('errore');
</div>
</form>
</Longevity>
<script>
// Nasconde i fieldset delle alternative non scelte (#13): senza JS restano tutti visibili
// e il form funziona lo stesso — lo scarto vero è server-side (checkup.ts), questo è solo
// per non far compilare al trainer due protocolli della stessa sotto-metrica per sbaglio.
document.querySelectorAll<HTMLElement>('[data-alt-sezione]').forEach((sezioneEl) => {
const aggiorna = () => {
const scelta = sezioneEl.querySelector<HTMLInputElement>('input[type=radio]:checked')?.value;
sezioneEl.querySelectorAll<HTMLElement>('[data-alt-fieldset]').forEach((fs) => {
fs.hidden = fs.dataset.altFieldset !== scelta;
});
};
sezioneEl.addEventListener('change', aggiorna);
aggiorna();
});
</script>
+9
View File
@@ -156,6 +156,15 @@
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
/* Sezioni ad alternative del check-up (#13): un fieldset per protocollo, radio in legenda. */
.lg-form__alternative { display: flex; flex-direction: column; gap: 12px; }
.lg-form__alternativa { border: 1px solid var(--lg-line); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
.lg-form__alternativa legend { padding: 0 4px; }
.lg-form__alternativa legend label { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--c-heading); }
.lg-form__soglia { margin: 0; font-size: .8rem; color: var(--c-text-light); }
.lg-form__alternativa details { font-size: .85rem; }
.lg-form__alternativa summary { cursor: pointer; color: var(--c-text-light); }
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
@@ -0,0 +1,156 @@
// Test B (#13): l'API checkup.ts scarta i campi di un'alternativa non scelta e aggiunge una
// voce di giornale dalla nota operatore. File separato (stesso motivo di
// correzione-checkup-api.test.ts): l'endpoint usa il singleton getLongevityDb(), e
// LONGEVITY_DB_PATH va impostata PRIMA che lo apra — non nello stesso file di altri test che
// lo inizializzano prima. A differenza di correzione-checkup-api.test.ts (un solo test) qui
// ce ne sono diversi: il singleton si apre UNA volta per tutto il file (beforeAll/afterAll,
// non chiuso e riaperto a ogni test — getLongevityDb() lo terrebbe chiuso dopo il primo
// close()), e ogni test usa un client_code proprio per non leggere le voci di giornale di un
// altro test.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { vociDelGiornale } from '../../src/lib/longevity/giornale';
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
// La data d'oggi vera del sistema che esegue i test (non una finta): un checkup con questa
// data non è "nel futuro" per la validazione dell'API.
const OGGI = new Date().toISOString().slice(0, 10);
let dir: string;
let primaLg: string | undefined;
let lg: ReturnType<typeof getLongevityDb>;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'lg-api-alt-test-'));
primaLg = process.env.LONGEVITY_DB_PATH;
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
lg = getLongevityDb();
seedRegistro(lg);
seedPesi(lg, MODEL_VERSION);
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005', 'ISL-0006']) {
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
}
});
afterAll(() => {
lg.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
});
describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () => {
it('scarta i campi di un\'alternativa non scelta, anche se compilati', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0001');
form.set('data', OGGI);
form.set('alt_spinta', 'pushup'); // scelta: push-up
form.set('f_pushup_rip', '15'); // dell'alternativa scelta: deve restare
form.set('f_panca_bil_carico_kg', '60'); // di un'altra alternativa (bilanciere): va scartato
form.set('f_panca_bil_rip', '5');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/salvato=/);
const sessioneId = Number(new URL(loc, 'http://localhost').searchParams.get('salvato'));
const righe = lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[];
const ids = righe.map((r) => r.test_id);
expect(ids).toContain('f_pushup_rip');
expect(ids).not.toContain('f_panca_bil_carico_kg');
expect(ids).not.toContain('f_panca_bil_rip');
});
it('senza radio inviata (nessun alt_*), non si scarta nulla: un campo di un\'alternativa non-prima resta salvato (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0002');
form.set('data', OGGI);
// nessun alt_core: 'ginocchia' è la prima alternativa di "Core", f_plank_sec è 'pieno'
// (la seconda) — prima della correzione questo campo veniva scartato in silenzio.
form.set('f_plank_sec', '90');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
const sessioneId = Number(new URL(res.headers.get('Location')!, 'http://localhost').searchParams.get('salvato'));
const ids = (lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[])
.map((r) => r.test_id);
expect(ids).toContain('f_plank_sec');
});
it('nota operatore non vuota: aggiunge una voce di giornale con la data del check-up e l\'operatore come autore', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0003');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', 'Cliente motivato, riprende dopo la pausa estiva.');
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const voci = vociDelGiornale(lg, 'ISL-0003');
expect(voci.length).toBe(1);
expect(voci[0].testo).toBe('Cliente motivato, riprende dopo la pausa estiva.');
expect(voci[0].data).toBe(OGGI);
expect(voci[0].autore).toBe('nicola');
});
it('nota operatore vuota (o assente): nessuna voce di giornale', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0004');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', ' '); // solo spazi: trim -> vuoto
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(vociDelGiornale(lg, 'ISL-0004').length).toBe(0);
});
it('una correzione (sessione_id nel form) non porta il campo nota: nessuna voce anche se la sessione ha una data valida', async () => {
const esito = salvaCheckup(lg, {
client_code: 'ISL-0005', data: OGGI, eta: 30, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 70 }],
});
if (!esito.ok) throw new Error(esito.errore);
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0005');
form.set('sessione_id', String(esito.sessioneId));
form.set('data', OGGI);
form.set('f_peso_kg', '71');
// niente campo "nota": la pagina non lo mostra in correzione
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0);
});
it('nota oltre 5000 caratteri: errore PRIMA di salvare, nessuna sessione né voce di giornale (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0006');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', 'x'.repeat(5001));
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
expect(res.headers.get('Location')).toMatch(/errore=/);
const sessioni = lg.prepare(`SELECT COUNT(*) AS n FROM sessioni WHERE client_code = ?`).get('ISL-0006') as { n: number };
expect(sessioni.n).toBe(0); // niente scritto: né la sessione né la misura f_peso_kg
expect(vociDelGiornale(lg, 'ISL-0006').length).toBe(0);
});
});
@@ -0,0 +1,112 @@
// Test B (#13): la struttura del modulo di check-up (strutturaCheckup) e la preselezione
// delle alternative (alternativaSelezionata) — tutto in memoria, nessun singleton. I test
// dell'API (campi scartati, nota->giornale) sono in checkup-alternative-api.test.ts: stesso
// motivo di correzione-checkup-api.test.ts, l'endpoint usa il singleton getLongevityDb().
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import {
strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup, salvaCheckup,
type SezioneCheckup,
} from '../../src/lib/longevity/gestionale';
function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso);
return db;
}
describe('strutturaCheckup: ordine e contenuto delle sezioni', () => {
it('le sezioni compaiono nell\'ordine del manuale: Pre-test, Mobilità, Equilibrio, Presa, Core, Forza spinta, Forza trazione, Arti inferiori, Aerobico, Strumenti', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
expect(modulo.sezioni.map((s) => s.titolo)).toEqual([
'Pre-test', 'Mobilità', 'Equilibrio', 'Presa', 'Core', 'Forza spinta',
'Forza trazione', 'Arti inferiori', 'Aerobico', 'Strumenti (Wellness Tower, Stress Index)',
]);
});
it('pressione e SpO2 non compaiono in nessuna sezione né in Altre misure', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const tuttiGliId = modulo.sezioni.flatMap((s) =>
s.tipo === 'campi' ? s.campi.map((c) => c.id)
: s.alternative.flatMap((a) => [...a.campi.map((c) => c.id), ...(a.dettagli?.map((c) => c.id) ?? [])])
);
for (const id of ['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']) {
expect(tuttiGliId).not.toContain(id);
expect(modulo.altreMisure.map((c) => c.id)).not.toContain(id);
}
});
it('su una data vecchia (prima del 28/09) un test dismesso finisce in Altre misure, mai perso', () => {
const db = dbPronto();
// 2026-09-27: i vecchi test_id storici sono ancora attivi (attivo_a: 2026-09-28),
// e non compaiono in nessuna sezione di STRUTTURA_RAW (che usa solo i nuovi id).
const modulo = strutturaCheckup(db, '2026-09-27');
const altreIds = modulo.altreMisure.map((c) => c.id);
expect(altreIds).toContain('f_handgrip_kg');
expect(altreIds).toContain('f_back_scratch_cm');
expect(altreIds).toContain('f_flamingo_cadute');
expect(altreIds).toContain('f_hrr_1min_bpm');
});
it('su una data recente (dopo il 28/09) i test dismessi non sono più attivi: spariscono anche da Altre misure', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const altreIds = modulo.altreMisure.map((c) => c.id);
expect(altreIds).not.toContain('f_handgrip_kg');
});
it('il MUTT porta i suoi otto step FC come dettagli, non come campi normali', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const aerobico = modulo.sezioni.find((s) => s.titolo === 'Aerobico');
expect(aerobico?.tipo).toBe('alternative');
if (aerobico?.tipo !== 'alternative') throw new Error('atteso alternative');
const mutt = aerobico.alternative.find((a) => a.chiave === 'mutt')!;
expect(mutt.dettagli?.length).toBe(8);
expect(mutt.dettagli?.map((c) => c.id)).toContain('f_mutt_fc_7_5');
expect(mutt.campi.map((c) => c.id)).not.toContain('f_mutt_fc_7_5');
});
});
describe('alternativaSelezionata: quale alternativa preselezionare', () => {
it('senza corrispondenza in valori, vince la prima alternativa elencata (neofita)', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
expect(alternativaSelezionata(spinta, new Map())).toBe('manubri');
});
it('con un valore di un\'alternativa più avanti nell\'elenco, quella vince', () => {
const db = dbPronto();
const modulo = strutturaCheckup(db, '2026-09-28');
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
const valori = new Map([['f_panca_bil_carico_kg', 60]]);
expect(alternativaSelezionata(spinta, valori)).toBe('bilanciere');
});
it('valoriUltimoCheckup: vuota se il cliente non ha ancora un check-up, altrimenti le misure dell\'ultimo', () => {
const db = dbPronto();
expect(valoriUltimoCheckup(db, 'ISL-0001').size).toBe(0);
salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-01-10', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_panca_bil_carico_kg', valore_num: 50 }, { test_id: 'f_panca_bil_rip', valore_num: 5 }],
});
const ultima = valoriUltimoCheckup(db, 'ISL-0001');
expect(ultima.get('f_panca_bil_carico_kg')).toBe(50);
// un secondo check-up più recente sovrascrive quale sia "l'ultimo"
salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-03-10', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_pushup_rip', valore_num: 12 }],
});
const piuRecente = valoriUltimoCheckup(db, 'ISL-0001');
expect(piuRecente.get('f_pushup_rip')).toBe(12);
expect(piuRecente.has('f_panca_bil_carico_kg')).toBe(false);
});
});
@@ -0,0 +1,179 @@
// La regola dei gruppi (issue #13, manuale batteria test di Nicola del 27/09/2026): per
// alcune sotto-metriche (handgrip, spinta, arti_inferiori, vo2max, hrr, flamingo,
// back_scratch) sottoDominiFisici sceglie fra piu' alternative COMPLETE quella la cui
// misura piu' recente vince (`recenza`, opzionale). Questo file copre la regola da sola,
// con `sottoDominiFisici` chiamata direttamente (mappe di misure a mano, non un DB): i
// valori attesi si calcolano con le STESSE funzioni di test-fisici.ts (gia' verificate
// contro l'oracolo in motore-fisici.test.ts), mai a mano.
import { describe, it, expect } from 'vitest';
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
import * as F from '../../src/lib/longevity/motore/test-fisici';
describe('un caso per ogni alternativa che da punteggio', () => {
it('handgrip: media dx/sx (manuale)', () => {
const m = new Map([['f_handgrip_dx_kg', 45], ['f_handgrip_sx_kg', 51]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBe(F.scoreHandgrip(48, 38, 'M')); // media (45+51)/2 = 48
});
it('handgrip: storico f_handgrip_kg (ancora letto)', () => {
const m = new Map([['f_handgrip_kg', 44]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBe(F.scoreHandgrip(44, 38, 'M'));
});
it('spinta: panca con bilanciere (carico, ripetizioni, peso)', () => {
const m = new Map([['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
});
it('spinta: push-up (invariato)', () => {
const m = new Map([['f_pushup_rip', 24]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
});
it('arti_inferiori: sit-to-stand (ripetizioni, eta, sesso, bmi da peso/altezza)', () => {
const m = new Map([['f_sit_to_stand_rip', 14], ['f_peso_kg', 65], ['f_altezza_cm', 160]]);
const r = sottoDominiFisici(m, 65, 'F');
const bmi = 65 / (160 / 100) ** 2;
expect(r.arti_inferiori).toBe(F.scoreSitToStand(14, 65, 'F', bmi));
});
it('vo2max: MILFIT (potenza, peso)', () => {
const m = new Map([['f_milfit_watt', 150], ['f_peso_kg', 80]]);
const r = sottoDominiFisici(m, 40, 'M');
const vo2 = F.vo2maxDaMilfit(150, 80);
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
});
it('vo2max: camminata 2 km UKK (tempo, fc, eta, bmi)', () => {
const m = new Map([
['f_walk2km_min', 18], ['f_walk2km_fc_bpm', 130], ['f_peso_kg', 80], ['f_altezza_cm', 180],
]);
const r = sottoDominiFisici(m, 40, 'M');
const bmi = 80 / (180 / 100) ** 2;
const vo2 = F.vo2maxDa2kmWalk(18, 130, 40, bmi);
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
});
it('hrr: MUTT (FC max - FC dopo 1 minuto, manuale)', () => {
const m = new Map([['f_mutt_fc_max_bpm', 180], ['f_mutt_fc_1min_bpm', 140]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(40)); // calo = 180 - 140
});
it('hrr: storico f_hrr_1min_bpm (gia un calo, ancora letto)', () => {
const m = new Map([['f_hrr_1min_bpm', 18]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(18));
});
it('flamingo: media dx/sx occhi aperti (manuale)', () => {
const m = new Map([['f_flamingo_oa_dx', 3], ['f_flamingo_oa_sx', 5]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBe(F.scoreFlamingo(4)); // media (3+5)/2 = 4
});
it('flamingo: storico f_flamingo_cadute (ancora letto)', () => {
const m = new Map([['f_flamingo_cadute', 6]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBe(F.scoreFlamingo(6));
});
it('back_scratch: media dx/sx (manuale)', () => {
const m = new Map([['f_back_scratch_dx_cm', -8], ['f_back_scratch_sx_cm', -4]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBe(F.scoreBackScratch(-6, 45, 'F')); // media (-8-4)/2 = -6
});
it('back_scratch: storico f_back_scratch_cm (ancora letto)', () => {
const m = new Map([['f_back_scratch_cm', -10]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBe(F.scoreBackScratch(-10, 45, 'F'));
});
it('la cascata del vo2max per una sessione con solo f_vo2max resta uguale', () => {
const m = new Map([['f_vo2max', 45]]);
const r = sottoDominiFisici(m, 40, 'M');
expect(r.vo2max).toBe(F.scoreVo2max(45, 40, 'M'));
});
});
describe('un lato solo, senza storico, non da punteggio', () => {
it('handgrip solo destro: nessun punteggio (nessuna media possibile, nessuno storico)', () => {
const m = new Map([['f_handgrip_dx_kg', 45]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.handgrip).toBeUndefined();
});
it('flamingo occhi aperti solo sinistro: nessun punteggio', () => {
const m = new Map([['f_flamingo_oa_sx', 4]]);
const r = sottoDominiFisici(m, 38, 'M');
expect(r.flamingo).toBeUndefined();
});
it('back_scratch solo sinistro: nessun punteggio', () => {
const m = new Map([['f_back_scratch_sx_cm', -6]]);
const r = sottoDominiFisici(m, 45, 'F');
expect(r.back_scratch).toBeUndefined();
});
});
describe('la regola dei gruppi sceglie per recenza, non solo per ordine di priorita', () => {
it('pushup a gennaio + panca a marzo: vale panca (piu recente)', () => {
const m = new Map([
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
]);
// recenza: posizione 0 = piu recente. Panca (marzo) piu recente del pushup (gennaio).
const recenza = new Map([
['f_panca_bil_carico_kg', 0], ['f_panca_bil_rip', 0], ['f_pushup_rip', 1],
]);
const r = sottoDominiFisici(m, 38, 'M', recenza);
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
});
it('panca a gennaio + pushup a marzo: vale pushup (piu recente) — il contrario del caso sopra', () => {
const m = new Map([
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
]);
const recenza = new Map([
['f_pushup_rip', 0], ['f_panca_bil_carico_kg', 1], ['f_panca_bil_rip', 1],
]);
const r = sottoDominiFisici(m, 38, 'M', recenza);
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
});
it('alternativa incompleta ma piu recente (sit-to-stand senza altezza) non entra: vale lo squat precedente', () => {
const m = new Map([
['f_sit_to_stand_rip', 14], // manca f_altezza_cm: bmi non calcolabile, alternativa incompleta
['f_squat_carico_kg', 100], ['f_squat_rip', 5], ['f_peso_kg', 80],
]);
// sit-to-stand risulterebbe il piu recente SE fosse completo — ma non lo e', quindi
// resta escluso dal gruppo a prescindere dalla recenza (regola: solo le alternative
// complete entrano).
const recenza = new Map([
['f_sit_to_stand_rip', 0], ['f_squat_carico_kg', 1], ['f_squat_rip', 1],
]);
const r = sottoDominiFisici(m, 65, 'M', recenza);
expect(r.arti_inferiori).toBe(F.scoreSollevamentoSuPeso(100, 80, 5, F.TIERS_SQUAT_M, F.TIERS_SQUAT_F, 'M'));
});
});
describe('i test "solo registrati" (nessuna tabella nel manuale) non producono chiavi', () => {
it('panca manubri, trazioni, plank ginocchia, 6MWT, flamingo occhi chiusi: nessuna chiave nel risultato', () => {
const m = new Map([
['f_panca_man_carico_kg', 12], ['f_panca_man_rip', 10],
['f_trazioni_rip', 8],
['f_plank_ginocchia_sec', 90],
['f_6mwt_m', 550],
['f_flamingo_oc_dx', 10], ['f_flamingo_oc_sx', 12],
['f_mutt_fc_2min_bpm', 110],
['f_fc_riposo_bpm', 60],
]);
const r = sottoDominiFisici(m, 38, 'M');
// nessuna di queste misure alimenta una funzione di scoring: il risultato resta {}
expect(r).toEqual({});
});
});
@@ -16,6 +16,7 @@ import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione, sottoDominiDellaSessione, misureValideAlla } from '../../src/lib/longevity/motore';
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
import { campiDelQuestionario } from '../../src/lib/longevity/vista';
import * as F from '../../src/lib/longevity/motore/test-fisici'; // per i valori attesi della regola dei gruppi (#13)
function dbPronto(clientCode: string, sesso: 'M' | 'F' = 'M') {
const db = createLongevityDb(':memory:');
@@ -232,6 +233,40 @@ describe('un check-up e un questionario sono sessioni separate, ma un cliente e
});
});
describe('la regola dei gruppi (#13) su un DB vero: due sessioni, lo score usa il test piu recente', () => {
it('pushup a gennaio, panca a marzo: il check-up di marzo usa la panca', () => {
const db = dbPronto('ISL-0030', 'M');
const sGennaio = apriSessione(db, { client_code: 'ISL-0030', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sGennaio, 'manuale', [
{ test_id: 'f_peso_kg', valore_num: 80 },
{ test_id: 'f_pushup_rip', valore_num: 24 },
]);
const sMarzo = apriSessione(db, { client_code: 'ISL-0030', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sMarzo, 'manuale', [
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
]);
// misureValideAlla porta avanti ANCHE il pushup di gennaio (mai sovrascritto): al check-up
// di marzo il gruppo "spinta" vede entrambe le alternative complete, e vince la piu recente.
const sd = sottoDominiDellaSessione(db, sMarzo);
expect(sd.spinta).toBeCloseTo(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'), 5);
});
it('panca a gennaio, pushup a marzo: il check-up di marzo usa il pushup (il contrario del caso sopra)', () => {
const db = dbPronto('ISL-0031', 'M');
const sGennaio = apriSessione(db, { client_code: 'ISL-0031', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sGennaio, 'manuale', [
{ test_id: 'f_peso_kg', valore_num: 80 },
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
]);
const sMarzo = apriSessione(db, { client_code: 'ISL-0031', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sMarzo, 'manuale', [{ test_id: 'f_pushup_rip', valore_num: 24 }]);
const sd = sottoDominiDellaSessione(db, sMarzo);
expect(sd.spinta).toBeCloseTo(F.scorePushup(24, 38, 'M'), 5);
});
});
describe('campiDelQuestionario resta un modulo di sole domande', () => {
it('nessun campo f_* (test fisico) compare fra i campi del questionario', () => {
const db = createLongevityDb(':memory:');