longevity: #14 unità A, andamento dei test nel tempo (dati)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
|||||||
|
//
|
||||||
|
// L'andamento nel tempo dei VALORI DEI TEST del check-up (issue #14): a differenza di
|
||||||
|
// progressione.ts — che mostra i punteggi congelati (assi, macro, Fitness Age), uno per
|
||||||
|
// sessione — questo modulo legge le misure GREZZE (`misure`, mai `score`), un punto per
|
||||||
|
// sessione e per test_id, così come sono state prese: nessuna curva, nessuna normalizzazione.
|
||||||
|
//
|
||||||
|
// Il catalogo qui sotto rispecchia il modulo cartaceo del check-up (STRUTTURA_RAW in
|
||||||
|
// gestionale.ts: Pre-test -> Mobilità -> Equilibrio -> Presa -> Core -> Forza spinta ->
|
||||||
|
// Forza trazione -> Arti inferiori -> Aerobico -> Strumenti), ma NON lo importa: le pagine
|
||||||
|
// cliente non devono importare moduli del gestionale (issue #14, unità A — questo file non
|
||||||
|
// importa gestionale.ts, giornale.ts né consulenza.ts). Le etichette e le unità di ogni
|
||||||
|
// linea vengono SEMPRE dal registro (`registro_test`), mai ripetute qui: solo i test_id, il
|
||||||
|
// titolo di ogni grafico (un raggruppamento dell'interfaccia, non un dato clinico) e chi lo
|
||||||
|
// può vedere (`soloTrainer`).
|
||||||
|
//
|
||||||
|
// Un test dismesso (es. f_handgrip_kg, sostituito da f_handgrip_dx_kg/f_handgrip_sx_kg il
|
||||||
|
// 28/09/2026, issue #13) resta nel suo grafico storico: è storia del cliente, non sparisce
|
||||||
|
// (DECISIONI f dell'issue #14 — "il cliente vede anche i dismessi"). Le etichette/unità si
|
||||||
|
// leggono da TUTTE le righe di `registro_test`, non da `testAttivi` (che filtra per data e
|
||||||
|
// farebbe sparire l'etichetta di un test non più attivo oggi).
|
||||||
|
//
|
||||||
|
// Ogni test_id del catalogo qui sotto è stato verificato a mano contro registro.ts (FISICI):
|
||||||
|
// nessuna divergenza — comprese le tre alternative storiche (f_handgrip_kg,
|
||||||
|
// f_back_scratch_cm, f_flamingo_cadute) e gli otto step del MUTT (f_mutt_fc_7_5 …
|
||||||
|
// f_mutt_fc_14_5, generati in registro.ts con lo stesso `String(kmh).replace('.', '_')`
|
||||||
|
// usato qui sotto).
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/** Una linea del grafico: un test_id, più — quando il grafico lo prevede (es. il carico di
|
||||||
|
* un 5RM) — il test_id di un secondo valore da mostrare solo nel tooltip (le ripetizioni),
|
||||||
|
* mai come una linea a sé stante. */
|
||||||
|
type LineaCatalogo = { testId: string; extraTestId?: string };
|
||||||
|
|
||||||
|
type GraficoCatalogo = { titolo: string; linee: LineaCatalogo[]; soloTrainer?: boolean };
|
||||||
|
|
||||||
|
type SezioneCatalogo = { titolo: string; grafici: GraficoCatalogo[] };
|
||||||
|
|
||||||
|
// Gli otto step del MUTT, un km/h alla volta (stessa lista di registro.ts), un test_id
|
||||||
|
// ciascuno: "FC per step solo trainer (8 linee in un grafico o elenco: scelta dell'agente)"
|
||||||
|
// — scelto un grafico solo con 8 linee, più semplice di 8 grafici da un punto ciascuno.
|
||||||
|
const MUTT_STEP_KMH = [7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5] as const;
|
||||||
|
|
||||||
|
const CATALOGO: SezioneCatalogo[] = [
|
||||||
|
{ titolo: 'Pre-test', grafici: [
|
||||||
|
{ titolo: 'Peso', linee: [{ testId: 'f_peso_kg' }] },
|
||||||
|
{ 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' }] },
|
||||||
|
{ 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: 'Presa', grafici: [
|
||||||
|
{ titolo: 'Handgrip', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] },
|
||||||
|
{ titolo: 'Handgrip (storico)', 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: '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: 'Trazione', grafici: [
|
||||||
|
{ titolo: 'Rematore', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] },
|
||||||
|
{ titolo: 'Trazioni', 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: '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' }] },
|
||||||
|
// 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.
|
||||||
|
{ 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: 'Recupero cardiaco (storico)', linee: [{ testId: 'f_hrr_1min_bpm' }], soloTrainer: true },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Strumenti', grafici: [
|
||||||
|
{ titolo: 'Massa grassa', linee: [{ testId: 'f_grasso_pct' }] },
|
||||||
|
{ titolo: 'Massa muscolare', linee: [{ testId: 'f_muscolo_pct' }] },
|
||||||
|
{ titolo: 'Rapporto vita-fianchi (WHR)', linee: [{ testId: 'f_whr' }] },
|
||||||
|
{ titolo: 'HRV', linee: [{ testId: 'f_hrv_score' }] },
|
||||||
|
// Outreach e buckling insieme (scelta dell'agente, prevista dall'issue): stessa unità
|
||||||
|
// (gradi), stesso strumento (Wellness Tower spalla), un solo grafico più leggibile.
|
||||||
|
{ titolo: 'Spalla (outreach/buckling)', linee: [{ testId: 'f_spalla_outreach_deg' }, { testId: 'f_spalla_buckling_deg' }] },
|
||||||
|
{ titolo: 'Pressione arteriosa', linee: [{ testId: 'f_pa_sistolica' }, { testId: 'f_pa_diastolica' }] },
|
||||||
|
{ titolo: 'Saturazione O2 (SpO2)', linee: [{ testId: 'f_spo2_pct' }] },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TITOLO_ALTRE_MISURE = 'Altre misure';
|
||||||
|
|
||||||
|
export type PuntoTest = { data: string; sessioneId: number; valore: number; extra?: number };
|
||||||
|
export type LineaAndamento = { testId: string; etichetta: string; punti: PuntoTest[] };
|
||||||
|
export type GraficoAndamento = { titolo: string; unita: string | null; linee: LineaAndamento[] };
|
||||||
|
export type CambioSezione = { data: string; da: string; a: string };
|
||||||
|
export type SezioneAndamento = { titolo: string; grafici: GraficoAndamento[]; cambi: CambioSezione[] };
|
||||||
|
|
||||||
|
type RigaMisura = { id: number; testId: string; valoreNum: number; sessioneId: number; data: string };
|
||||||
|
type PuntoBase = { data: string; sessioneId: number; valore: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tutte le misure valide (stessa regola di `misureValideAlla` in motore/index.ts:165 —
|
||||||
|
* `fuori_range = 0` oppure confermata dal trainer, `verifica = 'confermata'`) di un
|
||||||
|
* cliente, in ordine cronologico. A differenza di `misureValideAlla` — che per ogni
|
||||||
|
* test_id tiene solo il valore più recente valido a una data — qui serve TUTTA la storia:
|
||||||
|
* un punto per sessione. A parità di sessione (più righe dello stesso test_id, es. una
|
||||||
|
* correzione manuale) vince la riga con id più alto — lo stesso spareggio del motore
|
||||||
|
* (`righeMisureGrezze`, motore/index.ts).
|
||||||
|
* Legge sia i test `f_*` (check-up) sia `q_*` (questionario): il filtro sul prefisso è
|
||||||
|
* responsabilità del chiamante — qui restano fuori solo le misure non valide.
|
||||||
|
*/
|
||||||
|
function misureGrezzeDelCliente(db: Database.Database, clientCode: string): RigaMisura[] {
|
||||||
|
const righe = db.prepare(
|
||||||
|
`SELECT m.id AS id, m.test_id AS testId, m.valore_num AS valoreNum,
|
||||||
|
m.sessione_id AS sessioneId, s.data AS data
|
||||||
|
FROM misure m
|
||||||
|
JOIN sessioni s ON s.id = m.sessione_id
|
||||||
|
WHERE m.client_code = ?
|
||||||
|
AND m.valore_num IS NOT NULL
|
||||||
|
AND (m.fuori_range = 0 OR m.verifica = 'confermata')
|
||||||
|
ORDER BY s.data ASC, s.id ASC, m.id DESC`
|
||||||
|
).all(clientCode) as RigaMisura[];
|
||||||
|
|
||||||
|
const visti = new Set<string>();
|
||||||
|
const risultato: RigaMisura[] = [];
|
||||||
|
for (const r of righe) {
|
||||||
|
const chiave = `${r.testId}|${r.sessioneId}`;
|
||||||
|
if (visti.has(chiave)) continue; // già presa la riga con id più alto per questa sessione
|
||||||
|
visti.add(chiave);
|
||||||
|
risultato.push(r);
|
||||||
|
}
|
||||||
|
return risultato;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etichetta e unità di un test_id, da TUTTE le righe di `registro_test` (non da
|
||||||
|
* `testAttivi`, che esclude per data i test dismessi: qui servono anche quelli, per lo
|
||||||
|
* storico). Assente solo se il test_id non è mai stato nel registro — non dovrebbe
|
||||||
|
* succedere per una riga che viene da `misure` (`registraMisure` rifiuta un test_id
|
||||||
|
* sconosciuto all'inserimento). */
|
||||||
|
function vociRegistro(db: Database.Database): Map<string, { etichetta: string; unita: string | null }> {
|
||||||
|
const righe = db.prepare(`SELECT test_id AS testId, etichetta, unita FROM registro_test`).all() as
|
||||||
|
{ testId: string; etichetta: string; unita: string | null }[];
|
||||||
|
return new Map(righe.map((r) => [r.testId, { etichetta: r.etichetta, unita: r.unita }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'andamento nel tempo di ogni test del check-up per un cliente: una sezione per
|
||||||
|
* capacità (l'ordine del modulo cartaceo), ciascuna con i grafici che hanno almeno una
|
||||||
|
* misura valida e con i cambi di test segnalati (issue #14, DECISIONI a/e/f/g).
|
||||||
|
*
|
||||||
|
* `trainer: false` esclude i grafici `soloTrainer` (FC a riposo, dettaglio MUTT, FC per
|
||||||
|
* step, recupero cardiaco storico) — sia dai grafici mostrati sia dal calcolo dei cambi:
|
||||||
|
* un cliente non deve vedere il nome di un test che non può vedere, nemmeno dentro il testo
|
||||||
|
* di un cambio.
|
||||||
|
*
|
||||||
|
* Un test_id `f_*` con misure valide ma assente dal catalogo finisce in una sezione
|
||||||
|
* «Altre misure», in un grafico a sé (mai perso). I test del questionario (`q_*`) restano
|
||||||
|
* fuori da questo modulo per costruzione: sono percezioni del cliente, già rappresentate
|
||||||
|
* dagli assi in progressione.ts, non "test" del check-up — quindi non entrano nemmeno in
|
||||||
|
* «Altre misure».
|
||||||
|
*
|
||||||
|
* `g.linee.length > 0 && grafico.linee[0]` sono garantiti dal catalogo qui sopra (mai un
|
||||||
|
* grafico con zero linee dichiarate).
|
||||||
|
*/
|
||||||
|
export function andamentoTest(
|
||||||
|
db: Database.Database, clientCode: string, opts: { trainer: boolean }
|
||||||
|
): SezioneAndamento[] {
|
||||||
|
const misure = misureGrezzeDelCliente(db, clientCode);
|
||||||
|
const registro = vociRegistro(db);
|
||||||
|
|
||||||
|
// test_id -> i suoi punti, in ordine cronologico (misureGrezzeDelCliente è già ordinata
|
||||||
|
// data ASC, sessione ASC).
|
||||||
|
const puntiPerTest = new Map<string, PuntoBase[]>();
|
||||||
|
for (const r of misure) {
|
||||||
|
const lista = puntiPerTest.get(r.testId);
|
||||||
|
const punto = { data: r.data, sessioneId: r.sessioneId, valore: r.valoreNum };
|
||||||
|
if (lista) lista.push(punto);
|
||||||
|
else puntiPerTest.set(r.testId, [punto]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per il tooltip: valore del test_id "extra" nella STESSA sessione del punto principale.
|
||||||
|
const extraPerTestESessione = new Map<string, Map<number, number>>();
|
||||||
|
for (const [testId, punti] of puntiPerTest) {
|
||||||
|
extraPerTestESessione.set(testId, new Map(punti.map((p) => [p.sessioneId, p.valore])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function costruisciLinea(l: LineaCatalogo): LineaAndamento | null {
|
||||||
|
const punti = puntiPerTest.get(l.testId);
|
||||||
|
if (!punti || punti.length === 0) return null;
|
||||||
|
const voce = registro.get(l.testId);
|
||||||
|
const mappaExtra = l.extraTestId ? extraPerTestESessione.get(l.extraTestId) : undefined;
|
||||||
|
return {
|
||||||
|
testId: l.testId,
|
||||||
|
etichetta: voce?.etichetta ?? l.testId,
|
||||||
|
punti: punti.map((p) => {
|
||||||
|
const extra = mappaExtra?.get(p.sessioneId);
|
||||||
|
return extra === undefined ? { ...p } : { ...p, extra };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function costruisciGrafico(g: GraficoCatalogo): GraficoAndamento | null {
|
||||||
|
const linee: LineaAndamento[] = [];
|
||||||
|
for (const l of g.linee) {
|
||||||
|
const linea = costruisciLinea(l);
|
||||||
|
if (linea) linee.push(linea);
|
||||||
|
}
|
||||||
|
if (linee.length === 0) return null;
|
||||||
|
const primoTestId = g.linee[0]!.testId;
|
||||||
|
return { titolo: g.titolo, unita: registro.get(primoTestId)?.unita ?? null, linee };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idCatalogati = new Set(CATALOGO.flatMap((s) => s.grafici.flatMap((g) => g.linee.map((l) => l.testId))));
|
||||||
|
|
||||||
|
const sezioni: SezioneAndamento[] = [];
|
||||||
|
|
||||||
|
for (const s of CATALOGO) {
|
||||||
|
const graficiSezione = s.grafici.filter((g) => opts.trainer || !g.soloTrainer);
|
||||||
|
const graficiVisibili: GraficoAndamento[] = [];
|
||||||
|
for (const g of graficiSezione) {
|
||||||
|
const grafico = costruisciGrafico(g);
|
||||||
|
if (grafico) graficiVisibili.push(grafico);
|
||||||
|
}
|
||||||
|
if (graficiVisibili.length === 0) continue;
|
||||||
|
|
||||||
|
sezioni.push({
|
||||||
|
titolo: s.titolo,
|
||||||
|
grafici: graficiVisibili,
|
||||||
|
cambi: cambiDellaSezione(graficiSezione, graficiVisibili, puntiPerTest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Altre misure»: ogni test_id f_* con misure valide ma non nel catalogo, un grafico a sé.
|
||||||
|
const idAltri = [...puntiPerTest.keys()].filter((id) => id.startsWith('f_') && !idCatalogati.has(id)).sort();
|
||||||
|
if (idAltri.length > 0) {
|
||||||
|
const grafici: GraficoAndamento[] = idAltri.map((id) => {
|
||||||
|
const voce = registro.get(id);
|
||||||
|
const etichetta = voce?.etichetta ?? id;
|
||||||
|
return {
|
||||||
|
titolo: etichetta,
|
||||||
|
unita: voce?.unita ?? null,
|
||||||
|
linee: [{ testId: id, etichetta, punti: puntiPerTest.get(id)!.map((p) => ({ ...p })) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
sezioni.push({ titolo: TITOLO_ALTRE_MISURE, grafici, cambi: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sezioni;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)").
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function cambiDellaSezione(
|
||||||
|
graficiSezioneOrdinati: GraficoCatalogo[],
|
||||||
|
graficiVisibili: GraficoAndamento[],
|
||||||
|
puntiPerTest: Map<string, PuntoBase[]>
|
||||||
|
): 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<number, { data: string; titolo: string }>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
return cambi;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// tests/longevity/andamento.test.ts — issue #14, unità A (dati).
|
||||||
|
// andamentoTest legge le misure GREZZE di `misure` (mai `score`), un punto per sessione e
|
||||||
|
// per test_id: serie con buchi, doppie linee (dx/sx), tooltip del 5RM, cambio di test
|
||||||
|
// segnalato per sezione, misure fuori range escluse finché non confermate, visibilità
|
||||||
|
// trainer/cliente, test fuori catalogo in «Altre misure».
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||||
|
import { apriSessione, registraMisure, verificaMisura } from '../../src/lib/longevity/misure';
|
||||||
|
import { andamentoTest } from '../../src/lib/longevity/andamento';
|
||||||
|
|
||||||
|
function dbPronto() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkup(db: ReturnType<typeof dbPronto>, data: string) {
|
||||||
|
return apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 40 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('andamentoTest: serie nel tempo, buchi e doppie linee', () => {
|
||||||
|
it('un grafico con buchi: solo le sessioni con misura hanno un punto', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [{ test_id: 'f_peso_kg', valore_num: 80 }]);
|
||||||
|
const s2 = checkup(db, '2026-03-10');
|
||||||
|
registraMisure(db, s2, 'manuale', [{ test_id: 'f_squat_carico_kg', valore_num: 100 }]); // niente peso
|
||||||
|
const s3 = checkup(db, '2026-05-10');
|
||||||
|
registraMisure(db, s3, 'manuale', [{ test_id: 'f_peso_kg', valore_num: 78 }]);
|
||||||
|
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const preTest = sezioni.find((s) => s.titolo === 'Pre-test')!;
|
||||||
|
const peso = preTest.grafici.find((g) => g.titolo === 'Peso')!;
|
||||||
|
expect(peso.linee).toHaveLength(1);
|
||||||
|
expect(peso.linee[0]!.punti).toEqual([
|
||||||
|
{ data: '2026-01-10', sessioneId: s1, valore: 80 },
|
||||||
|
{ data: '2026-05-10', sessioneId: s3, valore: 78 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destro e sinistro sono due linee nello stesso grafico', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, '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')!;
|
||||||
|
const handgrip = presa.grafici.find((g) => g.titolo === 'Handgrip')!;
|
||||||
|
expect(handgrip.linee.map((l) => l.testId).sort()).toEqual(['f_handgrip_dx_kg', 'f_handgrip_sx_kg']);
|
||||||
|
expect(handgrip.unita).toBe('kg');
|
||||||
|
const dx = handgrip.linee.find((l) => l.testId === 'f_handgrip_dx_kg')!;
|
||||||
|
expect(dx.punti[0]!.valore).toBe(32);
|
||||||
|
// etichetta letta dal registro (non ripetuta nel catalogo): contiene "destro".
|
||||||
|
expect(dx.etichetta.toLowerCase()).toContain('destro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il tooltip del 5RM porta le ripetizioni della stessa sessione, mai come linea a sé', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [
|
||||||
|
{ test_id: 'f_row_carico_kg', valore_num: 60 },
|
||||||
|
{ test_id: 'f_row_rip', valore_num: 5 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const trazione = sezioni.find((s) => s.titolo === 'Trazione')!;
|
||||||
|
const rematore = trazione.grafici.find((g) => g.titolo === 'Rematore')!;
|
||||||
|
expect(rematore.linee).toHaveLength(1); // f_row_rip non è una linea a sé
|
||||||
|
expect(rematore.linee[0]!.testId).toBe('f_row_carico_kg');
|
||||||
|
expect(rematore.linee[0]!.punti[0]).toEqual({ data: '2026-01-10', sessioneId: s1, valore: 60, extra: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('andamentoTest: cambio di test dentro la stessa sezione (Trazione)', () => {
|
||||||
|
it('rematore -> trazioni -> rematore: due cambi, con la data giusta', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 60 }, { test_id: 'f_row_rip', valore_num: 5 }]);
|
||||||
|
const s2 = checkup(db, '2026-03-10');
|
||||||
|
registraMisure(db, s2, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 65 }, { test_id: 'f_row_rip', valore_num: 5 }]);
|
||||||
|
const s3 = checkup(db, '2026-05-10');
|
||||||
|
registraMisure(db, s3, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 8 }]);
|
||||||
|
const s4 = checkup(db, '2026-07-10');
|
||||||
|
registraMisure(db, s4, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 70 }, { test_id: 'f_row_rip', valore_num: 6 }]);
|
||||||
|
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const trazione = sezioni.find((s) => s.titolo === 'Trazione')!;
|
||||||
|
expect(trazione.cambi).toEqual([
|
||||||
|
{ data: '2026-05-10', da: 'Rematore', a: 'Trazioni' },
|
||||||
|
{ data: '2026-07-10', da: 'Trazioni', a: 'Rematore' },
|
||||||
|
]);
|
||||||
|
// entrambi i grafici restano, coi loro punti: un cambio non unisce le due serie.
|
||||||
|
// Rematore ha 3 punti (s1, s2, s4): un cambio segna il momento, non cancella la serie.
|
||||||
|
expect(trazione.grafici.find((g) => g.titolo === 'Rematore')!.linee[0]!.punti).toHaveLength(3);
|
||||||
|
expect(trazione.grafici.find((g) => g.titolo === 'Trazioni')!.linee[0]!.punti).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un solo test in tutta la sezione: nessun cambio', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 8 }]);
|
||||||
|
const s2 = checkup(db, '2026-03-10');
|
||||||
|
registraMisure(db, s2, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 9 }]);
|
||||||
|
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const trazione = sezioni.find((s) => s.titolo === 'Trazione')!;
|
||||||
|
expect(trazione.cambi).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('andamentoTest: solo misure valide', () => {
|
||||||
|
it('fuori range non confermata è esclusa; confermata entra', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
// f_milfit_watt: range 20-500 (registro.ts) — 999 è fuori range.
|
||||||
|
const { fuoriRange } = registraMisure(db, s1, 'manuale', [{ test_id: 'f_milfit_watt', valore_num: 999 }]);
|
||||||
|
expect(fuoriRange).toEqual(['f_milfit_watt']);
|
||||||
|
|
||||||
|
let sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
expect(sezioni.find((s) => s.titolo === 'Aerobico')).toBeUndefined();
|
||||||
|
|
||||||
|
const misuraRiga = db.prepare(
|
||||||
|
`SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_milfit_watt'`
|
||||||
|
).get(s1) as { id: number };
|
||||||
|
const esito = verificaMisura(db, { misuraId: misuraRiga.id, esito: 'confermata', operatore: 'nicola' });
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
|
||||||
|
sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const aerobico = sezioni.find((s) => s.titolo === 'Aerobico')!;
|
||||||
|
const milfit = aerobico.grafici.find((g) => g.titolo === 'MILFIT')!;
|
||||||
|
expect(milfit.linee[0]!.punti).toEqual([{ data: '2026-01-10', sessioneId: s1, valore: 999 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('andamentoTest: visibilità trainer vs cliente', () => {
|
||||||
|
it('il cliente non vede i grafici soloTrainer, il trainer sì', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [
|
||||||
|
{ test_id: 'f_peso_kg', valore_num: 80 },
|
||||||
|
{ test_id: 'f_fc_riposo_bpm', valore_num: 60 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trainer = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const preTrainer = trainer.find((s) => s.titolo === 'Pre-test')!;
|
||||||
|
expect(preTrainer.grafici.map((g) => g.titolo).sort()).toEqual(['Frequenza cardiaca a riposo', 'Peso']);
|
||||||
|
|
||||||
|
const cliente = andamentoTest(db, 'ISL-0001', { trainer: false });
|
||||||
|
const preCliente = cliente.find((s) => s.titolo === 'Pre-test')!;
|
||||||
|
expect(preCliente.grafici.map((g) => g.titolo)).toEqual(['Peso']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una sezione fatta solo di grafici soloTrainer sparisce del tutto per il cliente', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [{ test_id: 'f_fc_riposo_bpm', valore_num: 60 }]);
|
||||||
|
|
||||||
|
const cliente = andamentoTest(db, 'ISL-0001', { trainer: false });
|
||||||
|
expect(cliente.find((s) => s.titolo === 'Pre-test')).toBeUndefined();
|
||||||
|
|
||||||
|
const trainer = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
expect(trainer.find((s) => s.titolo === 'Pre-test')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('andamentoTest: test fuori catalogo', () => {
|
||||||
|
it('un test f_* misurato ma non nel catalogo finisce in «Altre misure», mai perso', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = checkup(db, '2026-01-10');
|
||||||
|
registraMisure(db, s1, 'manuale', [{ test_id: 'f_altezza_cm', valore_num: 175 }]);
|
||||||
|
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
|
||||||
|
const altre = sezioni.find((s) => s.titolo === 'Altre misure')!;
|
||||||
|
expect(altre).toBeDefined();
|
||||||
|
expect(altre.grafici).toHaveLength(1);
|
||||||
|
expect(altre.grafici[0]!.linee[0]!.testId).toBe('f_altezza_cm');
|
||||||
|
expect(altre.grafici[0]!.unita).toBe('cm');
|
||||||
|
expect(altre.grafici[0]!.linee[0]!.punti[0]!.valore).toBe(175);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('andamentoTest: cliente senza misure', () => {
|
||||||
|
it('nessuna sessione, nessuna misura -> nessuna sezione', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','M')`).run();
|
||||||
|
const sezioni = andamentoTest(db, 'ISL-0002', { trainer: true });
|
||||||
|
expect(sezioni).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user