diff --git a/apps/platforms/src/components/longevity/AndamentoTest.tsx b/apps/platforms/src/components/longevity/AndamentoTest.tsx new file mode 100644 index 0000000..ae0ac6d --- /dev/null +++ b/apps/platforms/src/components/longevity/AndamentoTest.tsx @@ -0,0 +1,209 @@ +// 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={false}` su ogni linea: una sessione senza quel test è un buco onesto, +// mai un segmento che la attraversa (stesso principio di Progressione.tsx). +// - 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; + +/** 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(); + 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( + 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 ( +
+

{formattaTsEsteso(label)}

+ {payload.map((voce) => { + const testId = String(voce.dataKey); + const extra = extraDiTestId.get(testId); + const valoreExtra = extra ? (voce.payload as RigaGrafico)[`${testId}__extra`] : undefined; + return ( +

+ {voce.name}: {formattaNumero(voce.value as number)}{grafico.unita ? ` ${grafico.unita}` : ''} + {extra && typeof valoreExtra === 'number' && ( + <> — {extra.etichetta}: {formattaNumero(valoreExtra)}{extra.unita ? ` ${extra.unita}` : ''} + )} +

+ ); + })} +
+ ); + }; +} + +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 ( +
+

{grafico.titolo}{grafico.unita ? ` (${grafico.unita})` : ''}

+ + + + + + + {grafico.linee.length > 1 && } + {grafico.linee.map((linea, i) => { + const stile = STILI_LINEA[i % STILI_LINEA.length]!; + return ( + + ); + })} + + +
+ ); +} + +export default function AndamentoTest({ sezioni }: Props) { + if (sezioni.length === 0) { + return

Nessun test ancora registrato.

; + } + + return ( +
+ {sezioni.map((sezione) => ( +
+

{sezione.titolo}

+ {sezione.cambi.length > 0 && ( +
    + {sezione.cambi.map((c) => ( +
  • + Dal {formattaDataEstesa(c.data)}: {c.a} (prima: {c.da}) +
  • + ))} +
+ )} +
+ {sezione.grafici.map((grafico) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/platforms/src/lib/longevity/andamento.ts b/apps/platforms/src/lib/longevity/andamento.ts index 60a699f..eeeeaa2 100644 --- a/apps/platforms/src/lib/longevity/andamento.ts +++ b/apps/platforms/src/lib/longevity/andamento.ts @@ -120,7 +120,13 @@ const CATALOGO: SezioneCatalogo[] = [ 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[] }; +/** `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[] }; @@ -219,6 +225,7 @@ export function andamentoTest( 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, @@ -226,6 +233,7 @@ export function andamentoTest( const extra = mappaExtra?.get(p.sessioneId); return extra === undefined ? { ...p } : { ...p, extra }; }), + ...(voceExtra ? { extra: { etichetta: voceExtra.etichetta, unita: voceExtra.unita } } : {}), }; } diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro index c773d3f..95c29f3 100644 --- a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro @@ -80,6 +80,7 @@ const flusso = params.get('flusso'); {!cliente.ha_accesso && ( Crea accesso al referto )} + Andamento Nuovo check-up diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/andamento.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/andamento.astro new file mode 100644 index 0000000..051db7b --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/andamento.astro @@ -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); +--- + +
+

Andamento

+

{cliente.cognome} {cliente.nome} — {cliente.client_code}.

+
+ +
+

Test

+ +
+ + {punti.length >= 2 && ( +
+

Assi, macro e Fitness Age

+ +
+ )} + +
+

Torna alla scheda cliente.

+
+
diff --git a/apps/platforms/src/pages/longevity/progressione.astro b/apps/platforms/src/pages/longevity/progressione.astro index b95d4b2..b8b5f4b 100644 --- a/apps/platforms/src/pages/longevity/progressione.astro +++ b/apps/platforms/src/pages/longevity/progressione.astro @@ -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 }) : []; --- {!codice && ( @@ -25,6 +32,13 @@ const punti = codice ? progressioneDi(db, codice) : []; )} + {codice && ( +
+

I tuoi test

+ +
+ )} + {codice && punti.length < 2 && (

Serve almeno un secondo check-up o questionario per vedere un andamento.

diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index 4daf1f2..7325776 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -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; } diff --git a/apps/platforms/tests/longevity/andamento.test.ts b/apps/platforms/tests/longevity/andamento.test.ts index 259ea7d..a501bf6 100644 --- a/apps/platforms/tests/longevity/andamento.test.ts +++ b/apps/platforms/tests/longevity/andamento.test.ts @@ -74,6 +74,31 @@ describe('andamentoTest: serie nel tempo, buchi e doppie linee', () => { 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)', () => { diff --git a/apps/platforms/tests/longevity/pagine.test.ts b/apps/platforms/tests/longevity/pagine.test.ts index ae574fc..8ad3dae 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -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.