4 Commits

Author SHA1 Message Date
Adriano cbaac91138 longevity: #14 unità B, la linea unisce i punti presenti
connectNulls={false} contraddiceva DECISIONI d dell'analisi: con le righe
unite per sessione, una misura mancante su una sola linea (es. il
sinistro dell'handgrip, uno step MUTT) spezzava la linea invece di farle
saltare il buco. I test non confrontabili stanno già in grafici diversi
(unità A): dentro un grafico i punti di una linea vanno sempre uniti, il
buco resta visibile solo come distanza sull'asse X temporale.

Refs #14
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ToarVnK21BEig85QQowuw
2026-09-28 06:59:19 +00:00
Adriano 1eb69e0317 longevity: #14 unità B, grafici dei test per cliente e trainer
AndamentoTest.tsx (recharts, isola React come Progressione.tsx): un
LineChart per grafico del catalogo di andamento.ts, asse X temporale vero
(timestamp, scala 'time'), dx/sx come due linee distinte per tratteggio
(mai colore), tooltip con valore+unità ed extra (etichetta/unità dal
registro), righe di cambio test in gg/mm/aaaa.

progressione.astro: sezione «I tuoi test» (andamentoTest trainer:false),
visibile anche con meno di 2 sessioni — il grafico degli assi resta con
la sua condizione ≥2 invariata.

Nuova gestionale/cliente/[code]/andamento.astro (trainer:true, + grafico
degli assi se ≥2 punti), link «Andamento» in [code].astro accanto a
«Nuovo check-up» (.lg-btn, già ≥44px).

Modifica ammessa in andamento.ts: LineaAndamento.extra (etichetta/unità
dell'extraTestId dal registro) così il tooltip non indovina — test in
andamento.test.ts.

Refs #14
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ToarVnK21BEig85QQowuw
2026-09-28 06:57:58 +00:00
Adriano a883a60a83 longevity: #14 unità A, cambi di test solo fra alternative
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 06:47:11 +00:00
Adriano 67ad9701c1 longevity: #14 unità A, andamento dei test nel tempo (dati)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 06:43:57 +00:00
8 changed files with 933 additions and 0 deletions
@@ -0,0 +1,213 @@
// L'andamento nel tempo dei VALORI DEI TEST (issue #14, unità B): isola React come
// Progressione.tsx e Radar.tsx (recharts ha bisogno del DOM). Un piccolo LineChart per
// grafico del catalogo (`andamento.ts`), sotto il titolo della sua sezione, con le righe di
// cambio test in cima — stesso linguaggio visivo del resto del referto, niente di nuovo:
// - asse X TEMPORALE vero: `ts` (epoch ms) su una scala 'time', non una categoria — un buco
// fra due check-up si vede come DISTANZA, non come una tacca uguale alle altre (DECISIONI
// d dell'issue #14). Le etichette dell'asse e del tooltip sono in italiano.
// - `connectNulls` su ogni linea: un buco fra due check-up è DISTANZA sull'asse X (vedi
// sopra), non un segmento mancante — la linea unisce i punti presenti (DECISIONI d
// dell'issue #14). Diverso da Progressione.tsx: lì un macro-score mancante è uno zero
// travestito da buco onesto; qui i punti non confrontabili sono già in grafici diversi
// (cambio di test, unità A) — dentro UN grafico ogni punto della linea è la stessa misura,
// quindi si uniscono sempre, anche a distanza.
// - dx/sx (o gli otto step del MUTT) sono più linee nello stesso grafico, distinte per
// tratteggio/spessore — MAI per colore: un solo accento, come ovunque nel referto.
// - il tooltip mostra data, valore (con l'unità del grafico) ed `extra` (le ripetizioni del
// 5RM, la FC della camminata 2 km) con la SUA etichetta/unità dal registro — mai
// indovinata (unità A, `LineaAndamento.extra`).
// - un grafico con un solo punto mostra comunque il punto: `dot` resta acceso, il dominio
// dell'asse X viene allargato apposta (altrimenti min===max collasserebbe la scala).
import {
ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts';
import type { TooltipContentProps } from 'recharts';
import type { SezioneAndamento, GraficoAndamento, LineaAndamento } from '../../lib/longevity/andamento';
import { formattaNumero } from '../../lib/longevity/formato';
interface Props {
sezioni: SezioneAndamento[];
}
// Stessi colori esadecimali di Progressione.tsx/Radar.tsx (recharts vuole attributi SVG,
// non var(--...)): non sono colori nuovi, sono la stessa copia.
const ACCENTO = '#9c8b70';
const LINEA = '#e4dfd7';
const TESTO_CHIARO = '#8a8a8a';
const TESTO_SCURO = '#2b2b2b';
// Tratteggio e spessore per distinguere le linee di uno stesso grafico (dx/sx, gli otto
// step del MUTT) senza un colore nuovo per ciascuna — stessa idea di STILI_SERIE in
// Progressione.tsx, con più varianti perché "MUTT, frequenza cardiaca per step" ne ha otto.
const STILI_LINEA: { dash?: string; width: number }[] = [
{ dash: undefined, width: 2.2 },
{ dash: '6 3', width: 2 },
{ dash: '2 2', width: 1.6 },
{ dash: '8 3 2 3', width: 1.6 },
{ dash: '1 3', width: 1.6 },
{ dash: '10 2 2 2', width: 1.4 },
{ dash: '4 2', width: 1.4 },
{ dash: '3 1 1 1', width: 1.4 },
];
const UN_GIORNO_MS = 24 * 60 * 60 * 1000;
/** 'AAAA-MM-GG' -> epoch ms a mezzanotte locale, come Progressione.tsx/[code].astro (mai
* `Date.parse` diretto sulla stringa, che in alcuni motori interpreta la data come UTC e
* la sposta di un giorno rispetto al fuso del browser). */
function timestamp(dataIso: string): number {
return new Date(`${dataIso}T00:00:00`).getTime();
}
/** gg/mm/aaaa (analisi dell'issue #14: «Dal 12/03/2027: Trazioni (prima: Rematore)»).
* Usato nelle righe di cambio e nel tooltip, dove lo spazio non è stretto come sull'asse. */
function formattaDataEstesa(dataIso: string): string {
return new Date(`${dataIso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Formato compatto per i tick dell'asse X (gg/mm/aa, anno a due cifre): a 360px di
* larghezza gg/mm/aaaa per ogni tick non ci sta, e recharts scarta comunque i tick che si
* sovrappongono. */
function formattaDataCompatta(ts: number): string {
return new Date(ts).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
/** Come formattaDataEstesa, ma da un epoch ms (il `label` del tooltip, già calcolato da
* `timestamp()` a mezzanotte locale) invece che da una stringa AAAA-MM-GG: evita un
* andata-e-ritorno per ISO che a cavallo di mezzanotte UTC sposterebbe il giorno. */
function formattaTsEsteso(ts: number): string {
return new Date(ts).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
type RigaGrafico = { ts: number; data: string } & Record<string, number | string | undefined>;
/** Una riga per sessione (unione di tutte le sessioni con almeno una misura di una linea
* del grafico), in ordine cronologico: il formato che LineChart si aspetta, con una chiave
* per test_id (il valore, `undefined` = buco) e una `${test_id}__extra` per il tooltip. */
function righeDelGrafico(grafico: GraficoAndamento): RigaGrafico[] {
const perSessione = new Map<number, RigaGrafico>();
for (const linea of grafico.linee) {
for (const p of linea.punti) {
let riga = perSessione.get(p.sessioneId);
if (!riga) {
riga = { ts: timestamp(p.data), data: p.data };
perSessione.set(p.sessioneId, riga);
}
riga[linea.testId] = p.valore;
if (p.extra !== undefined) riga[`${linea.testId}__extra`] = p.extra;
}
}
return [...perSessione.values()].sort((a, b) => a.ts - b.ts);
}
/** Il dominio dell'asse X: min/max delle date, allargato quando collasserebbe su un solo
* istante (un solo punto, o più punti tutti nella stessa sessione) — altrimenti la scala
* 'time' non avrebbe ampiezza e il punto finirebbe al bordo o non renderizzerebbe. */
function dominioTemporale(righe: RigaGrafico[]): [number, number] {
const min = Math.min(...righe.map((r) => r.ts));
const max = Math.max(...righe.map((r) => r.ts));
if (min === max) return [min - UN_GIORNO_MS * 7, max + UN_GIORNO_MS * 7];
const margine = Math.max((max - min) * 0.05, UN_GIORNO_MS);
return [min - margine, max + margine];
}
/** Il tooltip di UN grafico: data (estesa), valore + unità per ogni linea attiva, e — se la
* linea ha un `extra` (unità A) — la sua etichetta/unità lette dal registro, mai indovinate. */
function tooltipDelGrafico(grafico: GraficoAndamento) {
const extraDiTestId = new Map<string, LineaAndamento['extra']>(
grafico.linee.filter((l) => l.extra).map((l) => [l.testId, l.extra])
);
return function ContenutoTooltip({ active, payload, label }: TooltipContentProps) {
if (!active || !payload || payload.length === 0 || typeof label !== 'number') return null;
return (
<div style={{ background: '#fff', border: `1px solid ${LINEA}`, padding: '8px 10px', fontSize: 12 }}>
<p style={{ margin: '0 0 4px', color: TESTO_SCURO, fontWeight: 600 }}>{formattaTsEsteso(label)}</p>
{payload.map((voce) => {
const testId = String(voce.dataKey);
const extra = extraDiTestId.get(testId);
const valoreExtra = extra ? (voce.payload as RigaGrafico)[`${testId}__extra`] : undefined;
return (
<p key={testId} style={{ margin: '2px 0', color: TESTO_SCURO }}>
{voce.name}: {formattaNumero(voce.value as number)}{grafico.unita ? ` ${grafico.unita}` : ''}
{extra && typeof valoreExtra === 'number' && (
<> — {extra.etichetta}: {formattaNumero(valoreExtra)}{extra.unita ? ` ${extra.unita}` : ''}</>
)}
</p>
);
})}
</div>
);
};
}
function GraficoTest({ grafico }: { grafico: GraficoAndamento }) {
const righe = righeDelGrafico(grafico);
if (righe.length === 0) return null; // andamentoTest garantisce ≥1 misura, rete secondaria
const dominio = dominioTemporale(righe);
const ContenutoTooltip = tooltipDelGrafico(grafico);
return (
<div className="lg-andamento__grafico">
<p className="lg-andamento__grafico-titolo">{grafico.titolo}{grafico.unita ? ` (${grafico.unita})` : ''}</p>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={righe} margin={{ top: 8, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid stroke={LINEA} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="ts" type="number" scale="time" domain={dominio}
tickFormatter={formattaDataCompatta} stroke={TESTO_CHIARO} fontSize={10} tickLine={false}
/>
<YAxis stroke={TESTO_CHIARO} fontSize={11} tickLine={false} axisLine={false} domain={['auto', 'auto']} />
<Tooltip content={ContenutoTooltip} />
{grafico.linee.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 11 }} />}
{grafico.linee.map((linea, i) => {
const stile = STILI_LINEA[i % STILI_LINEA.length]!;
return (
<Line
key={linea.testId}
type="monotone"
dataKey={linea.testId}
name={linea.etichetta}
stroke={ACCENTO}
strokeWidth={stile.width}
strokeDasharray={stile.dash}
dot={{ r: 3 }}
connectNulls={true}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default function AndamentoTest({ sezioni }: Props) {
if (sezioni.length === 0) {
return <p className="lg-muted">Nessun test ancora registrato.</p>;
}
return (
<div className="lg-andamento">
{sezioni.map((sezione) => (
<section className="lg-andamento__sezione" key={sezione.titolo}>
<h3 className="lg-andamento__sezione-titolo">{sezione.titolo}</h3>
{sezione.cambi.length > 0 && (
<ul className="lg-andamento__cambi">
{sezione.cambi.map((c) => (
<li key={`${c.data}-${c.a}`}>
Dal {formattaDataEstesa(c.data)}: {c.a} (prima: {c.da})
</li>
))}
</ul>
)}
<div className="lg-andamento__grafici">
{sezione.grafici.map((grafico) => (
<GraficoTest key={grafico.titolo} grafico={grafico} />
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,351 @@
//
// 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 };
/** `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[] };
// 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: [
// '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', 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', 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', gruppo: 'core', linee: [{ testId: 'f_plank_ginocchia_sec' }] },
{ titolo: 'Plank', gruppo: 'core', linee: [{ testId: 'f_plank_sec' }] },
] },
{ titolo: 'Spinta', grafici: [
{ 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', 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', 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', 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. 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', 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: [
{ 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 };
/** `extra`, quando la linea ha un `extraTestId` nel catalogo (es. le ripetizioni del 5RM,
* la FC della camminata 2 km): etichetta e unità del valore che `punti[].extra` porta, lette
* da `registro_test` come tutto il resto — il tooltip (unità B) le mostra invece di
* indovinarle dal solo numero nudo. Assente quando la linea non ha un `extraTestId`. */
export type LineaAndamento = {
testId: string; etichetta: string; punti: PuntoTest[]; extra?: { etichetta: string; unita: string | null };
};
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;
const voceExtra = l.extraTestId ? registro.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 };
}),
...(voceExtra ? { extra: { etichetta: voceExtra.etichetta, unita: voceExtra.unita } } : {}),
};
}
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, 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.
*
* 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<string, PuntoBase[]>
): CambioSezione[] {
const titoliVisibili = new Set(graficiVisibili.map((g) => g.titolo));
// Raggruppa i grafici visibili con un `gruppo` dichiarato, nell'ordine del catalogo.
const graficiPerGruppo = new Map<string, GraficoCatalogo[]>();
for (const g of graficiSezioneOrdinati) {
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<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 });
}
}
cambi.sort((a, b) => (a.data < b.data ? -1 : a.data > b.data ? 1 : 0));
return cambi;
}
@@ -80,6 +80,7 @@ const flusso = params.get('flusso');
{!cliente.ha_accesso && (
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
)}
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/andamento`}>Andamento</a>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup`}>Nuovo check-up</a>
</div>
</div>
@@ -0,0 +1,51 @@
---
// L'andamento nel tempo per il trainer (issue #14, unità B): gli stessi grafici del
// cliente più quelli soloTrainer (`andamentoTest(..., { trainer: true })` — FC a riposo,
// dettaglio MUTT, FC per step, recupero cardiaco storico), e il grafico degli assi
// (progressioneDi, come in progressione.astro) quando ci sono almeno due sessioni con
// punteggi congelati. Stessa protezione/struttura delle pagine sorelle: checkup.astro e
// consulenza.astro — nessuna query qui, tutto passa da lib/longevity.
import Longevity from '../../../../../layouts/Longevity.astro';
import Progressione from '../../../../../components/longevity/Progressione';
import AndamentoTest from '../../../../../components/longevity/AndamentoTest';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import { progressioneDi } from '../../../../../lib/longevity/progressione';
import { andamentoTest } from '../../../../../lib/longevity/andamento';
export const prerender = false;
const { code } = Astro.params;
const cliente = code ? clienteDaCodice(code) : null;
if (!cliente) return new Response('Cliente non trovato', { status: 404 });
const db = getLongevityDb();
const sezioniTest = andamentoTest(db, cliente.client_code, { trainer: true });
const punti = progressioneDi(db, cliente.client_code);
---
<Longevity
title={`Andamento — ${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Andamento']}
variante="gestionale"
>
<div class="lg-head">
<h1>Andamento</h1>
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}.</p>
</div>
<section class="lg-card">
<h2>Test</h2>
<AndamentoTest client:load sezioni={sezioniTest} />
</section>
{punti.length >= 2 && (
<section class="lg-card">
<h2>Assi, macro e Fitness Age</h2>
<Progressione client:load punti={punti} />
</section>
)}
<section class="lg-card">
<p><a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Torna alla scheda cliente</a>.</p>
</section>
</Longevity>
@@ -5,11 +5,17 @@
// sotto src/pages/longevity/), il codice cliente arriva da codicePerUtenteLoggato
// (anagrafica.ts, l'unico modulo autorizzato a toccare identity), i punti da progressioneDi
// (progressione.ts, che legge SOLO score — mai un ricalcolo).
// «I tuoi test» (issue #14, unità B) usa andamento.ts con `trainer: false`: il cliente vede
// i suoi test (compresi i dismessi, sono la sua storia), mai i grafici soloTrainer. Sezione
// indipendente dal grafico degli assi sotto: quella resta con la sua condizione ≥2 sessioni
// (DECISIONI dell'issue), questa è visibile da 1 misura in su (DECISIONI g).
import Longevity from '../../layouts/Longevity.astro';
import Progressione from '../../components/longevity/Progressione';
import AndamentoTest from '../../components/longevity/AndamentoTest';
import { getLongevityDb } from '../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
import { progressioneDi } from '../../lib/longevity/progressione';
import { andamentoTest } from '../../lib/longevity/andamento';
export const prerender = false;
@@ -17,6 +23,7 @@ const user = Astro.locals.user!;
const codice = codicePerUtenteLoggato(user.id);
const db = getLongevityDb();
const punti = codice ? progressioneDi(db, codice) : [];
const sezioniTest = codice ? andamentoTest(db, codice, { trainer: false }) : [];
---
<Longevity title="Andamento nel tempo" crumbs={['Andamento nel tempo']}>
{!codice && (
@@ -25,6 +32,13 @@ const punti = codice ? progressioneDi(db, codice) : [];
</div>
)}
{codice && (
<section class="lg-card">
<h2>I tuoi test</h2>
<AndamentoTest client:load sezioni={sezioniTest} />
</section>
)}
{codice && punti.length < 2 && (
<div class="lg-card lg-card--incompleto">
<p>Serve almeno un secondo check-up o questionario per vedere un andamento.</p>
+15
View File
@@ -127,6 +127,21 @@
}
.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; }
/* --- l'andamento di ogni test (#14): il grafico vive nell'isola React
(AndamentoTest.tsx), qui il titolo di sezione, le righe di cambio test e la griglia dei
grafici piccoli — un grafico per test, leggibile anche a 360px (una colonna sola). --- */
.lg-andamento { display: flex; flex-direction: column; gap: 28px; }
.lg-andamento__sezione { display: flex; flex-direction: column; gap: 10px; }
.lg-andamento__sezione-titolo {
margin: 0; font-family: var(--font-heading); font-size: .95rem; font-weight: 600;
color: var(--c-heading); border-bottom: 1px solid var(--lg-line); padding-bottom: 6px;
}
.lg-andamento__cambi { margin: 0; padding-left: 18px; font-size: .82rem; color: var(--c-text-light); }
.lg-andamento__cambi li { margin-bottom: 2px; }
.lg-andamento__grafici { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.lg-andamento__grafico { border: 1px solid var(--lg-line); padding: 10px 12px 4px; }
.lg-andamento__grafico-titolo { margin: 0 0 4px; font-size: .82rem; font-weight: 600; color: var(--c-heading); }
/* --- le carte dei macro-score --- */
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
@@ -0,0 +1,269 @@
// 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 });
});
it('la linea con extraTestId porta etichetta e unità del valore in tooltip, lette dal registro (unità B non indovina)', () => {
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 },
{ test_id: 'f_walk2km_min', valore_num: 18 },
{ test_id: 'f_walk2km_fc_bpm', valore_num: 140 },
{ test_id: 'f_peso_kg', valore_num: 80 },
]);
const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true });
const rematore = sezioni.find((s) => s.titolo === 'Trazione')!.grafici.find((g) => g.titolo === 'Rematore')!;
// f_row_rip in registro.ts non ha `unita`: deve arrivare null, mai undefined (silenzioso).
expect(rematore.linee[0]!.extra).toEqual({ etichetta: 'Trazione/rematore, ripetizioni (test 5RM)', unita: null });
const camminata = sezioni.find((s) => s.titolo === 'Aerobico')!.grafici.find((g) => g.titolo === 'Camminata 2 km')!;
expect(camminata.linee[0]!.extra).toEqual({ etichetta: 'Camminata 2 km (UKK), frequenza cardiaca finale', unita: 'bpm' });
// Una linea senza extraTestId (es. Peso) non ha proprietà `extra`.
const peso = sezioni.find((s) => s.titolo === 'Pre-test')!.grafici.find((g) => g.titolo === 'Peso')!;
expect(peso.linee[0]!.extra).toBeUndefined();
});
});
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: 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();
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([]);
});
});
@@ -121,6 +121,13 @@ describe('il guscio della piattaforma', () => {
// in un file fuori dai due moduli autorizzati.
expect(src).not.toContain('Identity');
});
it('progressione.astro usa andamento.ts con trainer:false (#14): il cliente non deve vedere i grafici riservati', () => {
const p = join(process.cwd(), 'src/pages/longevity/progressione.astro');
const src = readFileSync(p, 'utf8');
expect(src).toMatch(/from ['"`].*lib\/longevity\/andamento['"`]/);
expect(src).toMatch(/andamentoTest\([^)]*trainer:\s*false/);
});
});
describe('il gestionale del trainer (Unità 2b)', () => {
@@ -130,6 +137,7 @@ describe('il gestionale del trainer (Unità 2b)', () => {
'src/pages/longevity/gestionale/cliente/[code].astro',
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
'src/pages/longevity/gestionale/cliente/[code]/andamento.astro', // #14
];
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {
@@ -170,6 +178,17 @@ describe('il gestionale del trainer (Unità 2b)', () => {
expect(src).toMatch(/\/longevity\/gestionale\/cliente\/\$\{cliente\.client_code\}\/accesso/);
});
it('la scheda cliente porta il link "Andamento" accanto a "Nuovo check-up" (#14)', () => {
const src = leggi('src/pages/longevity/gestionale/cliente/[code].astro');
expect(src).toMatch(/\/longevity\/gestionale\/cliente\/\$\{cliente\.client_code\}\/andamento/);
});
it('la pagina andamento del gestionale usa andamento.ts con trainer:true (#14): il trainer vede anche i grafici riservati', () => {
const src = leggi('src/pages/longevity/gestionale/cliente/[code]/andamento.astro');
expect(src).toMatch(/from ['"`].*lib\/longevity\/andamento['"`]/);
expect(src).toMatch(/andamentoTest\([^)]*trainer:\s*true/);
});
it('nessuna pagina longevity usa piu misureFuoriRange, rimossa da gestionale.ts a favore di misureDaVerificare (#7)', () => {
// Scandisce TUTTE le pagine sotto src/pages/longevity/, stesso criterio di
// referto.test.ts: un elenco scritto qui non protegge una pagina nuova.