longevity: #14 unità A, cambi di test solo fra alternative
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user