registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
# Review package: 8c98075..HEAD
|
||||
|
||||
## Commits
|
||||
6630be7 longevity: la prova sul congelamento ora discrimina davvero il ricalcolo
|
||||
7f01ee0 longevity: il referto del cliente, e il radar che dice cosa non sa
|
||||
|
||||
## Files changed
|
||||
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 | 103 +++++++++++++++++++++++
|
||||
6 files changed, 437 insertions(+), 1 deletion(-)
|
||||
|
||||
## Diff
|
||||
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;
|
||||
+---
|
||||
+<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>
|
||||
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 (
|
||||
+ <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>
|
||||
+ );
|
||||
+}
|
||||
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
|
||||
@@ -2,22 +2,23 @@
|
||||
// La vista che il questionario in pagina usa. Due responsabilità:
|
||||
// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
|
||||
// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
|
||||
// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
|
||||
// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
|
||||
// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
|
||||
// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
|
||||
// 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;
|
||||
etichetta: string;
|
||||
tipo: 'num' | 'txt';
|
||||
unita?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
sottoDominio?: string;
|
||||
};
|
||||
@@ -90,10 +91,137 @@ export function salvaDalForm(
|
||||
data: input.data,
|
||||
eta: input.eta,
|
||||
risposte: input.risposte,
|
||||
liberi: input.liberi,
|
||||
});
|
||||
|
||||
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
|
||||
|
||||
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;
|
||||
+}
|
||||
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;
|
||||
+---
|
||||
+<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>
|
||||
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
|
||||
@@ -72,20 +72,45 @@
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
|
||||
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
|
||||
|
||||
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
|
||||
|
||||
.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 {
|
||||
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--c-text-light); margin: 0;
|
||||
}
|
||||
.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
|
||||
.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
|
||||
|
||||
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
|
||||
diff --git a/tests/longevity/referto.test.ts b/tests/longevity/referto.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..c128885
|
||||
--- /dev/null
|
||||
+++ b/tests/longevity/referto.test.ts
|
||||
@@ -0,0 +1,103 @@
|
||||
+// 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 };
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Come conCompilazione, con IN PIU' una risposta di questionario_sonno (q_riposato).
|
||||
+ * Serve solo alla prova sul congelamento, che ha bisogno di un asse con PIU' sotto-domini
|
||||
+ * pesati e copertura parziale non nulla — vedi il commento sopra la mutazione qui sotto per
|
||||
+ * il perche' conCompilazione da sola non basta.
|
||||
+ */
|
||||
+function conCoperturaParziale() {
|
||||
+ 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,
|
||||
+ q_riposato: 8,
|
||||
+ },
|
||||
+ });
|
||||
+ 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 } = conCoperturaParziale();
|
||||
+ const prima = JSON.stringify(refertoDi(db, s));
|
||||
+ // ATTENZIONE alla scelta della mutazione: non una qualunque, una che DISCRIMINA
|
||||
+ // davvero un ricalcolo da una lettura congelata.
|
||||
+ //
|
||||
+ // 'Recupero & Sistema Nervoso' pesa QUATTRO sotto-domini (hrv 0.50, pressione 0.15,
|
||||
+ // hrr 0.15, questionario_sonno 0.20); questa sessione ne misura UNO solo
|
||||
+ // (questionario_sonno, via q_riposato in conCoperturaParziale) — copertura vera
|
||||
+ // 0.20/1.00=0.20, sotto COPERTURA_MINIMA (0.4): l'asse e' 'insufficiente', ma con
|
||||
+ // disponibili.length===1, non 0 — la formula del peso viene davvero eseguita, non
|
||||
+ // bypassata dalla scorciatoia "nessun dato disponibile" di aggrega() in cascata.ts.
|
||||
+ // Azzerando il peso di 'hrv' (un sotto-dominio ASSENTE) il totale scende a 0.50 e la
|
||||
+ // copertura vera diventa 0.20/0.50=0.40: attraversa la soglia, l'asse passerebbe da
|
||||
+ // insufficiente a ok SE il referto ricalcolasse (provato: vedi task-3-report.md).
|
||||
+ //
|
||||
+ // Una mutazione come questa NON discrimina, ed e' un errore da non ripetere:
|
||||
+ // il peso dell'UNICO sotto-dominio di un asse a un solo elemento (es.
|
||||
+ // 'questionario_lifestyle', 1.00 in 'Stile di Vita & Sonno') si semplifica sempre,
|
||||
+ // sia al numeratore sia al denominatore della media pesata — cambiarlo non altera
|
||||
+ // ne' il valore ne' la copertura, quindi il test resterebbe verde anche se
|
||||
+ // refertoDi ricalcolasse davvero (era il bug di questo stesso test, corretto qui).
|
||||
+ db.prepare(
|
||||
+ `UPDATE pesi SET peso = 0 WHERE model_version = ? AND livello='asse' AND contenitore='Recupero & Sistema Nervoso' AND elemento='hrv'`
|
||||
+ ).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);
|
||||
+ }
|
||||
+ });
|
||||
+});
|
||||
Reference in New Issue
Block a user