Files
InsanityLab-web/apps/platforms/src/lib/longevity/vista.ts
T
Adriano 4968b75472 longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni
Un check-up e un questionario sono sessioni separate: prima di questo
commit calcolaSessione leggeva solo le misure della sessione corrente,
quindi l'una non vedeva mai i dati dell'altra. misureValideAlla (nuova,
motore/index.ts) legge invece, per ogni test_id, la misura valida piu'
recente del cliente fra tutte le sue sessioni fino a quella data.
sottoDominiDellaSessione unisce sotto-domini da curva (questionario) e
sotto-domini fisici (sottoDominiFisici) in un'unica mappa, usata sia da
calcolaSessione (livello 1) sia da vista.ts (sottoDominiPresenti, per
"cosa manca" nel referto — non piu' una query separata sulla tabella
misure). Aggiornati i commenti di seed-demo.ts (il registro ora contiene
anche le voci f_*, anche se questa demo non le compila).

Test nuovi in motore-fisici-sessione.test.ts: il caso demo() dell'oracolo
end-to-end, un ingresso mancante o l'eta assente che lascia il
sotto-dominio fuori, la combinazione fra sessioni (check-up dopo il
questionario, sessione precedente che non vede il futuro, misura fuori
range sostituita dalla precedente valida). 202 test esistenti restano
verdi, 9 nuovi: 211 totali. tsc --noEmit: 0 errori (0 anche su 13d8cdd).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:38:34 +00:00

251 lines
12 KiB
TypeScript

//
// 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, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore';
export type CampoQuestionario = {
id: string;
etichetta: string;
tipo: 'num' | 'txt';
unita?: string;
min?: number;
max?: number;
sottoDominio?: string;
};
/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
* `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
return testAttivi(db, alla)
.filter((v) => v.test_id.startsWith('q_'))
.map((v) => ({
id: v.test_id,
etichetta: v.etichetta,
tipo: v.tipo_valore,
unita: v.unita,
min: v.range_min,
max: v.range_max,
sottoDominio: v.sotto_dominio,
}));
}
export type EsitoSalvataggio =
| { ok: true; sessioneId: number }
| { ok: false; errore: string };
const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
function consensoDato(risposta: string): boolean {
return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
}
/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso
* (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che
* cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla
* sessione solo quando il consenso è affermativo e la sessione esiste davvero. */
function registraConsenso(
db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
): void {
const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
const oggi = new Date().toISOString().slice(0, 10);
const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`;
db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
).run(clientCode, sessioneId, 'consenso_sanitario', testo);
}
/**
* Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
* qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
* note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
* scrive comunque, con l'esito letterale ricevuto.
*
* Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il
* salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la
* nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa
* correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava
* calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla
* transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che
* li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint
* quelle interne di `salvaCompilazione` e `salvaScore`.
*/
export function salvaDalForm(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
consensoSanitario: string;
liberi?: Record<string, string>;
}
): EsitoSalvataggio {
if (!consensoDato(input.consensoSanitario)) {
registraConsenso(db, input.client_code, null, input.consensoSanitario);
return {
ok: false,
errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
};
}
const tx = db.transaction(() => {
const sessioneId = salvaCompilazione(db, {
client_code: input.client_code,
data: input.data,
eta: input.eta,
risposte: input.risposte,
liberi: input.liberi,
});
// Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo
// successivo affidato a chi legge: senza questo, una compilazione restava per sempre
// senza i suoi punteggi.
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
return sessioneId;
});
return { ok: true, sessioneId: tx() };
}
//
// 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 sono chiavi che si leggono da `registro_test.sotto_dominio`
* per tutte: quelle del questionario sì, ma quelle fisiche (handgrip, VO2max, plank...) le
* produce `sottoDominiFisici` (`motore/fisici.ts`) come chiave del suo risultato — le voci
* FISICI del registro non portano un `sotto_dominio` proprio (vedi il commento su `FISICI`
* in registro.ts) — quindi la traduzione non può venire da una query sul registro: è dato
* locale a questo modulo. Unica eccezione rimasta davvero scoperta: `spirometria`, fuori
* dal perimetro di `sottoDominiFisici` (nessun calcolatore GLI, vedi quel file).
*/
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 un punteggio calcolabile per questa sessione — le chiavi di
* `sottoDominiDellaSessione`, che a differenza di una query sulla sola tabella `misure`
* vede anche le misure valide portate avanti da sessioni precedenti dello stesso cliente
* (`misureValideAlla` in `motore/index.ts`) e i sotto-domini fisici (`sottoDominiFisici`),
* non solo quelli da curva del questionario. Usa lo stesso calcolo che alimenta gli assi,
* non lo duplica: `mancano` si costruisce da qui, mai da un ricalcolo separato che
* potrebbe divergere. */
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId)));
}
/** 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; fitnessAgeCopertura: number } {
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;
// Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve
// poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca:
// la copertura resta leggibile anche quando lo stato è 'insufficiente'.
const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0;
return { assi, macro, fitnessAge, fitnessAgeCopertura };
}
/** 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;
}