longevity: #14 unità A, cambi di test solo fra alternative

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 06:47:11 +00:00
parent 67ad9701c1
commit a883a60a83
2 changed files with 131 additions and 57 deletions
+81 -57
View File
@@ -31,7 +31,13 @@ import type Database from 'better-sqlite3';
* mai come una linea a sé stante. */ * mai come una linea a sé stante. */
type LineaCatalogo = { testId: string; extraTestId?: string }; type LineaCatalogo = { testId: string; extraTestId?: string };
type GraficoCatalogo = { titolo: string; linee: LineaCatalogo[]; soloTrainer?: boolean }; /** `gruppo`: la chiave che dice quali grafici sono ALTERNATIVI fra loro dentro una sezione
* (stessa capacità, protocolli diversi — es. Rematore/Trazioni, Handgrip/Handgrip storico:
* le stesse alternative di STRUTTURA_RAW in gestionale.ts, tipo 'alternative', più i test
* storici dismessi). Solo i grafici con lo stesso `gruppo` entrano nel calcolo di `cambi`;
* un grafico senza `gruppo` (es. Sit and reach, che non è un'alternativa a Back scratch) non
* genera né interrompe un cambio — vedi `cambiDellaSezione`. */
type GraficoCatalogo = { titolo: string; linee: LineaCatalogo[]; soloTrainer?: boolean; gruppo?: string };
type SezioneCatalogo = { titolo: string; grafici: GraficoCatalogo[] }; type SezioneCatalogo = { titolo: string; grafici: GraficoCatalogo[] };
@@ -46,51 +52,56 @@ const CATALOGO: SezioneCatalogo[] = [
{ titolo: 'Frequenza cardiaca a riposo', linee: [{ testId: 'f_fc_riposo_bpm' }], soloTrainer: true }, { titolo: 'Frequenza cardiaca a riposo', linee: [{ testId: 'f_fc_riposo_bpm' }], soloTrainer: true },
] }, ] },
{ titolo: 'Mobilità', grafici: [ { titolo: 'Mobilità', grafici: [
{ titolo: 'Back scratch', linee: [{ testId: 'f_back_scratch_dx_cm' }, { testId: 'f_back_scratch_sx_cm' }] }, // 'back_scratch': alternativa storica (dismessa il 28/09, #13) — sit and reach non è
{ titolo: 'Back scratch (storico)', linee: [{ testId: 'f_back_scratch_cm' }] }, // un'alternativa a nessuno dei due, resta senza gruppo (non entra nei cambi).
{ titolo: 'Back scratch', gruppo: 'back_scratch', linee: [{ testId: 'f_back_scratch_dx_cm' }, { testId: 'f_back_scratch_sx_cm' }] },
{ titolo: 'Back scratch (storico)', gruppo: 'back_scratch', linee: [{ testId: 'f_back_scratch_cm' }] },
{ titolo: 'Sit and reach', linee: [{ testId: 'f_sit_reach_cm' }] }, { titolo: 'Sit and reach', linee: [{ testId: 'f_sit_reach_cm' }] },
] }, ] },
{ titolo: 'Equilibrio', grafici: [ { titolo: 'Equilibrio', grafici: [
{ titolo: 'Flamingo, occhi aperti', linee: [{ testId: 'f_flamingo_oa_dx' }, { testId: 'f_flamingo_oa_sx' }] }, { titolo: 'Flamingo, occhi aperti', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_oa_dx' }, { testId: 'f_flamingo_oa_sx' }] },
{ titolo: 'Flamingo, occhi chiusi', linee: [{ testId: 'f_flamingo_oc_dx' }, { testId: 'f_flamingo_oc_sx' }] }, { titolo: 'Flamingo, occhi chiusi', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_oc_dx' }, { testId: 'f_flamingo_oc_sx' }] },
{ titolo: 'Flamingo (storico)', linee: [{ testId: 'f_flamingo_cadute' }] }, { titolo: 'Flamingo (storico)', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_cadute' }] },
] }, ] },
{ titolo: 'Presa', grafici: [ { titolo: 'Presa', grafici: [
{ titolo: 'Handgrip', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] }, { titolo: 'Handgrip', gruppo: 'presa', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] },
{ titolo: 'Handgrip (storico)', linee: [{ testId: 'f_handgrip_kg' }] }, { titolo: 'Handgrip (storico)', gruppo: 'presa', linee: [{ testId: 'f_handgrip_kg' }] },
] }, ] },
{ titolo: 'Core', grafici: [ { titolo: 'Core', grafici: [
{ titolo: 'Plank sulle ginocchia', linee: [{ testId: 'f_plank_ginocchia_sec' }] }, { titolo: 'Plank sulle ginocchia', gruppo: 'core', linee: [{ testId: 'f_plank_ginocchia_sec' }] },
{ titolo: 'Plank', linee: [{ testId: 'f_plank_sec' }] }, { titolo: 'Plank', gruppo: 'core', linee: [{ testId: 'f_plank_sec' }] },
] }, ] },
{ titolo: 'Spinta', grafici: [ { titolo: 'Spinta', grafici: [
{ titolo: 'Panca con manubri', linee: [{ testId: 'f_panca_man_carico_kg', extraTestId: 'f_panca_man_rip' }] }, { titolo: 'Panca con manubri', gruppo: 'spinta', linee: [{ testId: 'f_panca_man_carico_kg', extraTestId: 'f_panca_man_rip' }] },
{ titolo: 'Push-up', linee: [{ testId: 'f_pushup_rip' }] }, { titolo: 'Push-up', gruppo: 'spinta', linee: [{ testId: 'f_pushup_rip' }] },
{ titolo: 'Panca con bilanciere', linee: [{ testId: 'f_panca_bil_carico_kg', extraTestId: 'f_panca_bil_rip' }] }, { titolo: 'Panca con bilanciere', gruppo: 'spinta', linee: [{ testId: 'f_panca_bil_carico_kg', extraTestId: 'f_panca_bil_rip' }] },
] }, ] },
{ titolo: 'Trazione', grafici: [ { titolo: 'Trazione', grafici: [
{ titolo: 'Rematore', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] }, { titolo: 'Rematore', gruppo: 'trazione', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] },
{ titolo: 'Trazioni', linee: [{ testId: 'f_trazioni_rip' }] }, { titolo: 'Trazioni', gruppo: 'trazione', linee: [{ testId: 'f_trazioni_rip' }] },
] }, ] },
{ titolo: 'Arti inferiori', grafici: [ { titolo: 'Arti inferiori', grafici: [
{ titolo: 'Sit-to-stand', linee: [{ testId: 'f_sit_to_stand_rip' }] }, { titolo: 'Sit-to-stand', gruppo: 'arti_inferiori', linee: [{ testId: 'f_sit_to_stand_rip' }] },
{ titolo: 'Squat', linee: [{ testId: 'f_squat_carico_kg', extraTestId: 'f_squat_rip' }] }, { titolo: 'Squat', gruppo: 'arti_inferiori', linee: [{ testId: 'f_squat_carico_kg', extraTestId: 'f_squat_rip' }] },
] }, ] },
{ titolo: 'Aerobico', grafici: [ { titolo: 'Aerobico', grafici: [
{ titolo: '6 Minute Walk Test', linee: [{ testId: 'f_6mwt_m' }] }, { titolo: '6 Minute Walk Test', gruppo: 'aerobico', linee: [{ testId: 'f_6mwt_m' }] },
{ titolo: "Queen's College Step Test", linee: [{ testId: 'f_step_hr_recovery' }] }, { titolo: "Queen's College Step Test", gruppo: 'aerobico', linee: [{ testId: 'f_step_hr_recovery' }] },
{ titolo: 'Camminata 2 km', linee: [{ testId: 'f_walk2km_min', extraTestId: 'f_walk2km_fc_bpm' }] }, { titolo: 'Camminata 2 km', gruppo: 'aerobico', linee: [{ testId: 'f_walk2km_min', extraTestId: 'f_walk2km_fc_bpm' }] },
{ titolo: 'MUTT', linee: [{ testId: 'f_mutt_vam' }] }, { titolo: 'MUTT', gruppo: 'aerobico', linee: [{ testId: 'f_mutt_vam' }] },
// Separato dal grafico MUTT sopra (non fuso in un solo grafico con 4 linee): la VAM è // Separato dal grafico MUTT sopra (non fuso in un solo grafico con 4 linee): la VAM è
// visibile al cliente, il dettaglio della frequenza cardiaca no (DECISIONI f) — e // visibile al cliente, il dettaglio della frequenza cardiaca no (DECISIONI f) — e
// `soloTrainer` è una proprietà del grafico intero, non della singola linea. // `soloTrainer` è una proprietà del grafico intero, non della singola linea. Senza
// `gruppo`: non è un'alternativa al protocollo aerobico, è un dettaglio di MUTT.
{ titolo: 'MUTT, dettaglio frequenza cardiaca', soloTrainer: true, linee: [ { titolo: 'MUTT, dettaglio frequenza cardiaca', soloTrainer: true, linee: [
{ testId: 'f_mutt_fc_max_bpm' }, { testId: 'f_mutt_fc_1min_bpm' }, { testId: 'f_mutt_fc_2min_bpm' }, { testId: 'f_mutt_fc_max_bpm' }, { testId: 'f_mutt_fc_1min_bpm' }, { testId: 'f_mutt_fc_2min_bpm' },
] }, ] },
{ titolo: 'MUTT, frequenza cardiaca per step', soloTrainer: true, { titolo: 'MUTT, frequenza cardiaca per step', soloTrainer: true,
linee: MUTT_STEP_KMH.map((kmh) => ({ testId: `f_mutt_fc_${String(kmh).replace('.', '_')}` })) }, linee: MUTT_STEP_KMH.map((kmh) => ({ testId: `f_mutt_fc_${String(kmh).replace('.', '_')}` })) },
{ titolo: 'MILFIT', linee: [{ testId: 'f_milfit_watt' }] }, { titolo: 'MILFIT', gruppo: 'aerobico', linee: [{ testId: 'f_milfit_watt' }] },
{ titolo: 'VO2max (misura diretta)', linee: [{ testId: 'f_vo2max' }] }, { titolo: 'VO2max (misura diretta)', gruppo: 'aerobico', linee: [{ testId: 'f_vo2max' }] },
// Senza gruppo: storico di una misura di recupero cardiaco, non un'alternativa al
// protocollo aerobico scelto (6MWT/Queen's/2km/MUTT/MILFIT/Calibre).
{ titolo: 'Recupero cardiaco (storico)', linee: [{ testId: 'f_hrr_1min_bpm' }], soloTrainer: true }, { titolo: 'Recupero cardiaco (storico)', linee: [{ testId: 'f_hrr_1min_bpm' }], soloTrainer: true },
] }, ] },
{ titolo: 'Strumenti', grafici: [ { titolo: 'Strumenti', grafici: [
@@ -268,52 +279,65 @@ export function andamentoTest(
} }
/** /**
* I cambi di test dentro una sezione: per ogni sessione con almeno una misura valida di * I cambi di test dentro una sezione, calcolati PER GRUPPO (`GraficoCatalogo.gruppo`), non
* QUALUNQUE grafico della sezione (fra quelli visibili a chi guarda), si prende il primo * per l'intera sezione: solo i grafici che sono davvero alternativi fra loro (stessa
* grafico — nell'ordine del catalogo — che ha una misura in quella sessione, e si segna un * capacità, protocolli diversi — le alternative di STRUTTURA_RAW in gestionale.ts più i
* cambio ogni volta che questo grafico è diverso da quello della sessione precedente * test storici dismessi) concorrono a un cambio. Un grafico senza `gruppo` (es. Sit and
* (DECISIONI a dell'issue #14: "Dal 12/03/2027: Trazioni (prima: Rematore 5RM)"). * reach, che non è un'alternativa a Back scratch) non genera né interrompe un cambio, e non
* viene nemmeno considerato in questo calcolo.
* *
* Applicato a livello di SEZIONE, non solo alle "alternative" vere e proprie (es. rematore * Dentro un gruppo: per ogni sessione con almeno una misura valida di un grafico del
* vs trazioni): in una sezione con un solo grafico visibile non produce mai un cambio; in * gruppo (fra quelli visibili a chi guarda), si prende il primo grafico del gruppo —
* una sezione che mescola un'alternativa storica con un test sempre presente (es. Mobilità: * nell'ordine del catalogo — che ha una misura in quella sessione, e si segna un cambio
* back scratch/back scratch storico insieme a sit and reach, che non sono alternativi fra * ogni volta che questo grafico è diverso da quello della sessione precedente DELLO STESSO
* loro) può in teoria segnalare un cambio se in una sessione manca del tutto il primo * GRUPPO (DECISIONI a dell'issue #14: "Dal 12/03/2027: Trazioni (prima: Rematore 5RM)").
* grafico del catalogo mentre è presente un altro — caso raro nella pratica (i test si * I cambi dei diversi gruppi di una sezione (es. Aerobico ha 'aerobico' più due grafici
* misurano nella stessa seduta) e non coperto dai test di questa unità: lasciato così * senza gruppo) finiscono in un'unica lista, quella restituita, ordinata per data.
* perché è la lettura più diretta del testo dell'analisi ("calcolati per sezione ... il
* primo test usato in ogni sessione"). Da rivedere se in produzione risultasse rumoroso.
*/ */
function cambiDellaSezione( function cambiDellaSezione(
graficiSezioneOrdinati: GraficoCatalogo[], graficiSezioneOrdinati: GraficoCatalogo[],
graficiVisibili: GraficoAndamento[], graficiVisibili: GraficoAndamento[],
puntiPerTest: Map<string, PuntoBase[]> puntiPerTest: Map<string, PuntoBase[]>
): CambioSezione[] { ): CambioSezione[] {
if (graficiVisibili.length <= 1) return [];
const titoliVisibili = new Set(graficiVisibili.map((g) => g.titolo)); const titoliVisibili = new Set(graficiVisibili.map((g) => g.titolo));
// sessioneId -> {data, titolo del primo grafico — nell'ordine del catalogo — con una // Raggruppa i grafici visibili con un `gruppo` dichiarato, nell'ordine del catalogo.
// misura in quella sessione}. const graficiPerGruppo = new Map<string, GraficoCatalogo[]>();
const perSessione = new Map<number, { data: string; titolo: string }>();
for (const g of graficiSezioneOrdinati) { for (const g of graficiSezioneOrdinati) {
if (!titoliVisibili.has(g.titolo)) continue; if (!g.gruppo || !titoliVisibili.has(g.titolo)) continue;
for (const l of g.linee) { const lista = graficiPerGruppo.get(g.gruppo);
const punti = puntiPerTest.get(l.testId) ?? []; if (lista) lista.push(g);
for (const p of punti) { else graficiPerGruppo.set(g.gruppo, [g]);
if (!perSessione.has(p.sessioneId)) perSessione.set(p.sessioneId, { data: p.data, titolo: g.titolo }); }
const cambi: CambioSezione[] = [];
for (const graficiGruppo of graficiPerGruppo.values()) {
if (graficiGruppo.length <= 1) continue; // niente da confrontare
// sessioneId -> {data, titolo del primo grafico DEL GRUPPO — nell'ordine del catalogo
// — con una misura in quella sessione}.
const perSessione = new Map<number, { data: string; titolo: string }>();
for (const g of graficiGruppo) {
for (const l of g.linee) {
const punti = puntiPerTest.get(l.testId) ?? [];
for (const p of punti) {
if (!perSessione.has(p.sessioneId)) perSessione.set(p.sessioneId, { data: p.data, titolo: g.titolo });
}
} }
} }
const sessioniOrdinate = [...perSessione.entries()]
.sort(([idA, a], [idB, b]) => (a.data < b.data ? -1 : a.data > b.data ? 1 : idA - idB))
.map(([, v]) => v);
for (let i = 1; i < sessioniOrdinate.length; i++) {
const prima = sessioniOrdinate[i - 1]!;
const dopo = sessioniOrdinate[i]!;
if (dopo.titolo !== prima.titolo) cambi.push({ data: dopo.data, da: prima.titolo, a: dopo.titolo });
}
} }
const sessioniOrdinate = [...perSessione.entries()] cambi.sort((a, b) => (a.data < b.data ? -1 : a.data > b.data ? 1 : 0));
.sort(([idA, a], [idB, b]) => (a.data < b.data ? -1 : a.data > b.data ? 1 : idA - idB))
.map(([, v]) => v);
const cambi: CambioSezione[] = [];
for (let i = 1; i < sessioniOrdinate.length; i++) {
const prima = sessioniOrdinate[i - 1]!;
const dopo = sessioniOrdinate[i]!;
if (dopo.titolo !== prima.titolo) cambi.push({ data: dopo.data, da: prima.titolo, a: dopo.titolo });
}
return cambi; return cambi;
} }
@@ -113,6 +113,56 @@ describe('andamentoTest: cambio di test dentro la stessa sezione (Trazione)', ()
}); });
}); });
describe('andamentoTest: i cambi valgono solo fra alternative vere, non nell\'intera sezione', () => {
it('Strumenti: massa grassa poi solo WHR non e\' un cambio (test non alternativi)', () => {
const db = dbPronto();
const s1 = checkup(db, '2026-01-10');
registraMisure(db, s1, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 20 }]);
const s2 = checkup(db, '2026-03-10');
registraMisure(db, s2, 'manuale', [{ test_id: 'f_whr', valore_num: 0.9 }]); // niente massa grassa
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
const strumenti = sezioni.find((s) => s.titolo === 'Strumenti')!;
expect(strumenti.grafici.map((g) => g.titolo).sort()).toEqual(['Massa grassa', 'Rapporto vita-fianchi (WHR)']);
expect(strumenti.cambi).toEqual([]);
});
it('Mobilità: back scratch in ogni sessione, sit and reach solo in una, non e\' un cambio', () => {
const db = dbPronto();
const s1 = checkup(db, '2026-01-10');
registraMisure(db, s1, 'manuale', [
{ test_id: 'f_back_scratch_dx_cm', valore_num: -5 },
{ test_id: 'f_back_scratch_sx_cm', valore_num: -6 },
]);
const s2 = checkup(db, '2026-03-10');
registraMisure(db, s2, 'manuale', [
{ test_id: 'f_back_scratch_dx_cm', valore_num: -4 },
{ test_id: 'f_back_scratch_sx_cm', valore_num: -5 },
{ test_id: 'f_sit_reach_cm', valore_num: 2 }, // solo in questa sessione
]);
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
const mobilita = sezioni.find((s) => s.titolo === 'Mobilità')!;
expect(mobilita.grafici.map((g) => g.titolo).sort()).toEqual(['Back scratch', 'Sit and reach']);
expect(mobilita.cambi).toEqual([]);
});
it('Presa: handgrip storico poi handgrip dx/sx e\' un vero cambio (alternativa dismessa)', () => {
const db = dbPronto();
const s1 = checkup(db, '2026-01-10');
registraMisure(db, s1, 'manuale', [{ test_id: 'f_handgrip_kg', valore_num: 30 }]);
const s2 = checkup(db, '2026-03-10');
registraMisure(db, s2, 'manuale', [
{ test_id: 'f_handgrip_dx_kg', valore_num: 32 },
{ test_id: 'f_handgrip_sx_kg', valore_num: 28 },
]);
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
const presa = sezioni.find((s) => s.titolo === 'Presa')!;
expect(presa.cambi).toEqual([{ data: '2026-03-10', da: 'Handgrip (storico)', a: 'Handgrip' }]);
});
});
describe('andamentoTest: solo misure valide', () => { describe('andamentoTest: solo misure valide', () => {
it('fuori range non confermata è esclusa; confermata entra', () => { it('fuori range non confermata è esclusa; confermata entra', () => {
const db = dbPronto(); const db = dbPronto();