longevity: il referto del cliente, e il radar che dice cosa non sa

This commit is contained in:
2026-08-22 14:28:29 +02:00
parent 8c980750c0
commit 7f01ee0b45
6 changed files with 396 additions and 1 deletions
+37
View File
@@ -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;
---
<div class:list={['lg-card', 'lg-macro__card', { 'lg-card--incompleto': valore === null }]}>
<p class="lg-score__label">{etichetta}</p>
<div class="lg-score">
<span class="lg-score__value lg-num">{valore !== null ? valore : '—'}</span>
<span class="lg-score__max">/ 100</span>
</div>
<div class="lg-score__bar">
<span
class="lg-score__bar-fill"
style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`}
></span>
</div>
{valore === null && (
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(copertura * 100)}% — non ancora sufficiente per un numero.
</p>
)}
</div>
+86
View File
@@ -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 (
<div className="lg-radar">
<div className="lg-radar__grafico">
<ResponsiveContainer width="100%" height={360}>
<RadarChart data={dati} outerRadius="72%">
<PolarGrid stroke={LINEA} />
<PolarAngleAxis dataKey="nome" tick={{ fill: TESTO_CHIARO, fontSize: 11 }} />
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
{/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */}
<Radar
name="Perimetro completo"
dataKey="potenziale"
stroke={TRATTEGGIO}
strokeDasharray="4 3"
strokeWidth={1.5}
fill="none"
isAnimationActive={false}
/>
<Radar
name="Quello che sappiamo"
dataKey="noto"
stroke={ACCENTO}
fill={ACCENTO}
fillOpacity={0.32}
strokeWidth={2}
isAnimationActive={false}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{incompleti.length > 0 && (
<div className="lg-radar__mancano">
<p className="lg-radar__intro">
L'area piena è quello che sappiamo davvero. Il perimetro tratteggiato si completa così:
</p>
{incompleti.map((a) => (
<div className="lg-radar__voce" key={a.nome}>
<p className="lg-radar__voce-nome">{a.nome}</p>
<ul className="lg-radar__lista">
{a.mancano.map((m) => <li key={m}>{m}</li>)}
</ul>
</div>
))}
</div>
)}
</div>
);
}
+129 -1
View File
@@ -9,8 +9,9 @@
// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a // nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
// cosa) che un domani va dimostrato tanto quanto un assenso. // cosa) che un domani va dimostrato tanto quanto un assenso.
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { testAttivi } from './registro'; import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, QUEST_VERSION } from './questionario'; import { salvaCompilazione, QUEST_VERSION } from './questionario';
import { leggiScore } from './motore';
export type CampoQuestionario = { export type CampoQuestionario = {
id: string; id: string;
@@ -97,3 +98,130 @@ export function salvaDalForm(
return { ok: true, sessioneId }; 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<string, string> = {
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<string> {
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>): 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;
}
+57
View File
@@ -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;
---
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && !referto && (
<div class="lg-card lg-card--incompleto">
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class="lg-card">
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">{referto.fitnessAge ?? '—'}</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
</section>
<section class="lg-card">
<h2>Il tuo radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
</div>
)}
</Longevity>
+25
View File
@@ -79,6 +79,31 @@
.lg-muted { color: var(--c-text-light); } .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 --- */ /* --- il questionario: un blocco alla volta, avanti/indietro --- */
.lg-form { display: flex; flex-direction: column; gap: 24px; } .lg-form { display: flex; flex-direction: column; gap: 24px; }
.lg-form__passo { .lg-form__passo {
+62
View File
@@ -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);
}
});
});