diff --git a/apps/platforms/src/lib/longevity/andamento.ts b/apps/platforms/src/lib/longevity/andamento.ts index 2c0d32c..60a699f 100644 --- a/apps/platforms/src/lib/longevity/andamento.ts +++ b/apps/platforms/src/lib/longevity/andamento.ts @@ -31,7 +31,13 @@ import type Database from 'better-sqlite3'; * mai come una linea a sé stante. */ 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[] }; @@ -46,51 +52,56 @@ const CATALOGO: SezioneCatalogo[] = [ { titolo: 'Frequenza cardiaca a riposo', linee: [{ testId: 'f_fc_riposo_bpm' }], soloTrainer: true }, ] }, { titolo: 'Mobilità', grafici: [ - { titolo: 'Back scratch', linee: [{ testId: 'f_back_scratch_dx_cm' }, { testId: 'f_back_scratch_sx_cm' }] }, - { titolo: 'Back scratch (storico)', linee: [{ testId: 'f_back_scratch_cm' }] }, + // 'back_scratch': alternativa storica (dismessa il 28/09, #13) — sit and reach non è + // 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: 'Equilibrio', grafici: [ - { titolo: 'Flamingo, occhi aperti', 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 (storico)', linee: [{ testId: 'f_flamingo_cadute' }] }, + { titolo: 'Flamingo, occhi aperti', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_oa_dx' }, { testId: 'f_flamingo_oa_sx' }] }, + { titolo: 'Flamingo, occhi chiusi', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_oc_dx' }, { testId: 'f_flamingo_oc_sx' }] }, + { titolo: 'Flamingo (storico)', gruppo: 'equilibrio', linee: [{ testId: 'f_flamingo_cadute' }] }, ] }, { titolo: 'Presa', grafici: [ - { titolo: 'Handgrip', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] }, - { titolo: 'Handgrip (storico)', linee: [{ testId: 'f_handgrip_kg' }] }, + { titolo: 'Handgrip', gruppo: 'presa', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] }, + { titolo: 'Handgrip (storico)', gruppo: 'presa', linee: [{ testId: 'f_handgrip_kg' }] }, ] }, { titolo: 'Core', grafici: [ - { titolo: 'Plank sulle ginocchia', linee: [{ testId: 'f_plank_ginocchia_sec' }] }, - { titolo: 'Plank', linee: [{ testId: 'f_plank_sec' }] }, + { titolo: 'Plank sulle ginocchia', gruppo: 'core', linee: [{ testId: 'f_plank_ginocchia_sec' }] }, + { titolo: 'Plank', gruppo: 'core', linee: [{ testId: 'f_plank_sec' }] }, ] }, { titolo: 'Spinta', grafici: [ - { titolo: 'Panca con manubri', linee: [{ testId: 'f_panca_man_carico_kg', extraTestId: 'f_panca_man_rip' }] }, - { titolo: 'Push-up', linee: [{ testId: 'f_pushup_rip' }] }, - { titolo: 'Panca con bilanciere', linee: [{ testId: 'f_panca_bil_carico_kg', extraTestId: 'f_panca_bil_rip' }] }, + { titolo: 'Panca con manubri', gruppo: 'spinta', linee: [{ testId: 'f_panca_man_carico_kg', extraTestId: 'f_panca_man_rip' }] }, + { titolo: 'Push-up', gruppo: 'spinta', linee: [{ testId: 'f_pushup_rip' }] }, + { titolo: 'Panca con bilanciere', gruppo: 'spinta', linee: [{ testId: 'f_panca_bil_carico_kg', extraTestId: 'f_panca_bil_rip' }] }, ] }, { titolo: 'Trazione', grafici: [ - { titolo: 'Rematore', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] }, - { titolo: 'Trazioni', linee: [{ testId: 'f_trazioni_rip' }] }, + { titolo: 'Rematore', gruppo: 'trazione', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] }, + { titolo: 'Trazioni', gruppo: 'trazione', linee: [{ testId: 'f_trazioni_rip' }] }, ] }, { titolo: 'Arti inferiori', grafici: [ - { titolo: 'Sit-to-stand', linee: [{ testId: 'f_sit_to_stand_rip' }] }, - { titolo: 'Squat', linee: [{ testId: 'f_squat_carico_kg', extraTestId: 'f_squat_rip' }] }, + { titolo: 'Sit-to-stand', gruppo: 'arti_inferiori', linee: [{ testId: 'f_sit_to_stand_rip' }] }, + { titolo: 'Squat', gruppo: 'arti_inferiori', linee: [{ testId: 'f_squat_carico_kg', extraTestId: 'f_squat_rip' }] }, ] }, { titolo: 'Aerobico', grafici: [ - { titolo: '6 Minute Walk Test', linee: [{ testId: 'f_6mwt_m' }] }, - { titolo: "Queen's College Step Test", linee: [{ testId: 'f_step_hr_recovery' }] }, - { titolo: 'Camminata 2 km', linee: [{ testId: 'f_walk2km_min', extraTestId: 'f_walk2km_fc_bpm' }] }, - { titolo: 'MUTT', linee: [{ testId: 'f_mutt_vam' }] }, + { titolo: '6 Minute Walk Test', gruppo: 'aerobico', linee: [{ testId: 'f_6mwt_m' }] }, + { titolo: "Queen's College Step Test", gruppo: 'aerobico', linee: [{ testId: 'f_step_hr_recovery' }] }, + { titolo: 'Camminata 2 km', gruppo: 'aerobico', linee: [{ testId: 'f_walk2km_min', extraTestId: 'f_walk2km_fc_bpm' }] }, + { 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 è // 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: [ { 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, linee: MUTT_STEP_KMH.map((kmh) => ({ testId: `f_mutt_fc_${String(kmh).replace('.', '_')}` })) }, - { titolo: 'MILFIT', linee: [{ testId: 'f_milfit_watt' }] }, - { titolo: 'VO2max (misura diretta)', linee: [{ testId: 'f_vo2max' }] }, + { titolo: 'MILFIT', gruppo: 'aerobico', linee: [{ testId: 'f_milfit_watt' }] }, + { 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: '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 - * QUALUNQUE grafico della sezione (fra quelli visibili a chi guarda), si prende il primo - * grafico — nell'ordine del catalogo — che ha una misura in quella sessione, e si segna un - * cambio ogni volta che questo grafico è diverso da quello della sessione precedente - * (DECISIONI a dell'issue #14: "Dal 12/03/2027: Trazioni (prima: Rematore 5RM)"). + * I cambi di test dentro una sezione, calcolati PER GRUPPO (`GraficoCatalogo.gruppo`), non + * per l'intera sezione: solo i grafici che sono davvero alternativi fra loro (stessa + * capacità, protocolli diversi — le alternative di STRUTTURA_RAW in gestionale.ts più i + * test storici dismessi) concorrono a un cambio. Un grafico senza `gruppo` (es. Sit and + * 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 - * vs trazioni): in una sezione con un solo grafico visibile non produce mai un cambio; in - * una sezione che mescola un'alternativa storica con un test sempre presente (es. Mobilità: - * back scratch/back scratch storico insieme a sit and reach, che non sono alternativi fra - * loro) può in teoria segnalare un cambio se in una sessione manca del tutto il primo - * grafico del catalogo mentre è presente un altro — caso raro nella pratica (i test si - * misurano nella stessa seduta) e non coperto dai test di questa unità: lasciato così - * 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. + * Dentro un gruppo: per ogni sessione con almeno una misura valida di un grafico del + * gruppo (fra quelli visibili a chi guarda), si prende il primo grafico del gruppo — + * nell'ordine del catalogo — che ha una misura in quella sessione, e si segna un cambio + * ogni volta che questo grafico è diverso da quello della sessione precedente DELLO STESSO + * GRUPPO (DECISIONI a dell'issue #14: "Dal 12/03/2027: Trazioni (prima: Rematore 5RM)"). + * I cambi dei diversi gruppi di una sezione (es. Aerobico ha 'aerobico' più due grafici + * senza gruppo) finiscono in un'unica lista, quella restituita, ordinata per data. */ function cambiDellaSezione( graficiSezioneOrdinati: GraficoCatalogo[], graficiVisibili: GraficoAndamento[], puntiPerTest: Map ): CambioSezione[] { - if (graficiVisibili.length <= 1) return []; const titoliVisibili = new Set(graficiVisibili.map((g) => g.titolo)); - // sessioneId -> {data, titolo del primo grafico — nell'ordine del catalogo — con una - // misura in quella sessione}. - const perSessione = new Map(); + // Raggruppa i grafici visibili con un `gruppo` dichiarato, nell'ordine del catalogo. + const graficiPerGruppo = new Map(); for (const g of graficiSezioneOrdinati) { - if (!titoliVisibili.has(g.titolo)) continue; - 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 }); + if (!g.gruppo || !titoliVisibili.has(g.titolo)) continue; + const lista = graficiPerGruppo.get(g.gruppo); + if (lista) lista.push(g); + else graficiPerGruppo.set(g.gruppo, [g]); + } + + 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(); + 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()] - .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 }); - } + cambi.sort((a, b) => (a.data < b.data ? -1 : a.data > b.data ? 1 : 0)); return cambi; } diff --git a/apps/platforms/tests/longevity/andamento.test.ts b/apps/platforms/tests/longevity/andamento.test.ts index d483dcd..259ea7d 100644 --- a/apps/platforms/tests/longevity/andamento.test.ts +++ b/apps/platforms/tests/longevity/andamento.test.ts @@ -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', () => { it('fuori range non confermata è esclusa; confermata entra', () => { const db = dbPronto();