diff --git a/apps/platforms/src/components/longevity/Progressione.tsx b/apps/platforms/src/components/longevity/Progressione.tsx new file mode 100644 index 0000000..3c9922f --- /dev/null +++ b/apps/platforms/src/components/longevity/Progressione.tsx @@ -0,0 +1,139 @@ +// L'andamento nel tempo: una linea per macro-score, isola React come Radar (recharts ha +// bisogno del DOM). Stesso linguaggio visivo del radar, non uno nuovo: +// - `connectNulls={false}` su ogni linea: un valore mancante (sessione insufficiente per +// quel macro-score) è un BUCO nella linea, non un segmento che la attraversa come se il +// dato ci fosse. Un buco onesto vale più di una linea continua che mente. +// - le tre serie (PERFORMANCE/ENERGY/RECOVERY) si distinguono per tratteggio e spessore, +// MAI per colore: qui non c'è semaforo, come nel radar (vedi longevity.css). Un colore +// diverso per macro-score si leggerebbe come un giudizio ("questo è il buono, quello è il +// cattivo"), che il servizio non fa. +// - la riga verticale tratteggiata su un cambio di versione usa lo stesso colore del +// perimetro "non lo sappiamo ancora" del radar (TRATTEGGIO): un confine dichiarato, non +// un errore. +import { + ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ReferenceLine, +} from 'recharts'; +import type { PuntoProgressione } from '../../lib/longevity/progressione'; +import { formattaNumero } from '../../lib/longevity/formato'; + +interface Props { + punti: PuntoProgressione[]; +} + +// Stessi colori di Radar.tsx (esadecimali, non var(--...): recharts vuole attributi SVG, +// non proprietà CSS). Non sono colori nuovi, sono una copia — vedi il commento in Radar.tsx. +const ACCENTO = '#9c8b70'; +const TRATTEGGIO = '#d8d2c8'; +const LINEA = '#e4dfd7'; +const TESTO_CHIARO = '#8a8a8a'; + +/** 'PERFORMANCE' -> 'Performance': stessa trasformazione di MacroScore.astro, non un + * dizionario duplicato da tenere allineato — il NOME resta quello di prodotto (formato.ts), + * solo la resa grafica cambia maiuscola/minuscola. */ +const etichetta = (nome: string) => nome.charAt(0) + nome.slice(1).toLowerCase(); + +// Tratteggio e spessore che distinguono le tre serie senza un colore nuovo per ciascuna: +// PERFORMANCE piena e più spessa (il macro-score principale), le altre due via via più +// leggere. L'ordine qui non impone un ordine di lettura: è solo il fallback per un +// macro-score che un domani non fosse fra questi tre (mai successo finora, ma +// `progressioneDi` non hardcoda i nomi — vedi il commento lì — quindi nemmeno qui). +const STILI_SERIE: { dash?: string; width: number }[] = [ + { dash: undefined, width: 2.5 }, + { dash: '6 3', width: 2 }, + { dash: '2 2', width: 1.5 }, +]; + +export default function Progressione({ punti }: Props) { + const nomiMacro = Array.from(new Set(punti.flatMap((p) => Object.keys(p.macro)))); + const nomiAssi = Array.from(new Set(punti.flatMap((p) => Object.keys(p.assi)))); + + const dati = punti.map((p) => { + const riga: Record = { data: p.data }; + for (const nome of nomiMacro) riga[nome] = p.macro[nome]; + return riga; + }); + + const cambi = punti.filter((p) => p.cambioVersione !== null); + + return ( +
+

+ Una linea per macro-score (Performance, Energy, Recovery): dove manca un tratto, quella + sessione non aveva dati sufficienti per quel numero — non è uno zero, è un buco. La + riga verticale tratteggiata segna dove è cambiato il questionario o il modo di + calcolare: i numeri prima e dopo non si confrontano allo stesso modo. +

+ +
+ + + + + + + + {cambi.map((p) => ( + + ))} + {nomiMacro.map((nome, i) => { + const stile = STILI_SERIE[i % STILI_SERIE.length]; + return ( + + ); + })} + + +
+ + {/* La tabella per asse: la stessa granularità del radar (sette assi), qui nel tempo + invece che in un solo istante. «—» dove il dato non basta, mai uno zero. */} +
+ + + + + {punti.map((p) => ( + + ))} + + + + {nomiAssi.map((nome) => ( + + + {punti.map((p) => { + const v = p.assi[nome]; + return ( + + ); + })} + + ))} + +
{p.data}
{nome} + {v !== null && v !== undefined ? formattaNumero(v) : '—'} +
+
+
+ ); +} diff --git a/apps/platforms/src/pages/longevity/io.astro b/apps/platforms/src/pages/longevity/io.astro index 7adb7d4..e03e8c0 100644 --- a/apps/platforms/src/pages/longevity/io.astro +++ b/apps/platforms/src/pages/longevity/io.astro @@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;

InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni raccontano come cambi, non solo come stai oggi. - Compila di nuovo il questionario. + Guarda il tuo andamento nel tempo o + compila di nuovo il questionario.

diff --git a/apps/platforms/src/pages/longevity/progressione.astro b/apps/platforms/src/pages/longevity/progressione.astro new file mode 100644 index 0000000..b95d4b2 --- /dev/null +++ b/apps/platforms/src/pages/longevity/progressione.astro @@ -0,0 +1,47 @@ +--- +// L'andamento nel tempo del cliente: una sessione per punto, i punteggi congelati che +// quella sessione ha davvero. Stesso modello di io.astro: la pagina non interroga il +// database da sola (c'è un test che lo verifica, referto.test.ts, esteso a tutte le pagine +// 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). +import Longevity from '../../layouts/Longevity.astro'; +import Progressione from '../../components/longevity/Progressione'; +import { getLongevityDb } from '../../lib/longevity/db'; +import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica'; +import { progressioneDi } from '../../lib/longevity/progressione'; + +export const prerender = false; + +const user = Astro.locals.user!; +const codice = codicePerUtenteLoggato(user.id); +const db = getLongevityDb(); +const punti = codice ? progressioneDi(db, codice) : []; +--- + + {!codice && ( +
+

Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.

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

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

+

Torna al tuo referto.

+
+ )} + + {codice && punti.length >= 2 && ( +
+
+

Il tuo andamento

+ +
+ +
+

Torna al tuo referto.

+
+
+ )} +
diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index daaac5c..8823402 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -100,6 +100,22 @@ .lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } .lg-radar__lista li { margin-bottom: 2px; } +/* --- l'andamento nel tempo: il grafico vive nell'isola React (Progressione.tsx), + qui solo il testo introduttivo e la tabella per asse sotto al grafico. --- */ +.lg-progressione { display: flex; flex-direction: column; gap: 20px; } +.lg-progressione__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); } +.lg-progressione__grafico { width: 100%; } +.lg-progressione__tabella { overflow-x: auto; } +.lg-progressione__table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.lg-progressione__table th, .lg-progressione__table td { + padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--lg-line); + white-space: nowrap; +} +.lg-progressione__table th:first-child, .lg-progressione__table td:first-child { + text-align: left; color: var(--c-heading); font-weight: 500; +} +.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; } + /* --- 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/pagine.test.ts b/apps/platforms/tests/longevity/pagine.test.ts index c4ce3c7..82d173f 100644 --- a/apps/platforms/tests/longevity/pagine.test.ts +++ b/apps/platforms/tests/longevity/pagine.test.ts @@ -93,4 +93,19 @@ describe('il guscio della piattaforma', () => { const src = readFileSync(p, 'utf8'); expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); }); + + it('la pagina dell andamento non interroga il database e usa codicePerUtenteLoggato', () => { + // Rete secondaria alla stessa prova generica di referto.test.ts ("nessuna pagina + // interroga il database da sola", che scandisce TUTTE le .astro sotto longevity/): + // qui si controlla in piu' che il codice cliente arrivi dalla via autorizzata, non da + // un'altra query o da un parametro della richiesta. + const p = join(process.cwd(), 'src/pages/longevity/progressione.astro'); + expect(existsSync(p)).toBe(true); + const src = readFileSync(p, 'utf8'); + expect(src).not.toMatch(/\.prepare\(/); + expect(src).toMatch(/codicePerUtenteLoggato/); + // Stessa rete secondaria di anagrafica.test.ts: nessuna giunzione identity/longevity + // in un file fuori dai due moduli autorizzati. + expect(src).not.toContain('Identity'); + }); });