// // 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 } 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; consensoSanitario: string; liberi?: Record; } ): 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 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; 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; }