From 7f01ee0b45bdeecb87e3c2e9aa11574f776a7cc9 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 14:28:29 +0200 Subject: [PATCH] longevity: il referto del cliente, e il radar che dice cosa non sa --- src/components/longevity/MacroScore.astro | 37 ++++++ src/components/longevity/Radar.tsx | 86 ++++++++++++++ src/lib/longevity/vista.ts | 130 +++++++++++++++++++++- src/pages/longevity/io.astro | 57 ++++++++++ src/styles/longevity.css | 25 +++++ tests/longevity/referto.test.ts | 62 +++++++++++ 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/components/longevity/MacroScore.astro create mode 100644 src/components/longevity/Radar.tsx create mode 100644 src/pages/longevity/io.astro create mode 100644 tests/longevity/referto.test.ts diff --git a/src/components/longevity/MacroScore.astro b/src/components/longevity/MacroScore.astro new file mode 100644 index 0000000..2b220e3 --- /dev/null +++ b/src/components/longevity/MacroScore.astro @@ -0,0 +1,37 @@ +--- +// La carta di un macro-score (Performance/Energy/Recovery). Stesso principio del radar: +// il numero se il dato basta, un trattino se no — MAI uno zero, che si leggerebbe come +// "hai zero" invece di "non lo sappiamo ancora". Il bordo tratteggiato (.lg-card--incompleto, +// dal Task 1) è lo stesso linguaggio visivo del radar: non una convenzione nuova per le carte. +// L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un +// elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery +// con una trasformazione, non con un dizionario da tenere allineato a mano. +interface Props { + nome: string; + valore: number | null; + copertura: number; +} +const { nome, valore, copertura } = Astro.props; +const etichetta = nome.charAt(0) + nome.slice(1).toLowerCase(); +// L'intensità dell'accento (mai un colore diverso, Task 1): minimo 0.15 così una carta +// nota ma bassa resta comunque leggibile, non sparisce nello sfondo. +const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0; +--- +
+

{etichetta}

+
+ {valore !== null ? valore : '—'} + / 100 +
+
+ +
+ {valore === null && ( +

+ Copertura dati: {Math.round(copertura * 100)}% — non ancora sufficiente per un numero. +

+ )} +
diff --git a/src/components/longevity/Radar.tsx b/src/components/longevity/Radar.tsx new file mode 100644 index 0000000..26799c2 --- /dev/null +++ b/src/components/longevity/Radar.tsx @@ -0,0 +1,86 @@ +// Il radar a sette assi del referto: isola React perché recharts ha bisogno del DOM. +// Due serie sovrapposte sugli stessi sette assi (recharts vuole un solo `data` condiviso, +// non può disegnare un poligono che copre solo alcuni assi): +// - "noto": l'area piena, coi valori congelati che la sessione ha davvero. Dove il dato +// manca vale 0 — non si inventa un centro pieno per una misura che non c'è, si lascia +// che l'area si ritiri, onestamente, verso il centro; +// - "potenziale": il perimetro tratteggiato. Dove il dato c'è coincide col bordo di +// "noto" (stesso valore, la linea tratteggiata sparisce dietro quella piena); dove +// manca si allarga fino al massimo (100) — è la parte che CHIUDE il perimetro, il gesto +// su cui si regge l'interfaccia: non un errore, un invito a completare quell'asse. +// Sotto il grafico, l'elenco leggibile di cosa completerebbe ogni asse ancora tratteggiato, +// letto da `mancano` (calcolato in vista.ts, mai qui: questo componente non tocca il db). +import { + ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, +} from 'recharts'; +import type { AsseVista } from '../../lib/longevity/vista'; + +interface Props { + assi: AsseVista[]; +} + +// Stessi colori del sito (come in DataChart.tsx, che per lo stesso motivo — attributi SVG, +// non proprietà CSS — non passa var(--...) a recharts): l'accento e il tratteggio di +// longevity.css, non un colore nuovo. +const ACCENTO = '#9c8b70'; +const TRATTEGGIO = '#d8d2c8'; +const LINEA = '#e4dfd7'; +const TESTO_CHIARO = '#8a8a8a'; + +export default function RadarAssi({ assi }: Props) { + const dati = assi.map((a) => ({ + nome: a.nome, + noto: a.valore ?? 0, + potenziale: a.valore ?? 100, + })); + const incompleti = assi.filter((a) => a.mancano.length > 0); + + return ( +
+
+ + + + + + {/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */} + + + + +
+ + {incompleti.length > 0 && ( +
+

+ L'area piena è quello che sappiamo davvero. Il perimetro tratteggiato si completa così: +

+ {incompleti.map((a) => ( +
+

{a.nome}

+
    + {a.mancano.map((m) =>
  • {m}
  • )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts index 4acc370..9a35ad7 100644 --- a/src/lib/longevity/vista.ts +++ b/src/lib/longevity/vista.ts @@ -9,8 +9,9 @@ // nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a // cosa) che un domani va dimostrato tanto quanto un assenso. import type Database from 'better-sqlite3'; -import { testAttivi } from './registro'; +import { testAttivi, pesiDi, MODEL_VERSION } from './registro'; import { salvaCompilazione, QUEST_VERSION } from './questionario'; +import { leggiScore } from './motore'; export type CampoQuestionario = { id: string; @@ -97,3 +98,130 @@ export function salvaDalForm( return { ok: true, sessioneId }; } + +// +// Il referto che il cliente legge: il radar a sette assi e le carte dei macro-score. +// Due regole non negoziabili, entrambe già chiuse a monte, non da questo modulo: +// - il valore viene da `leggiScore` (punteggi congelati in `score`), mai da un +// ricalcolo — cambiare i pesi non deve mai cambiare un referto già emesso; +// - un asse/macro insufficiente non porta un `valore` (resta `null`): è il tipo +// `Punteggio` del motore che lo rende impossibile da violare per distrazione. +// + +/** Un asse del radar: il valore (assente se il dato non basta), la copertura di peso + * disponibile e — solo quando manca — l'elenco leggibile di cosa lo completerebbe. */ +export type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] }; + +/** Una carta di macro-score: stesso principio degli assi, senza l'elenco di cosa manca + * (il "cosa manca" si legge scendendo ai sette assi, non qui). */ +export type MacroVista = { nome: string; valore: number | null; copertura: number }; + +/** + * Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle + * di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i + * test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il + * questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non + * può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa + * dove il registro non arriva ancora. + */ +const ETICHETTE_SOTTO_DOMINIO: Record = { + handgrip: 'Forza di presa (handgrip)', + spinta: 'Test di spinta (push-up o panca)', + trazione: 'Test di trazione', + arti_inferiori: 'Test arti inferiori', + core: 'Test del core (plank)', + grasso: 'Composizione corporea — massa grassa', + muscolo: 'Composizione corporea — massa muscolare', + whr: 'Rapporto vita-fianchi (WHR)', + vo2max: 'Test cardio (VO2max)', + spirometria: 'Spirometria', + wellness_tower_cardio: 'Wellness Tower — cardio', + hrv: 'Variabilità della frequenza cardiaca (HRV)', + pressione: 'Pressione arteriosa', + hrr: 'Frequenza cardiaca di recupero (HRR)', + questionario_sonno: 'Questionario del sonno', + questionario_energia_stress: 'Questionario energia e stress', + flamingo: 'Test di equilibrio (flamingo)', + sit_and_reach: 'Test di flessibilità (sit and reach)', + plank: 'Plank (tenuta)', + back_scratch: 'Test di mobilità delle spalle (back scratch)', + wellness_tower_shoulder: 'Wellness Tower — spalla', + questionario_lifestyle: 'Questionario di stile di vita', +}; + +/** I contenitori (assi o macro-score) dichiarati nei pesi per il model version corrente, + * nell'ordine in cui compaiono nella tabella — che è l'ordine di inserimento di `seedPesi`, + * cioè quello dichiarato in registro.ts. Non un elenco duplicato qui: si legge dal dato. */ +function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string[] { + return (db.prepare( + `SELECT contenitore FROM pesi WHERE model_version = ? AND livello = ? + GROUP BY contenitore ORDER BY MIN(rowid)` + ).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore); +} + +/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione: + * l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali + * test sono stati fatti non è ricalcolare come pesano. */ +function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set { + const righe = db.prepare( + `SELECT DISTINCT rt.sotto_dominio AS sd + FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id + WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL + AND rt.sotto_dominio IS NOT NULL` + ).all(sessioneId) as { sd: string }[]; + return new Set(righe.map((r) => r.sd)); +} + +/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse + * che questa sessione non ha, tradotti nell'etichetta leggibile. */ +function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, presenti: Set): string[] { + const pesi = pesiDi(db, MODEL_VERSION, 'asse', nomeAsse); + return Object.keys(pesi) + .filter((sd) => !presenti.has(sd)) + .map((sd) => ETICHETTE_SOTTO_DOMINIO[sd] ?? sd); +} + +/** + * Il referto di una sessione: i sette assi del radar, i macro-score e la Fitness Age. + * Legge SOLO `leggiScore` per i valori (punteggi congelati): non chiama mai + * `calcolaSessione`, quindi un peso cambiato dopo il fatto non altera un referto già + * emesso. `mancano` guarda invece le misure grezze della sessione (quali test ci sono), + * non il punteggio — è informazione su cosa manca, non un ricalcolo di quanto vale. + */ +export function refertoDi( + db: Database.Database, + sessioneId: number +): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null } { + const punteggi = leggiScore(db, sessioneId); + const presenti = sottoDominiPresenti(db, sessioneId); + + const assi: AsseVista[] = contenitoriDi(db, 'asse').map((nome) => { + const p = punteggi.assi[nome]; + if (p?.stato === 'ok') return { nome, valore: p.valore, copertura: p.copertura, mancano: [] }; + return { + nome, valore: null, copertura: p?.copertura ?? 0, + mancano: mancanoPer(db, sessioneId, nome, presenti), + }; + }); + + const macro: MacroVista[] = contenitoriDi(db, 'macro').map((nome) => { + const p = punteggi.macro[nome]; + return p?.stato === 'ok' + ? { nome, valore: p.valore, copertura: p.copertura } + : { nome, valore: null, copertura: p?.copertura ?? 0 }; + }); + + const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; + + return { assi, macro, fitnessAge }; +} + +/** L'ultima sessione di un cliente (la più recente per data, a parità di data la più + * recente per id). `null` se il cliente non ha ancora nessuna sessione — la pagina del + * referto la usa per non dover interrogare `sessioni` da sola. */ +export function ultimaSessioneDi(db: Database.Database, clientCode: string): number | null { + const r = db.prepare( + `SELECT id FROM sessioni WHERE client_code = ? ORDER BY data DESC, id DESC LIMIT 1` + ).get(clientCode) as { id: number } | undefined; + return r?.id ?? null; +} diff --git a/src/pages/longevity/io.astro b/src/pages/longevity/io.astro new file mode 100644 index 0000000..6d2b992 --- /dev/null +++ b/src/pages/longevity/io.astro @@ -0,0 +1,57 @@ +--- +// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score. +// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice +// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a +// toccare identity), la sessione più recente e il referto stesso da vista.ts. +import Longevity from '../../layouts/Longevity.astro'; +import Radar from '../../components/longevity/Radar'; +import MacroScore from '../../components/longevity/MacroScore.astro'; +import { getLongevityDb } from '../../lib/longevity/db'; +import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica'; +import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista'; + +export const prerender = false; + +const user = Astro.locals.user!; +const codice = codicePerUtenteLoggato(user.id); +const db = getLongevityDb(); +const sessioneId = codice ? ultimaSessioneDi(db, codice) : null; +const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null; +--- + + {!codice && ( +
+

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

+
+ )} + + {codice && !referto && ( +
+

Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.

+

Comincia da qui.

+
+ )} + + {referto && ( +
+
+

Fitness Age

+
+ {referto.fitnessAge ?? '—'} + {referto.fitnessAge !== null && anni} +
+
+ +
+

Il tuo radar

+ +
+ +
+ {referto.macro.map((m) => ( + + ))} +
+
+ )} +
diff --git a/src/styles/longevity.css b/src/styles/longevity.css index d87b2f7..daaac5c 100644 --- a/src/styles/longevity.css +++ b/src/styles/longevity.css @@ -79,6 +79,31 @@ .lg-muted { color: var(--c-text-light); } +/* i numeri che si confrontano nel tempo non devono ballare a ogni cifra */ +.lg-num { font-variant-numeric: tabular-nums; } + +/* --- il referto: colonna di sezioni --- */ +.lg-referto { display: flex; flex-direction: column; gap: 24px; } + +/* --- il radar dei sette assi: area piena + perimetro tratteggiato --- + Il disegno vive nell'isola React (Radar.tsx); qui solo il testo sotto al grafico — + l'elenco di ciò che manca, che è la parte che rende il buco un invito. */ +.lg-radar { display: flex; flex-direction: column; gap: 20px; } +.lg-radar__grafico { width: 100%; } +.lg-radar__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); } +.lg-radar__mancano { display: flex; flex-direction: column; gap: 14px; } +.lg-radar__voce { border-top: 1px dashed var(--lg-tratteggio); padding-top: 12px; } +.lg-radar__voce-nome { + margin: 0 0 6px; font-family: var(--font-heading); font-size: .82rem; font-weight: 600; + color: var(--c-heading); +} +.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } +.lg-radar__lista li { margin-bottom: 2px; } + +/* --- 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; } + /* --- il questionario: un blocco alla volta, avanti/indietro --- */ .lg-form { display: flex; flex-direction: column; gap: 24px; } .lg-form__passo { diff --git a/tests/longevity/referto.test.ts b/tests/longevity/referto.test.ts new file mode 100644 index 0000000..574d93f --- /dev/null +++ b/tests/longevity/referto.test.ts @@ -0,0 +1,62 @@ +// tests/longevity/referto.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; +import { refertoDi } from '../../src/lib/longevity/vista'; + +function conCompilazione() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 }, + }); + salvaScore(db, s, calcolaSessione(db, s)); + return { db, s }; +} + +describe('il referto del cliente', () => { + it('restituisce i sette assi', () => { + const { db, s } = conCompilazione(); + expect(refertoDi(db, s).assi.length).toBe(7); + }); + + it('un asse senza dati NON porta un numero', () => { + const { db, s } = conCompilazione(); + const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!; + expect(forza.valore).toBeNull(); + }); + + it('e dice cosa manca per completarlo, invece di lasciare un buco', () => { + const { db, s } = conCompilazione(); + const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!; + expect(forza.mancano.length).toBeGreaterThan(0); + }); + + it('un asse coperto porta il suo numero', () => { + const { db, s } = conCompilazione(); + const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!; + expect(stile.valore).not.toBeNull(); + expect(stile.mancano).toEqual([]); + }); + + it('legge i punteggi congelati, non li ricalcola al volo', () => { + const { db, s } = conCompilazione(); + // se rileggesse le misure invece degli score salvati, cambiare i pesi cambierebbe il referto + const prima = JSON.stringify(refertoDi(db, s)); + db.prepare(`UPDATE pesi SET peso = 0.99 WHERE model_version = ? AND elemento = 'questionario_lifestyle'`).run(MODEL_VERSION); + expect(JSON.stringify(refertoDi(db, s))).toBe(prima); + }); + + it('nessuna pagina interroga il database da sola', () => { + for (const f of ['src/pages/longevity/io.astro', 'src/pages/longevity/questionario.astro']) { + const src = readFileSync(join(process.cwd(), f), 'utf8'); + expect(src, `${f} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i); + } + }); +});