diff --git a/.gitignore b/.gitignore index e27adf0..5a2272d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ .astro/ .env diff --git a/apps/platforms/node_modules b/apps/platforms/node_modules deleted file mode 120000 index 9b5e35b..0000000 --- a/apps/platforms/node_modules +++ /dev/null @@ -1 +0,0 @@ -/opt/docker/insanitylab-apps/apps/platforms/node_modules \ No newline at end of file diff --git a/apps/platforms/scripts/seed-demo.ts b/apps/platforms/scripts/seed-demo.ts index 1379da8..5224a22 100644 --- a/apps/platforms/scripts/seed-demo.ts +++ b/apps/platforms/scripts/seed-demo.ts @@ -26,13 +26,14 @@ * ⚠️ I dati sono INVENTATI e vanno detti tali a chi guarda: è una persona che non esiste. * Il referto che ne esce non è un esempio clinico, è un esempio di interfaccia. * - * ⚠️ Cosa mostrerà davvero il radar: il registro contiene oggi SOLO le 20 domande del - * questionario — i test fisici (handgrip, VO2max, plank, composizione corporea...) non - * hanno ancora una riga, arrivano col gestionale trainer. Quindi escono con un valore gli - * assi che il questionario copre almeno al 40%, e gli altri restano dichiarati - * insufficienti con l'elenco di cosa manca. Non è un difetto della demo: è lo stato del - * prodotto, ed è anche la regola di Nicola che si vede all'opera (annullare invece di - * stimare). + * ⚠️ Cosa mostrerà davvero il radar: questo script compila SOLO il questionario (le 20 + * domande `q_*`) tramite `salvaDalForm` — non registra nessuna misura fisica (`f_*`), che + * arriva dal check-up col gestionale trainer, non da qui. Il registro le contiene entrambe + * (vedi `registro.ts`), ma senza `registraMisure` per le `f_*` questa sessione non ne vede + * nessuna. Quindi escono con un valore gli assi che il questionario copre almeno al 40%, e + * gli altri restano dichiarati insufficienti con l'elenco di cosa manca. Non è un difetto + * della demo: è lo stato di UNA sessione di solo questionario, ed è anche la regola di + * Nicola che si vede all'opera (annullare invece di stimare). * * DIPENDE DA: tsx (devDependency) — il codice del prodotto è TypeScript e questo script * lo usa davvero invece di riscriverne una copia in SQL. diff --git a/apps/platforms/src/components/longevity/Progressione.tsx b/apps/platforms/src/components/longevity/Progressione.tsx new file mode 100644 index 0000000..3c9922f --- /dev/null +++ b/apps/platforms/src/components/longevity/Progressione.tsx @@ -0,0 +1,139 @@ +// L'andamento nel tempo: una linea per macro-score, isola React come Radar (recharts ha +// bisogno del DOM). Stesso linguaggio visivo del radar, non uno nuovo: +// - `connectNulls={false}` su ogni linea: un valore mancante (sessione insufficiente per +// quel macro-score) è un BUCO nella linea, non un segmento che la attraversa come se il +// dato ci fosse. Un buco onesto vale più di una linea continua che mente. +// - le tre serie (PERFORMANCE/ENERGY/RECOVERY) si distinguono per tratteggio e spessore, +// MAI per colore: qui non c'è semaforo, come nel radar (vedi longevity.css). Un colore +// diverso per macro-score si leggerebbe come un giudizio ("questo è il buono, quello è il +// cattivo"), che il servizio non fa. +// - la riga verticale tratteggiata su un cambio di versione usa lo stesso colore del +// perimetro "non lo sappiamo ancora" del radar (TRATTEGGIO): un confine dichiarato, non +// un errore. +import { + ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ReferenceLine, +} from 'recharts'; +import type { PuntoProgressione } from '../../lib/longevity/progressione'; +import { formattaNumero } from '../../lib/longevity/formato'; + +interface Props { + punti: PuntoProgressione[]; +} + +// Stessi colori di Radar.tsx (esadecimali, non var(--...): recharts vuole attributi SVG, +// non proprietà CSS). Non sono colori nuovi, sono una copia — vedi il commento in Radar.tsx. +const ACCENTO = '#9c8b70'; +const TRATTEGGIO = '#d8d2c8'; +const LINEA = '#e4dfd7'; +const TESTO_CHIARO = '#8a8a8a'; + +/** 'PERFORMANCE' -> 'Performance': stessa trasformazione di MacroScore.astro, non un + * dizionario duplicato da tenere allineato — il NOME resta quello di prodotto (formato.ts), + * solo la resa grafica cambia maiuscola/minuscola. */ +const etichetta = (nome: string) => nome.charAt(0) + nome.slice(1).toLowerCase(); + +// Tratteggio e spessore che distinguono le tre serie senza un colore nuovo per ciascuna: +// PERFORMANCE piena e più spessa (il macro-score principale), le altre due via via più +// leggere. L'ordine qui non impone un ordine di lettura: è solo il fallback per un +// macro-score che un domani non fosse fra questi tre (mai successo finora, ma +// `progressioneDi` non hardcoda i nomi — vedi il commento lì — quindi nemmeno qui). +const STILI_SERIE: { dash?: string; width: number }[] = [ + { dash: undefined, width: 2.5 }, + { dash: '6 3', width: 2 }, + { dash: '2 2', width: 1.5 }, +]; + +export default function Progressione({ punti }: Props) { + const nomiMacro = Array.from(new Set(punti.flatMap((p) => Object.keys(p.macro)))); + const nomiAssi = Array.from(new Set(punti.flatMap((p) => Object.keys(p.assi)))); + + const dati = punti.map((p) => { + const riga: Record = { data: p.data }; + for (const nome of nomiMacro) riga[nome] = p.macro[nome]; + return riga; + }); + + const cambi = punti.filter((p) => p.cambioVersione !== null); + + return ( +
+

+ Una linea per macro-score (Performance, Energy, Recovery): dove manca un tratto, quella + sessione non aveva dati sufficienti per quel numero — non è uno zero, è un buco. La + riga verticale tratteggiata segna dove è cambiato il questionario o il modo di + calcolare: i numeri prima e dopo non si confrontano allo stesso modo. +

+ +
+ + + + + + + + {cambi.map((p) => ( + + ))} + {nomiMacro.map((nome, i) => { + const stile = STILI_SERIE[i % STILI_SERIE.length]; + return ( + + ); + })} + + +
+ + {/* La tabella per asse: la stessa granularità del radar (sette assi), qui nel tempo + invece che in un solo istante. «—» dove il dato non basta, mai uno zero. */} +
+ + + + + {punti.map((p) => ( + + ))} + + + + {nomiAssi.map((nome) => ( + + + {punti.map((p) => { + const v = p.assi[nome]; + return ( + + ); + })} + + ))} + +
{p.data}
{nome} + {v !== null && v !== undefined ? formattaNumero(v) : '—'} +
+
+
+ ); +} diff --git a/apps/platforms/src/layouts/Longevity.astro b/apps/platforms/src/layouts/Longevity.astro index 8ec8396..27b048b 100644 --- a/apps/platforms/src/layouts/Longevity.astro +++ b/apps/platforms/src/layouts/Longevity.astro @@ -5,8 +5,8 @@ // dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro // volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno // strumento di lavoro. Il layout prevede due forme: -// - "referto": una colonna che scorre, senza navigazione (questo task) -// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale) +// - "referto": una colonna che scorre, senza navigazione (il cliente) +// - "gestionale": barra laterale sul modello di StressIndex.astro (il trainer, Unità 2b #2) import '@fontsource/montserrat/400.css'; import '@fontsource/montserrat/500.css'; import '@fontsource/montserrat/600.css'; @@ -19,10 +19,18 @@ interface Props { title: string; /** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */ crumbs?: string[]; - /** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, non ancora implementata). */ + /** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, per il trainer). */ variante?: 'referto' | 'gestionale'; } const { title, crumbs = [], variante = 'referto' } = Astro.props; +const path = Astro.url.pathname; + +// Le voci della barra laterale del gestionale: nasce con due pagine, non le sette di +// Stress Index — un elenco più lungo arriverà con gli sviluppi futuri (#3, #7, #8). +const NAV_GESTIONALE = [ + { href: '/longevity/gestionale', label: 'Clienti' }, + { href: '/longevity/gestionale/nuovo', label: 'Nuovo cliente' }, +]; --- @@ -34,14 +42,45 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props; -
-
-

- Longevity - {crumbs.map((c) => / {c})} -

-
-
-
+ {variante === 'gestionale' ? ( + <> + + + +
+
+

+ Longevity + {crumbs.map((c) => / {c})} +

+
+
+
+ + ) : ( +
+
+

+ Longevity + {crumbs.map((c) => / {c})} +

+
+
+
+ )} diff --git a/apps/platforms/src/lib/longevity/accesso.ts b/apps/platforms/src/lib/longevity/accesso.ts new file mode 100644 index 0000000..51801de --- /dev/null +++ b/apps/platforms/src/lib/longevity/accesso.ts @@ -0,0 +1,88 @@ +// +// Crea l'account di accesso al sito per un cliente che ha GIA' un fascicolo aperto +// (il percorso inverso di crea-fascicolo.ts, che parte da un utente esistente e apre il +// fascicolo). Qui si parte dal fascicolo — creato dal trainer al check-up, spesso prima +// che il cliente abbia un account — e si crea l'utente che gli permette di entrare da +// /longevity a leggere il proprio referto e compilare il questionario. +// +// ⚠️ Non apre identity da sé: passa dal solo modulo autorizzato a farlo (anagrafica.ts), +// che è anche l'unico a sapere cosa sia "un accesso" per un fascicolo. Questo file tocca +// due database (users in piattaforme.db, il collegamento in identity.db) ma non li apre +// mai insieme: riceve `utenti` da fuori e delega identity alle funzioni di anagrafica.ts. +import type Database from 'better-sqlite3'; +import { getDb } from '../db'; +import { createUser, deleteUser, randomPassword } from '../auth'; +import { accessoDelCliente, collegaAccessoCliente } from './anagrafica'; + +/** Solo lettere minuscole, cifre e ".", "_", "@", "-": stesso alfabeto permissivo di create-user.mjs. */ +const USERNAME_VALIDO = /^[a-z0-9._@-]{3,64}$/; + +/** + * Le dipendenze di creaAccessoCliente, iniettabili per i test (DB in memoria, doppi finti + * per accessoDi/collega) invece del singleton reale. In produzione si passa `undefined` e + * si prende il default qui sotto — lo stesso schema del singleton di getDb(): una + * connessione per processo, non riaperta a ogni chiamata. + */ +export interface DipendenzeAccesso { + utenti: Database.Database; + accessoDi: (code: string) => { userId: number | null; email: string | null } | null; + collega: (code: string, userId: number) => boolean; +} + +function dipendenzeDiDefault(): DipendenzeAccesso { + return { utenti: getDb(), accessoDi: accessoDelCliente, collega: collegaAccessoCliente }; +} + +export type EsitoAccesso = + | { ok: true; username: string; password: string } + | { ok: false; errore: string }; + +/** + * Crea l'utente `cliente` e lo collega al fascicolo `code`. Ordine dei controlli: prima + * tutto ciò che si può verificare SENZA scrivere (username valido, fascicolo esistente e + * ancora senza accesso, username libero), poi si crea l'utente, poi si collega. + * + * Se il collegamento fallisce (fascicolo sparito o già collegato nel frattempo, o + * l'update lancia per un motivo diverso) l'utente appena creato si cancella: due database + * distinti, quindi nessuna transazione unica possibile — stessa compensazione di + * creaCliente in anagrafica.ts. Un utente `cliente` senza fascicolo collegato non + * servirebbe a niente e resterebbe un accesso fantasma. + * + * La password è generata qui (randomPassword, mai scelta da chi chiama) e torna in + * chiaro UNA volta sola nell'esito: non viene salvata da nessuna parte se non nel suo + * hash dentro users.password_hash. Chi chiama non la deve loggare. + */ +export function creaAccessoCliente( + deps: DipendenzeAccesso = dipendenzeDiDefault(), + { code, username }: { code: string; username: string } +): EsitoAccesso { + const nome = username.trim().toLowerCase(); + if (!USERNAME_VALIDO.test(nome)) { + return { ok: false, errore: 'Username non valido: 3-64 caratteri fra lettere minuscole, cifre, ". _ @ -".' }; + } + + const fascicolo = deps.accessoDi(code); + if (!fascicolo) return { ok: false, errore: `Nessun fascicolo con codice ${code}.` }; + if (fascicolo.userId !== null) { + return { ok: false, errore: 'Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.' }; + } + + const usernameOccupato = deps.utenti.prepare('SELECT 1 FROM users WHERE username = ?').get(nome); + if (usernameOccupato) return { ok: false, errore: `L'username "${nome}" è già in uso.` }; + + const password = randomPassword(); + const userId = createUser(deps.utenti, nome, password, 'cliente'); + + let collegato: boolean; + try { + collegato = deps.collega(code, userId); + } catch { + collegato = false; + } + if (!collegato) { + deleteUser(deps.utenti, userId); + return { ok: false, errore: 'Il fascicolo non è più collegabile (cancellato o già collegato nel frattempo).' }; + } + + return { ok: true, username: nome, password }; +} diff --git a/apps/platforms/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts index cfea267..3b3736c 100644 --- a/apps/platforms/src/lib/longevity/anagrafica.ts +++ b/apps/platforms/src/lib/longevity/anagrafica.ts @@ -4,7 +4,7 @@ // risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione // la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. import type Database from 'better-sqlite3'; -import { createIdentityDb } from './db'; +import { createIdentityDb, getLongevityDb } from './db'; // Connessione singola a identity, privata di questo modulo: non è esportata, quindi non // è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file @@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null { return codicePerUtente(getIdentityDb(), userId); } +/** + * Lo stato di accesso di un fascicolo: chi (se qualcuno) lo può aprire da sito, e l'email + * già in anagrafica (serve solo a precompilare l'username in fase di creazione, non è + * usata per altro). `null` vuol dire codice inesistente — diverso da "esiste ma senza + * accesso", che è `{ userId: null, email }`. + */ +export function accessoDi( + identity: Database.Database, + code: string +): { userId: number | null; email: string | null } | null { + const r = identity.prepare(`SELECT user_id, email FROM clienti WHERE client_code = ?`).get(code) as + { user_id: number | null; email: string | null } | undefined; + if (!r) return null; + return { userId: r.user_id, email: r.email }; +} + +/** Come accessoDi, ma sulla connessione singleton: per il gestionale, che identity non la apre mai da sé. */ +export function accessoDelCliente(code: string): { userId: number | null; email: string | null } | null { + return accessoDi(getIdentityDb(), code); +} + +/** + * Lega un utente già creato al fascicolo: vero solo se il fascicolo esiste ed era ancora + * senza accesso (`user_id IS NULL` nel WHERE, non un controllo prima separato) — così due + * chiamate concorrenti sullo stesso fascicolo non possono collegare due utenti diversi, + * la seconda trova zero righe e torna false. Chi chiama con false deve considerare + * l'utente appena creato orfano e cancellarlo (vedi accesso.ts). + */ +export function collegaAccesso(identity: Database.Database, code: string, userId: number): boolean { + const res = identity.prepare( + `UPDATE clienti SET user_id = ? WHERE client_code = ? AND user_id IS NULL` + ).run(userId, code); + return res.changes === 1; +} + +/** Come collegaAccesso, ma sulla connessione singleton. */ +export function collegaAccessoCliente(code: string, userId: number): boolean { + return collegaAccesso(getIdentityDb(), code, userId); +} + /** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */ export function etaAllaData(dataNascita: string, alla: string): number { const n = new Date(dataNascita); @@ -94,3 +134,88 @@ export function etaAllaData(dataNascita: string, alla: string): number { if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; return eta; } + +// +// Gestionale del trainer (#2, unità 2a): tre letture sull'anagrafica e l'apertura di un +// fascicolo, tutte sotto lo stesso vincolo di codicePerUtente/codicePerUtenteLoggato qui +// sopra — una versione PARAMETRIZZATA (identity esplicita, esportata solo per i test e per +// comporre da qui dentro) e una versione OPERATIVA (nessun argomento, apre da sola +// getIdentityDb) che è quella che pagine ed endpoint del gestionale importano davvero. La +// versione operativa non si può testare su un DB :memory: — il singleton è uno solo per +// processo — quindi il test vero gira sulla parametrizzata, esattamente come +// anagrafica.test.ts fa già con codicePerUtente invece di codicePerUtenteLoggato. +// + +export type ClienteElenco = { + client_code: string; + nome: string; + cognome: string; + data_nascita: string | null; + /** True se il cliente ha già un account per vedere il proprio referto (user_id valorizzato + * in identity.clienti). Creare quell'account è #8, fuori da questa unità: qui si legge + * soltanto se esiste già. */ + ha_accesso: boolean; +}; + +/** L'elenco dei clienti per la tabella del gestionale, ordinato per cognome poi nome — + * l'ordine in cui un trainer cerca una persona, non l'ordine di creazione del fascicolo. */ +export function elencoClientiParam(identity: Database.Database): ClienteElenco[] { + const righe = identity.prepare( + `SELECT client_code, nome, cognome, data_nascita, user_id FROM clienti ORDER BY cognome, nome` + ).all() as { client_code: string; nome: string; cognome: string; data_nascita: string | null; user_id: number | null }[]; + return righe.map(({ user_id, ...resto }) => ({ ...resto, ha_accesso: user_id !== null })); +} + +export function elencoClienti(): ClienteElenco[] { + return elencoClientiParam(getIdentityDb()); +} + +export type ClienteDettaglio = ClienteElenco & { + email: string | null; + telefono: string | null; +}; + +/** L'anagrafica di un cliente per la sua scheda nel gestionale, `null` se il codice non + * esiste — la pagina lo traduce in un 404, non in uno schermo vuoto. */ +export function clienteDaCodiceParam(identity: Database.Database, code: string): ClienteDettaglio | null { + const r = identity.prepare( + `SELECT client_code, nome, cognome, data_nascita, email, telefono, user_id + FROM clienti WHERE client_code = ?` + ).get(code) as + { client_code: string; nome: string; cognome: string; data_nascita: string | null; + email: string | null; telefono: string | null; user_id: number | null } | undefined; + if (!r) return null; + const { user_id, ...resto } = r; + return { ...resto, ha_accesso: user_id !== null }; +} + +export function clienteDaCodice(code: string): ClienteDettaglio | null { + return clienteDaCodiceParam(getIdentityDb(), code); +} + +/** L'età del cliente alla data indicata, `null` se non ha una data di nascita in anagrafica + * (compilazione facoltativa: il fascicolo può nascere senza). Non è un ripiego a zero — + * calcolaSessione tratta già `eta_alla_data: null` come "niente Fitness Age", stessa + * barriera del motore (vedi l'intestazione di motore/index.ts). */ +export function etaDelClienteParam(identity: Database.Database, code: string, alla: string): number | null { + const r = identity.prepare(`SELECT data_nascita FROM clienti WHERE client_code = ?`).get(code) as + { data_nascita: string | null } | undefined; + if (!r?.data_nascita) return null; + return etaAllaData(r.data_nascita, alla); +} + +export function etaDelCliente(code: string, alla: string): number | null { + return etaDelClienteParam(getIdentityDb(), code, alla); +} + +/** Apre un fascicolo nuovo dal gestionale: SEMPRE senza user_id — creare l'account che + * darebbe accesso al cliente è #8, non questa unità, e il tipo di `dati` qui sotto non + * prevede quel campo (stessa garanzia strutturale di CorpoQuestionario nell'endpoint del + * questionario: chi volesse passare uno user_id da qui non supererebbe `tsc --noEmit`). + * Delega tutto a `creaCliente`, che è già la versione testabile con DB propri. */ +export function apriFascicolo(dati: { + nome: string; cognome: string; sesso: 'M' | 'F'; + data_nascita?: string; email?: string; telefono?: string; +}): string { + return creaCliente(getIdentityDb(), getLongevityDb(), dati); +} diff --git a/apps/platforms/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts index 703f592..8621879 100644 --- a/apps/platforms/src/lib/longevity/db.ts +++ b/apps/platforms/src/lib/longevity/db.ts @@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure ( fonte TEXT NOT NULL CHECK (fonte IN ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), fuori_range INTEGER NOT NULL DEFAULT 0, + -- Verifica di una misura fuori range (issue #7): il trainer la conferma (entra nello + -- score come le altre, vedi misureValideAlla in motore/index.ts) o la scarta (resta + -- esclusa, per sempre). NULL = non ancora verificata. Su un longevity.db creato PRIMA + -- di questa modifica queste tre colonne NON ci sono: le aggiunge migra(), qui sotto. + verifica TEXT CHECK (verifica IN ('confermata','scartata')), + verificata_da TEXT, + verificata_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); @@ -128,6 +135,36 @@ function apri(p: string, schema: string): Database.Database { return db; } +/** + * Migrazione idempotente per un `longevity.db` aperto PRIMA di questa modifica (issue #7): + * `CREATE TABLE IF NOT EXISTS` (sopra) non aggiunge colonne a una tabella che esiste già, e + * in produzione un longevity.db esiste dal 03/09/2026 (VPS, + * /opt/docker/insanitylab-apps/apps/platforms/data/longevity.db) — la stessa trappola già + * documentata sul vincolo di `score`, qui sotto. Senza questa funzione le tre colonne di + * verifica resterebbero assenti in produzione mentre ogni database creato da zero (ogni + * test, e un domani un secondo deploy) le avrebbe già: un disallineamento silenzioso fra + * ambienti, non un errore che si vede subito. + * `PRAGMA table_info` legge le colonne DAVVERO presenti sul file aperto, non lo schema + * dichiarato sopra: per ognuna delle tre assente, un `ALTER TABLE ... ADD COLUMN` la + * aggiunge (nullable, quindi le righe esistenti restano valide contro il CHECK — NULL non + * fa mai fallire un CHECK). Su un database che le ha già il controllo salta tutto: girarla + * a ogni apertura (come seedRegistro/seedPesi) è sicuro. + */ +function migra(db: Database.Database): void { + const colonne = new Set( + (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name) + ); + if (!colonne.has('verifica')) { + db.exec(`ALTER TABLE misure ADD COLUMN verifica TEXT CHECK (verifica IN ('confermata','scartata'))`); + } + if (!colonne.has('verificata_da')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_da TEXT`); + } + if (!colonne.has('verificata_at')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_at TEXT`); + } +} + /** * ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database. * @@ -147,6 +184,7 @@ function apri(p: string, schema: string): Database.Database { */ export function createLongevityDb(path?: string): Database.Database { const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); + migra(db); seedRegistro(db); seedPesi(db, MODEL_VERSION); return db; diff --git a/apps/platforms/src/lib/longevity/gestionale.ts b/apps/platforms/src/lib/longevity/gestionale.ts new file mode 100644 index 0000000..84abcb5 --- /dev/null +++ b/apps/platforms/src/lib/longevity/gestionale.ts @@ -0,0 +1,134 @@ +// +// Il gestionale del trainer (#2, unità 2a): il modulo del check-up manuale e le letture +// sulle sessioni di un cliente. Solo il database clinico (vedi db.ts) — mai quello +// anagrafico, e mai le due parole riservate a quel confine insieme in un file che non sia +// anagrafica.ts o db.ts (tests/longevity/anagrafica.test.ts lo verifica): un cliente qui è +// solo un client_code, mai un nome. Chi ha bisogno del nome lo chiede ad anagrafica.ts, un +// altro modulo, un'altra chiamata. +import type Database from 'better-sqlite3'; +import { testAttivi, esisteTest, MODEL_VERSION } from './registro'; +import { apriSessione, registraMisure, type MisuraIn } from './misure'; +import { calcolaSessione, salvaScore, leggiScore } from './motore'; +import type { CampoQuestionario } from './vista'; + +export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] }; + +/** + * I campi del modulo di check-up alla data indicata: solo i test_id fisici (prefisso `f_`, + * per lo stesso motivo per cui `campiDelQuestionario` in vista.ts prende solo `q_*`), + * raggruppati per asse — la sezione del modulo cartaceo che il trainer compila — + * nell'ordine in cui l'asse compare nei pesi (`pesi`, livello 'asse'): stesso criterio di + * `contenitoriDi` in vista.ts, letto dal dato invece di un elenco duplicato qui. Un asse + * fisico che non fosse fra i contenitori pesati (non dovrebbe succedere: FISICI in + * registro.ts usa solo assi che PESI_ASSE dichiara) chiude in coda, nell'ordine in cui + * compare per la prima volta nel registro. + */ +export function campiDelCheckup(db: Database.Database, alla: string): GruppoCheckup[] { + const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_')); + + const ordineAssi = (db.prepare( + `SELECT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse' + GROUP BY contenitore ORDER BY MIN(rowid)` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const perAsse = new Map(); + for (const v of fisici) { + const asse = v.asse ?? '(senza asse)'; + const campo: CampoQuestionario = { + 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, + }; + if (!perAsse.has(asse)) perAsse.set(asse, []); + perAsse.get(asse)!.push(campo); + } + + const ordine = ordineAssi.filter((a) => perAsse.has(a)); + for (const a of perAsse.keys()) if (!ordine.includes(a)) ordine.push(a); + + return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! })); +} + +export type EsitoCheckup = + | { ok: true; sessioneId: number; fuoriRange: string[] } + | { ok: false; errore: string }; + +/** + * Salva un check-up manuale del trainer: stesso schema di `salvaDalForm` (vista.ts) — + * validare TUTTO prima di scrivere qualunque cosa, poi un'unica transazione (apertura + * sessione, misure, calcolo e congelamento dei punteggi) perché il congelamento è parte del + * salvataggio, non un passo lasciato a chi legge dopo (lo stesso difetto già corretto una + * volta in questo modulo, vedi il commento su `salvaDalForm`). + * + * Rifiuta PRIMA di aprire la sessione, quindi senza lasciare righe orfane in `sessioni`: + * nessuna misura, un test_id fuori dal registro, o un test_id che non è un test fisico + * (`f_*` — un `q_*` qui è un errore del chiamante, il check-up non è il questionario), o un + * client_code che non ha un soggetto in longevity. + */ +export function salvaCheckup( + db: Database.Database, + input: { + client_code: string; data: string; eta: number | null; + operatore: string; misure: MisuraIn[]; + } +): EsitoCheckup { + if (input.misure.length === 0) { + return { ok: false, errore: 'Nessuna misura da salvare.' }; + } + for (const m of input.misure) { + if (!m.test_id.startsWith('f_')) { + return { ok: false, errore: `Il check-up accetta solo test fisici: ${m.test_id}` }; + } + if (!esisteTest(db, m.test_id)) { + return { ok: false, errore: `Test non riconosciuto: ${m.test_id}` }; + } + } + const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.client_code); + if (!soggetto) { + return { ok: false, errore: `Cliente inesistente: ${input.client_code}` }; + } + + const tx = db.transaction(() => { + const sessioneId = apriSessione(db, { + client_code: input.client_code, data: input.data, tipo: 'checkup', + eta_alla_data: input.eta ?? undefined, operatore: input.operatore, + }); + const { fuoriRange } = registraMisure(db, sessioneId, 'manuale', input.misure); + // Il calcolo e il congelamento sono parte del salvataggio: vedi salvaDalForm (vista.ts). + salvaScore(db, sessioneId, calcolaSessione(db, sessioneId)); + return { sessioneId, fuoriRange }; + }); + + const { sessioneId, fuoriRange } = tx(); + return { ok: true, sessioneId, fuoriRange }; +} + +export type SessioneElenco = { + id: number; + data: string; + tipo: 'checkup' | 'questionario'; + operatore: string | null; + numeroMisure: number; + /** La Fitness Age congelata di quella sessione, `null` se insufficiente o non calcolata — + * mai un ricalcolo: legge `leggiScore`, come `refertoDi` in vista.ts. */ + fitnessAge: number | null; +}; + +/** Le sessioni di un cliente, più recente prima (data DESC, a parità id DESC — stesso + * criterio di `misureValideAlla` e `leggiScore` nel motore). */ +export function sessioniDi(db: Database.Database, code: string): SessioneElenco[] { + const righe = db.prepare( + `SELECT s.id AS id, s.data AS data, s.tipo AS tipo, s.operatore AS operatore, + (SELECT COUNT(*) FROM misure m WHERE m.sessione_id = s.id) AS numero_misure + FROM sessioni s WHERE s.client_code = ? ORDER BY s.data DESC, s.id DESC` + ).all(code) as + { id: number; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null; numero_misure: number }[]; + + return righe.map((r) => { + const punteggi = leggiScore(db, r.id); + const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; + return { + id: r.id, data: r.data, tipo: r.tipo, operatore: r.operatore, + numeroMisure: r.numero_misure, fitnessAge, + }; + }); +} diff --git a/apps/platforms/src/lib/longevity/misure.ts b/apps/platforms/src/lib/longevity/misure.ts index dbad9d6..0c378e3 100644 --- a/apps/platforms/src/lib/longevity/misure.ts +++ b/apps/platforms/src/lib/longevity/misure.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { esisteTest } from './registro'; +import { calcolaSessione, salvaScore } from './motore'; export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; @@ -72,3 +73,105 @@ export function registraMisure( return { scritte: misure.length, fuoriRange }; } + +/** Una misura fuori range in attesa che il trainer decida se e' un dato vero o uno + * strumento/protocollo sbagliato (§9 della spec). L'etichetta e il range vengono dal + * registro (dato di prodotto), non duplicati qui; `data` e' quella della SESSIONE in cui + * la misura e' stata presa, utile a ordinare la lista senza un altro giro sul database. */ +export type MisuraDaVerificare = { + id: number; + test_id: string; + etichetta: string; + valore_num: number | null; + unita: string | null; + range_min: number | null; + range_max: number | null; + data: string; +}; + +/** + * Le misure di un cliente ancora da verificare: fuori range (`fuori_range = 1`) e mai + * verificate (`verifica IS NULL`) — una gia' confermata o scartata non torna qui, la + * decisione del trainer e' definitiva (vedi `verificaMisura`). Ordine per data della + * sessione decrescente: la piu' recente prima, come le altre liste del gestionale. + */ +export function misureDaVerificare(db: Database.Database, clientCode: string): MisuraDaVerificare[] { + return db.prepare( + `SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, + m.valore_num AS valore_num, m.unita AS unita, + r.range_min AS range_min, r.range_max AS range_max, + s.data AS data + FROM misure m + JOIN registro_test r ON r.test_id = m.test_id + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.client_code = ? AND m.fuori_range = 1 AND m.verifica IS NULL + ORDER BY s.data DESC, s.id DESC, m.id DESC` + ).all(clientCode) as MisuraDaVerificare[]; +} + +export type EsitoVerifica = + | { ok: true; sessioniRicalcolate: number[] } + | { ok: false; errore: string }; + +/** + * La decisione del trainer su una misura fuori range: confermarla (e' un dato vero, deve + * contare) o scartarla (e' uno strumento/protocollo sbagliato, resta fuori per sempre). + * Rifiuta una misura inesistente, una gia' dentro il range (non c'era niente da + * verificare) o gia' verificata (la decisione non si cambia scrivendoci sopra — chi + * sbaglia riapre una nuova misura, non riscrive la storia di questa). + * + * Solo la CONFERMA ricalcola: da quel momento `misureValideAlla` la lascia passare, quindi + * ogni punteggio congelato che l'ha ignorata e' cambiato sotto di lui. Si ricongela la + * sessione della misura stessa e ogni sessione SUCCESSIVA dello stesso cliente (stesso + * criterio data/id di `misureValideAlla`): anche quelle possono aver ereditato il valore + * — o la sua assenza — tramite la propagazione in avanti. Una sessione precedente non puo' + * mai vedere una misura futura, quindi non ha bisogno di ricalcolo. Lo SCARTO non tocca + * `score`: la misura restava esclusa anche prima, niente e' cambiato per il motore. + */ +export function verificaMisura( + db: Database.Database, + input: { misuraId: number; esito: 'confermata' | 'scartata'; operatore: string } +): EsitoVerifica { + const misura = db.prepare( + `SELECT m.id AS id, m.client_code AS client_code, m.sessione_id AS sessione_id, + m.fuori_range AS fuori_range, m.verifica AS verifica, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.id = ?` + ).get(input.misuraId) as + { id: number; client_code: string; sessione_id: number; fuori_range: number; verifica: string | null; data: string } + | undefined; + + if (!misura) return { ok: false, errore: `misura ${input.misuraId} inesistente` }; + if (misura.fuori_range === 0) return { ok: false, errore: 'la misura e nel range: non c e niente da verificare' }; + if (misura.verifica !== null) return { ok: false, errore: 'misura gia verificata' }; + + const sessioniRicalcolate: number[] = []; + + const tx = db.transaction(() => { + db.prepare( + `UPDATE misure SET verifica = ?, verificata_da = ?, verificata_at = datetime('now') WHERE id = ?` + ).run(input.esito, input.operatore, misura.id); + + if (input.esito === 'confermata') { + // La sessione della misura stessa (id = sessione_id) piu' ogni sessione successiva + // dello stesso cliente (data > D, o stessa data e id maggiore) — stesso confronto + // di misureValideAlla, cosi' la platea dei ricalcoli e' esattamente quella dei + // punteggi che possono essere cambiati da questa conferma. + const sessioni = db.prepare( + `SELECT id FROM sessioni + WHERE client_code = ? + AND (id = ? OR data > ? OR (data = ? AND id > ?)) + ORDER BY data ASC, id ASC` + ).all(misura.client_code, misura.sessione_id, misura.data, misura.data, misura.sessione_id) as { id: number }[]; + + for (const s of sessioni) { + salvaScore(db, s.id, calcolaSessione(db, s.id)); + sessioniRicalcolate.push(s.id); + } + } + }); + tx(); + + return { ok: true, sessioniRicalcolate }; +} diff --git a/apps/platforms/src/lib/longevity/motore/fisici.ts b/apps/platforms/src/lib/longevity/motore/fisici.ts new file mode 100644 index 0000000..f610174 --- /dev/null +++ b/apps/platforms/src/lib/longevity/motore/fisici.ts @@ -0,0 +1,139 @@ +/** + * Dalle misure grezze del check-up (registrate coi test_id `f_*` di `registro.ts`) ai + * punteggi 0-100 di sotto-dominio, con lo stesso raggruppamento che l'oracolo fa in + * `demo()` (`tests/longevity/riferimento/isl_scoring_engine.py`): ogni sotto-dominio è + * una chiamata a una funzione di `test-fisici.ts`, con gli argomenti giusti presi dalla + * mappa delle misure. + * + * A differenza del questionario, queste voci NON passano da `applicaCurva`: le sette curve + * generiche (`bell`, `lin_dec`, ecc.) prendono un solo valore, e quasi tutte le funzioni qui + * sotto ne vogliono almeno due (il valore grezzo più età e/o sesso, a volte anche il peso + * corporeo di un'altra misura). Estendere `applicaCurva` per farci passare età e sesso è + * lavoro del piano degli import (vedi l'intestazione di `test-fisici.ts`), non di questa + * revisione: qui il calcolo resta una funzione dedicata, non una curva dichiarata nel dato. + * + * REGOLA UNICA, la stessa di tutto il motore: un sotto-dominio a cui manca anche un solo + * ingresso (compresa l'età, dove la funzione la richiede) NON compare nel risultato — mai + * un valore di ripiego. È lo stesso principio di `applicaCurva`/`paramsValidi`, qui + * applicato a funzioni con più argomenti invece che a una curva coi parametri incompleti. + * + * FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore + * GLI che non esiste ancora), milfit, il 2km walk, il sit-to-stand, la trazione isometrica. + * L'oracolo li usa nella demo con segnaposto o non li usa affatto nei pesi correnti; + * restano fuori finché non arriva un test_id e una decisione su come raccoglierli. + */ + +import type { Sesso } from './test-fisici'; +import { clamp } from './curve'; +import { + scoreHandgrip, scorePushup, scoreSollevamentoSuPeso, TIERS_ROW_M, TIERS_ROW_F, + TIERS_SQUAT_M, TIERS_SQUAT_F, scorePlank, scoreGrassoPercento, scoreMuscoloPercento, + scoreWhr, scoreVo2max, vo2maxDaMutt, vo2maxDaStepTest, scoreSpo2, scorePressione, + scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla, +} from './test-fisici'; + +export type { Sesso }; + +/** + * `m` è la mappa test_id -> valore grezzo più recente e valido (vedi `misureValideAlla` in + * `motore/index.ts`), `eta` l'età alla data della sessione (nullable come in tutto il + * motore: senza età gli score che la richiedono restano assenti, mai calcolati a zero). + * + * Ordine dei blocchi: uguale a `demo()` nell'oracolo (forza, composizione, cardio, + * recupero, stabilità), così un confronto riga per riga fra questo file e quello resta + * diretto. + */ +export function sottoDominiFisici( + m: Map, eta: number | null, sesso: Sesso +): Record { + const out: Record = {}; + + // --- Forza & Struttura ---------------------------------------------------- + const handgrip = m.get('f_handgrip_kg'); + if (handgrip !== undefined && eta !== null) out.handgrip = scoreHandgrip(handgrip, eta, sesso); + + const pushup = m.get('f_pushup_rip'); + if (pushup !== undefined && eta !== null) out.spinta = scorePushup(pushup, eta, sesso); + + const pesoKg = m.get('f_peso_kg'); + const rowCarico = m.get('f_row_carico_kg'); + const rowRip = m.get('f_row_rip'); + if (rowCarico !== undefined && pesoKg !== undefined && rowRip !== undefined) { + out.trazione = scoreSollevamentoSuPeso(rowCarico, pesoKg, rowRip, TIERS_ROW_M, TIERS_ROW_F, sesso); + } + + const squatCarico = m.get('f_squat_carico_kg'); + const squatRip = m.get('f_squat_rip'); + if (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined) { + out.arti_inferiori = scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso); + } + + // Il plank pesa su DUE assi (core in Forza & Struttura, plank in Stabilità & Mobilità): + // confermato da Nicola il 22/08, vedi il commento su PESI_ASSE in registro.ts. Stessa + // misura, stessa funzione, due chiavi nel risultato — non è una duplicazione per errore. + const plank = m.get('f_plank_sec'); + if (plank !== undefined) { + const punteggio = scorePlank(plank, sesso); + out.core = punteggio; + out.plank = punteggio; + } + + // --- Composizione Corporea ------------------------------------------------- + const grasso = m.get('f_grasso_pct'); + if (grasso !== undefined) out.grasso = scoreGrassoPercento(grasso, sesso); + + const muscolo = m.get('f_muscolo_pct'); + if (muscolo !== undefined) out.muscolo = scoreMuscoloPercento(muscolo, sesso); + + const whr = m.get('f_whr'); + if (whr !== undefined) out.whr = scoreWhr(whr, sesso); + + // --- Cardio-Respiratorio ----------------------------------------------------- + // VO2max: diretto se misurato, altrimenti stimato da MUTT, altrimenti dallo step test — + // il primo presente vince, come nella cascata di fonti dell'oracolo (che nella demo usa + // solo MUTT, ma le tre vie coesistono nel resto del file Python). + const vo2Diretto = m.get('f_vo2max'); + const muttVam = m.get('f_mutt_vam'); + const stepHr = m.get('f_step_hr_recovery'); + let vo2max: number | undefined; + if (vo2Diretto !== undefined) vo2max = vo2Diretto; + else if (muttVam !== undefined) vo2max = vo2maxDaMutt(muttVam); + else if (stepHr !== undefined) vo2max = vo2maxDaStepTest(stepHr, sesso); + if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso); + + const spo2 = m.get('f_spo2_pct'); + if (spo2 !== undefined) out.wellness_tower_cardio = scoreSpo2(spo2); + + // --- Recupero & Sistema Nervoso / Energia & Regolazione Stress --------------- + // hrv è già un punteggio 0-100 (lo calcola lo Stress Index a monte, non una curva di + // questo motore): qui si limita soltanto, non si ricalcola. Pesa su DUE assi (Recupero + // e Energia, vedi PESI_ASSE) con la stessa chiave 'hrv' — un solo valore in `out`, letto + // da entrambi in cascata.ts. + const hrv = m.get('f_hrv_score'); + if (hrv !== undefined) out.hrv = clamp(hrv); + + const sistolica = m.get('f_pa_sistolica'); + const diastolica = m.get('f_pa_diastolica'); + if (sistolica !== undefined && diastolica !== undefined) out.pressione = scorePressione(sistolica, diastolica); + + const hrr = m.get('f_hrr_1min_bpm'); + if (hrr !== undefined) out.hrr = scoreRecuperoCardiaco(hrr); + + // --- Stabilità & Mobilità Funzionale ------------------------------------------ + const flamingo = m.get('f_flamingo_cadute'); + if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo); + + const sitReach = m.get('f_sit_reach_cm'); + if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso); + + const backScratch = m.get('f_back_scratch_cm'); + if (backScratch !== undefined && eta !== null) out.back_scratch = scoreBackScratch(backScratch, eta, sesso); + + const outreach = m.get('f_spalla_outreach_deg'); + const buckling = m.get('f_spalla_buckling_deg'); + if (outreach !== undefined && buckling !== undefined) { + out.wellness_tower_shoulder = scoreMobilitaSpalla(outreach, buckling); + } + + return out; +} diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index a8004f1..d367869 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -6,15 +6,25 @@ * Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel * registro (`registro_test.curva`), coi parametri dichiarati lì * (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un - * test senza curva dichiarata (es. un campo testuale) non contribuisce al - * calcolo. + * test senza curva dichiarata (es. un campo testuale, o una misura fisica — + * vedi sotto) non contribuisce a QUESTO livello. * - * Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione - * per sotto-dominio (media dei test dello stesso sotto-dominio — più test - * possono contribuire allo stesso sotto-dominio, es. spinta via push-up o - * panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`, - * `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette - * assi, i tre macro-score e la Fitness Age. + * Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola + * `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una + * curva generica (età, sesso, a volte un'altra misura come il peso + * corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini + * da curva (questionario) e sotto-domini fisici — in un'unica mappa. + * + * Un check-up e un questionario sono sessioni SEPARATE: senza altro, una + * sessione vedrebbe solo le proprie misure e lascerebbe vuoti gli assi + * dell'altra sessione. `misureValideAlla` risolve questo leggendo, per ogni + * test_id, la misura valida più recente DEL CLIENTE fra tutte le sue + * sessioni fino a quella data — non solo quelle della sessione corrente. + * + * Livelli 2-4 (`calcolaSessione`): usa `sottoDominiDellaSessione`, poi + * `cascata.ts` (`calcolaAsse`, `calcolaMacro`, `calcolaFitnessAge`) coi pesi + * letti da `pesi` (via `pesiDi`) per i sette assi, i tre macro-score e la + * Fitness Age. * * `salvaScore` congela il risultato nella tabella `score`, una riga per * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è @@ -30,8 +40,8 @@ * applicarla, e restituisce `null` se il registro (dato modificabile senza * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava * `lerp` con estremi indefiniti; e la lettura delle misure esclude - * `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma - * (punto aperto in §12). + * `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer + * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`). */ import type Database from 'better-sqlite3'; @@ -41,6 +51,7 @@ import { } from './curve'; import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata'; import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro'; +import { sottoDominiFisici, type Sesso } from './fisici'; /** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */ type ParamsCurva = { @@ -114,6 +125,120 @@ export function applicaCurva(voce: VoceRegistro, valore: number): number | null } } +/** + * Sotto questa età (in giorni) una misura smette di contare per una sessione successiva. + * `null` = nessun limite: oggi una misura resta valida per sempre, finché non ne arriva + * una più recente dello stesso test_id. Decisione aperta per Nicola — quanto "invecchia" + * un dato fisico prima di non poter più coprire il questionario di un mese dopo è una + * scelta di prodotto, non tecnica; finché non arriva si tiene la più permissiva delle due, + * perché la direzione di guasto opposta (scartare una misura buona) è quella che di nuovo + * produce assi vuoti che potrebbero non esserlo. + */ +export const FINESTRA_GIORNI: number | null = null; + +/** + * Per ogni test_id, il valore valido più recente DI QUESTO CLIENTE fra tutte le sue + * sessioni fino a quella data compresa — non solo le misure della sessione `sessioneId`. + * Risolve il problema del check-up e del questionario come sessioni separate (vedi + * l'intestazione del file): un check-up del giorno D+10 vede ancora le risposte del + * questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò + * che accadrà dopo di lei. + * + * "Più recente" = data della sessione DESC, poi id di sessione DESC (a parità di data, + * l'inserimento più tardo vince) — stesso criterio di `leggiScore`. A parità di SESSIONE + * (due misure dello stesso test_id nella stessa sessione: `registraMisure` non lo vieta, + * es. una correzione manuale dopo un refuso) l'ORDER BY aggiunge un terzo criterio + * esplicito, `m.id` di riga in `misure` DESC: vince quella scritta dopo. Senza questo + * terzo criterio le due righe uscirebbero in un ordine NON definito da SQL (nessuna delle + * due chiavi precedenti le distingue), non "implicito" come diceva una versione precedente + * di questo commento — un ordine che sembra stabile per come SQLite le restituisce oggi, + * ma su cui non si può contare. + * + * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della + * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da + * una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`, + * colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel + * momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione + * successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta + * esclusa come prima. + */ +export function misureValideAlla(db: Database.Database, sessioneId: number): Map { + const sessione = db.prepare( + `SELECT client_code, data, id FROM sessioni WHERE id = ?` + ).get(sessioneId) as { client_code: string; data: string; id: number } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const righe = db.prepare( + `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data + FROM misure m + JOIN sessioni s2 ON s2.id = m.sessione_id + WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL + AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) + ORDER BY s2.data DESC, s2.id DESC, m.id DESC` + ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as + { test_id: string; valore_num: number; data: string }[]; + + const mappa = new Map(); + for (const r of righe) { + // gia' trovato un valore per questo test_id: e' il piu' recente, per data di sessione, + // poi id di sessione, poi (spareggio) id di riga in misure — tutti e tre espliciti + // nell'ORDER BY qui sopra, la prima riga per test_id in questo ciclo e' sempre quella giusta. + if (mappa.has(r.test_id)) continue; + if (FINESTRA_GIORNI !== null) { + const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000; + if (giorni > FINESTRA_GIORNI) continue; + } + mappa.set(r.test_id, r.valore_num); + } + return mappa; +} + +/** + * I sotto-domini di una sessione, dalle due fonti che oggi esistono: il questionario + * (livello 1, `applicaCurva` sulle misure di `misureValideAlla`) e i test fisici + * (`sottoDominiFisici`, sull'età alla data della sessione e il sesso del cliente). In + * caso di chiave in conflitto vince il fisico — oggi non capita mai (i sotto-domini da + * curva sono tutti `questionario_*`), ma se domani un test_id q_* condividesse la chiave + * di un sotto-dominio fisico, un dato misurato in un check-up deve prevalere su + * un'autovalutazione. + */ +export function sottoDominiDellaSessione(db: Database.Database, sessioneId: number): Record { + const sessione = db.prepare( + `SELECT data, eta_alla_data, client_code FROM sessioni WHERE id = ?` + ).get(sessioneId) as { data: string; eta_alla_data: number | null; client_code: string } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const misurate = misureValideAlla(db, sessioneId); + + // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per + // sotto-dominio — più test nello stesso sotto-dominio contano come media. + const attivi = testAttivi(db, sessione.data); + const perSottoDominio = new Map(); + for (const voce of attivi) { + if (!voce.sotto_dominio) continue; + const grezzo = misurate.get(voce.test_id); + if (grezzo === undefined) continue; + const punteggio = applicaCurva(voce, grezzo); + if (punteggio === null) continue; + const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; + lista.push(punteggio); + perSottoDominio.set(voce.sotto_dominio, lista); + } + const daCurva: Record = {}; + for (const [nome, valori] of perSottoDominio) { + daCurva[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); + } + + const soggetto = db.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(sessione.client_code) as + { sesso: Sesso } | undefined; + // soggetto dovrebbe sempre esistere (sessioni.client_code referenzia soggetti, FK attive + // in db.ts): il controllo resta comunque, perche' l'assenza di dato fisico e' un {} + // sicuro, mai un sesso indovinato. + const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso) : {}; + + return { ...daCurva, ...daFisici }; +} + /** * Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. * `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché @@ -134,43 +259,16 @@ export type RisultatoSessione = { /** * Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da * costanti: `testAttivi` alla data della sessione, i valori grezzi da - * `misure`, i pesi da `pesi` (tramite `pesiDi`). + * `misure` (tramite `sottoDominiDellaSessione`), i pesi da `pesi` (tramite `pesiDi`). */ export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione { const sessione = db.prepare( - `SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?` - ).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined; + `SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?` + ).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined; if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); - const attivi = testAttivi(db, sessione.data); - // fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno - // non le conferma". La colonna della conferma non esiste ancora (punto aperto in - // §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar - // passare le misure confermate, non tolto del tutto. - const misurate = new Map( - (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as - { test_id: string; valore_num: number | null }[]) - .filter((m) => m.valore_num !== null) - .map((m) => [m.test_id, m.valore_num as number]) - ); - - // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per - // sotto-dominio — più test nello stesso sotto-dominio contano come media. - const perSottoDominio = new Map(); - for (const voce of attivi) { - if (!voce.sotto_dominio) continue; - const grezzo = misurate.get(voce.test_id); - if (grezzo === undefined) continue; - const punteggio = applicaCurva(voce, grezzo); - if (punteggio === null) continue; - const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; - lista.push(punteggio); - perSottoDominio.set(voce.sotto_dominio, lista); - } - const sottoDominio: Record = {}; - for (const [nome, valori] of perSottoDominio) { - sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); - } + // Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione. + const sottoDominio = sottoDominiDellaSessione(db, sessioneId); // Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso. const nomiAssi = (db.prepare( diff --git a/apps/platforms/src/lib/longevity/motore/test-fisici.ts b/apps/platforms/src/lib/longevity/motore/test-fisici.ts index 446c33c..4693929 100644 --- a/apps/platforms/src/lib/longevity/motore/test-fisici.ts +++ b/apps/platforms/src/lib/longevity/motore/test-fisici.ts @@ -15,18 +15,15 @@ * comparabili fra protocolli), la seconda perché non è mai chiamata da * nessun peso. * - * ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate - * contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono - * irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo - * sulle sette curve generiche dichiarate nel vocabolario di `registro.ts` - * (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e - * non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`, - * senza modo di passare età e sesso, che quasi tutte queste funzioni - * richiedono. Agganciarle è lavoro del piano degli import, non di questa - * revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far - * arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai - * dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve - * anche codice nuovo nel motore, non solo righe nuove in `registro_test`. + * COLLEGATE AL MOTORE tramite `motore/fisici.ts` (`sottoDominiFisici`), non + * tramite `applicaCurva`: `applicaCurva` smista solo sulle sette curve + * generiche dichiarate nel vocabolario di `registro.ts` (`bell`, `lin_dec`, + * `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e la sua firma prende + * `(voce, valore)`, senza modo di passare età e sesso che quasi tutte queste + * funzioni richiedono — estenderla resta lavoro futuro, non fatto qui. + * `sottoDominiFisici` aggira il problema chiamando queste funzioni + * direttamente, con gli argomenti che servono presi dalla mappa delle misure + * del check-up (`registro.ts`, voci `f_*`). */ import { clamp, lerp, arrotonda1 } from './curve'; diff --git a/apps/platforms/src/lib/longevity/progressione.ts b/apps/platforms/src/lib/longevity/progressione.ts new file mode 100644 index 0000000..dc10d09 --- /dev/null +++ b/apps/platforms/src/lib/longevity/progressione.ts @@ -0,0 +1,129 @@ +// +// L'andamento nel tempo di un cliente: una riga per sessione, i punteggi congelati che +// quella sessione ha davvero, e dove segnare che qualcosa nel modo di calcolare è cambiato. +// +// Stessa regola non negoziabile di `refertoDi` (vista.ts): i valori vengono da `leggiScore` +// (punteggi congelati in `score`), MAI da un ricalcolo — un peso cambiato dopo il fatto non +// deve alterare un punto già emesso, tanto quanto non altera un referto già emesso. Qui in +// più c'è la STORIA di più sessioni, quindi la domanda "quale sessione entra nel grafico" ha +// una risposta precisa: solo quelle che hanno almeno una riga in `score`. Una sessione aperta +// (`apriSessione`) ma mai calcolata — capita quando il consenso non è dato, o quando qualcosa +// si è fermato a metà prima del congelamento — non ha punteggi da mostrare, e mostrarla come +// un punto vuoto sarebbe un buco silenzioso nel grafico, non un dato mancante dichiarato. +import type Database from 'better-sqlite3'; +import { leggiScore } from './motore'; + +/** + * Un punto della progressione: una sessione con i suoi punteggi congelati. + * `questVersion` è `null` per un check-up (le sessioni `checkup` non passano dal + * questionario, quindi `sessioni.quest_version` resta NULL — vedi db.ts). + * `assi`/`macro` sono `Record`: la chiave è il nome del contenitore + * (es. 'Forza & Struttura', 'PERFORMANCE') così com'è scritto in `score.elemento`, il + * valore è `null` quando lo stato congelato era 'insufficiente' — mai un numero inventato. + * `cambioVersione` è il testo del cambio rispetto al PUNTO PRECEDENTE (non rispetto + * all'ultimo `quest_version` in assoluto, tranne per il caso descritto sotto), `null` sul + * primo punto per costruzione (non c'è un "prima"). + */ +export type PuntoProgressione = { + sessioneId: number; + data: string; + tipo: 'checkup' | 'questionario'; + questVersion: string | null; + modelVersion: string; + assi: Record; + macro: Record; + fitnessAge: number | null; + cambioVersione: string | null; +}; + +/** + * Le sessioni di un cliente, in ordine cronologico, che hanno almeno una riga in `score` + * — le uniche che `progressioneDi` può mostrare. Stesso criterio d'ordine di + * `misureValideAlla`/`leggiScore` (data ASC, poi id ASC: qui l'ordine è cronologico in + * avanti, l'opposto di quelle due funzioni che cercano "il più recente", ma il criterio + * di spareggio a parità di data è lo stesso — l'id di sessione, non l'ordine con cui + * SQLite restituisce le righe). + */ +function sessioniConScore(db: Database.Database, clientCode: string) { + return db.prepare( + `SELECT id AS sessioneId, data, tipo + FROM sessioni + WHERE client_code = ? + AND EXISTS (SELECT 1 FROM score WHERE score.sessione_id = sessioni.id) + ORDER BY data ASC, id ASC` + ).all(clientCode) as { sessioneId: number; data: string; tipo: 'checkup' | 'questionario' }[]; +} + +/** + * `quest_version`/`model_version` della sessione, letti da `score` (non da `sessioni`: + * `sessioni.quest_version` è quello che il cliente ha compilato, `score.quest_version` è + * quello con cui l'ultimo calcolo congelato ha davvero girato — nel funzionamento normale + * coincidono sempre, ma è `score` la fonte che questo modulo legge per coerenza con + * `leggiScore`, non un'altra tabella). Una sessione può avere più calcoli nel tempo (vedi + * il commento di `leggiScore` su `salvaScore` senza vincolo di unicità): si prende la riga + * più recente per id, lo stesso criterio con cui `leggiScore` sceglie il valore di ciascun + * elemento. + */ +function versioniDi(db: Database.Database, sessioneId: number): { questVersion: string | null; modelVersion: string } { + const riga = db.prepare( + `SELECT quest_version, model_version FROM score WHERE sessione_id = ? ORDER BY id DESC LIMIT 1` + ).get(sessioneId) as { quest_version: string | null; model_version: string } | undefined; + // Non dovrebbe mai mancare: sessioniConScore filtra con lo stesso EXISTS. Se manca è un + // bug di questa funzione (query diversa da quella del filtro), non un caso di prodotto — + // meglio un errore esplicito che un punto silenziosamente senza versione. + if (!riga) throw new Error(`sessione ${sessioneId} senza righe in score, nonostante il filtro`); + return { questVersion: riga.quest_version, modelVersion: riga.model_version }; +} + +/** + * L'andamento nel tempo di un cliente: un punto per sessione calcolata, in ordine + * cronologico. `cambioVersione` marca dove il modo di calcolare è cambiato: + * - il `modelVersion` è diverso da quello del punto precedente (un cambio di curve o pesi + * cambia il significato del numero, non solo il suo valore); + * - oppure il `questVersion` (quando non è `null`) è diverso dall'ultimo `questVersion` + * NON NULLO visto prima — non dal punto immediatamente precedente: un check-up in mezzo + * a due questionari ha `questVersion` null e non deve né innescare un cambio (non ha una + * versione da confrontare) né "nascondere" un cambio vero fra i due questionari che lo + * circondano (il confronto salta il check-up, non si ferma su di lui). + */ +export function progressioneDi(db: Database.Database, clientCode: string): PuntoProgressione[] { + const sessioni = sessioniConScore(db, clientCode); + + const punti: PuntoProgressione[] = []; + let modelVersionPrecedente: string | null = null; + let questVersionNonNullaPrecedente: string | null = null; + + for (const s of sessioni) { + const punteggi = leggiScore(db, s.sessioneId); + const { questVersion, modelVersion } = versioniDi(db, s.sessioneId); + + const assi: Record = {}; + for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null; + + const macro: Record = {}; + for (const [nome, p] of Object.entries(punteggi.macro)) macro[nome] = p.stato === 'ok' ? p.valore : null; + + const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; + + const cambi: string[] = []; + if (punti.length > 0) { + if (modelVersionPrecedente !== null && modelVersion !== modelVersionPrecedente) { + cambi.push(`modello ${modelVersionPrecedente} → ${modelVersion}`); + } + if (questVersion !== null && questVersionNonNullaPrecedente !== null && questVersion !== questVersionNonNullaPrecedente) { + cambi.push(`questionario ${questVersionNonNullaPrecedente} → ${questVersion}`); + } + } + + punti.push({ + sessioneId: s.sessioneId, data: s.data, tipo: s.tipo, + questVersion, modelVersion, assi, macro, fitnessAge, + cambioVersione: cambi.length > 0 ? cambi.join('; ') : null, + }); + + modelVersionPrecedente = modelVersion; + if (questVersion !== null) questVersionNonNullaPrecedente = questVersion; + } + + return punti; +} diff --git a/apps/platforms/src/lib/longevity/registro.ts b/apps/platforms/src/lib/longevity/registro.ts index 3204c26..e17530f 100644 --- a/apps/platforms/src/lib/longevity/registro.ts +++ b/apps/platforms/src/lib/longevity/registro.ts @@ -93,6 +93,71 @@ const QUESTIONARIO: VoceRegistro[] = [ asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, ]; +// I test fisici del check-up: misure GREZZE, non punteggi. A differenza del questionario +// non passano da `applicaCurva` (nessuna `curva`/`params` qui: quelle sette curve generiche +// non sanno cosa farsene di un'età o di un sesso) — il calcolo è in `motore/fisici.ts` +// (`sottoDominiFisici`), che è il porting dell'oracolo per queste ventiquattro voci. Per +// questo `sotto_dominio` resta assente: il sotto-dominio non è un dato del registro come +// per il questionario, è il NOME della chiave che `sottoDominiFisici` restituisce. +// `asse` invece è valorizzato: serve solo all'interfaccia (raggruppare il modulo del +// check-up per sezione), nessun codice lo legge oggi. Due voci alimentano DUE +// sotto-domini dalla stessa misura (f_plank_sec -> core E plank, f_hrv_score -> hrv sia +// in Recupero che in Energia): l'asse riportato qui è quello principale, l'altro si vede +// in `sottoDominiFisici`. +// `range_min`/`range_max` sono limiti di PLAUSIBILITÀ FISIOLOGICA, larghi apposta: non sono +// soglie cliniche (quelle vivono nelle funzioni di `test-fisici.ts`), servono solo a +// intercettare un errore di battitura in fase di inserimento (es. 480 kg di squat). +const FISICI: VoceRegistro[] = [ + { test_id: 'f_peso_kg', etichetta: 'Peso corporeo', unita: 'kg', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 30, range_max: 250, attivo_da: DA }, + { test_id: 'f_handgrip_kg', etichetta: 'Forza di presa (handgrip, dinamometro)', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 150, attivo_da: DA }, + { test_id: 'f_row_carico_kg', etichetta: 'Trazione/rematore, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA }, + { test_id: 'f_row_rip', etichetta: 'Trazione/rematore, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_squat_carico_kg', etichetta: 'Squat, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 400, attivo_da: DA }, + { test_id: 'f_squat_rip', etichetta: 'Squat, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_plank_sec', etichetta: 'Plank, tenuta', unita: 'sec', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 1200, attivo_da: DA }, + { test_id: 'f_grasso_pct', etichetta: 'Massa grassa', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 2, range_max: 60, attivo_da: DA }, + { test_id: 'f_muscolo_pct', etichetta: 'Massa muscolare', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 20, range_max: 70, attivo_da: DA }, + { test_id: 'f_whr', etichetta: 'Rapporto vita-fianchi (WHR)', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 0.5, range_max: 1.5, attivo_da: DA }, + { test_id: 'f_vo2max', etichetta: 'VO2max (misura diretta)', unita: 'ml/kg/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 10, range_max: 90, attivo_da: DA }, + { test_id: 'f_mutt_vam', etichetta: 'VAM al test MUTT/tapis (per stimare il VO2max)', unita: 'm/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 400, attivo_da: DA }, + { test_id: 'f_step_hr_recovery', etichetta: 'Frequenza cardiaca di recupero, Queen\'s College Step Test', + unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 220, attivo_da: DA }, + { test_id: 'f_spo2_pct', etichetta: 'Saturazione O2 (SpO2)', unita: '%', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 100, attivo_da: DA }, + { test_id: 'f_hrv_score', etichetta: 'HRV, punteggio (Stress Index)', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pa_sistolica', etichetta: 'Pressione arteriosa sistolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 70, range_max: 250, attivo_da: DA }, + { test_id: 'f_pa_diastolica', etichetta: 'Pressione arteriosa diastolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 40, range_max: 150, attivo_da: DA }, + { test_id: 'f_hrr_1min_bpm', etichetta: 'Recupero cardiaco a 1 minuto post-sforzo', unita: 'bpm', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_flamingo_cadute', etichetta: 'Test del fenicottero, numero di cadute', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA }, + { test_id: 'f_sit_reach_cm', etichetta: 'Sit and reach', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -30, range_max: 50, attivo_da: DA }, + { test_id: 'f_back_scratch_cm', etichetta: 'Back scratch (mobilità spalle)', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA }, + { test_id: 'f_spalla_outreach_deg', etichetta: 'Wellness Tower spalla, outreach', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'f_spalla_buckling_deg', etichetta: 'Wellness Tower spalla, buckling', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, +]; + export function seedRegistro(db: Database.Database): void { const ins = db.prepare( `INSERT OR REPLACE INTO registro_test @@ -114,6 +179,7 @@ export function seedRegistro(db: Database.Database): void { } }); tx(QUESTIONARIO); + tx(FISICI); } // Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, diff --git a/apps/platforms/src/lib/longevity/vista.ts b/apps/platforms/src/lib/longevity/vista.ts index be66d2c..a88092d 100644 --- a/apps/platforms/src/lib/longevity/vista.ts +++ b/apps/platforms/src/lib/longevity/vista.ts @@ -11,7 +11,7 @@ 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'; +import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore'; export type CampoQuestionario = { id: string; @@ -137,11 +137,13 @@ export type MacroVista = { nome: string; valore: number | null; copertura: numbe /** * 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. + * 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 = { handgrip: 'Forza di presa (handgrip)', @@ -178,17 +180,15 @@ function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string ).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. */ +/** 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 { - 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)); + return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId))); } /** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse diff --git a/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts new file mode 100644 index 0000000..8ca5c92 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts @@ -0,0 +1,90 @@ +import type { APIRoute } from 'astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { etaDelCliente } from '../../../../lib/longevity/anagrafica'; +import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro'; +import { salvaCheckup } from '../../../../lib/longevity/gestionale'; +import type { MisuraIn } from '../../../../lib/longevity/misure'; + +export const prerender = false; + +// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/). + +const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/; +const REGEX_CODE = /^ISL-\d{4}$/; + +const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } }); + +/** + * Il parsing del form del check-up, isolato dall'I/O (nessun DB, nessuna Request) apposta + * per essere testato senza fixture: riceve i campi ATTIVI (da `testAttivi`, filtrati su + * `f_*` da chi chiama) e il `FormData` grezzo, e produce le misure da passare a + * `salvaCheckup` — o l'errore che ferma tutto PRIMA che una sessione si apra. + * + * Regole, dalla spec (#2): un campo vuoto = non misurato (non un errore, non uno zero); la + * virgola decimale si accetta ("32,5" come "32.5", scrittura comune su un modulo cartaceo); + * un valore non numerico è un errore che blocca l'intero check-up, non solo quel campo — + * meglio fermarsi che salvare a metà con un tacito zero; le chiavi che non sono fra i campi + * attivi (client_code, data, un q_* del questionario, un refuso) si ignorano in silenzio, + * mai un errore per la loro sola presenza — un `` nascosto o un campo dismesso non + * deve rompere il salvataggio del resto. + */ +export function misureDalForm( + form: FormData, + campiAttivi: Pick[] +): { ok: true; misure: MisuraIn[] } | { ok: false; errore: string } { + const perTestId = new Map(campiAttivi.map((c) => [c.test_id, c])); + const misure: MisuraIn[] = []; + + for (const [chiave, valoreGrezzo] of form.entries()) { + const campo = perTestId.get(chiave); + if (!campo) continue; // chiave estranea: ignorata, non è un errore del form + + const testo = String(valoreGrezzo).trim(); + if (testo === '') continue; // vuoto = non misurato + + const numero = Number(testo.replace(',', '.')); + if (!Number.isFinite(numero)) { + return { ok: false, errore: `Valore non numerico per ${chiave}: "${testo}"` }; + } + misure.push({ test_id: chiave, valore_num: numero, unita: campo.unita }); + } + + return { ok: true, misure }; +} + +export const POST: APIRoute = async ({ request, locals }) => { + const form = await request.formData(); + const clientCode = String(form.get('client_code') ?? '').trim(); + const data = String(form.get('data') ?? '').trim(); + + // Senza un codice valido non c'è una scheda a cui tornare: l'unico ritorno sensato è + // l'indice del gestionale, non /cliente/undefined/checkup. + if (!REGEX_CODE.test(clientCode)) { + return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`); + } + const erroreAllaScheda = (testo: string) => + redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?errore=${encodeURIComponent(testo)}`); + + if (!REGEX_DATA.test(data)) return erroreAllaScheda('Data non valida (formato AAAA-MM-GG).'); + const oggi = new Date().toISOString().slice(0, 10); + if (data > oggi) return erroreAllaScheda('La data del check-up non può essere nel futuro.'); + + const db = getLongevityDb(); + const campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_')); + + const parse = misureDalForm(form, campiAttivi); + if (!parse.ok) return erroreAllaScheda(parse.errore); + + // L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware: + // solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del + // client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta. + const operatore = locals.user!.username; + const eta = etaDelCliente(clientCode, data); + + const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure }); + if (!esito.ok) return erroreAllaScheda(esito.errore); + + const params = new URLSearchParams({ salvato: String(esito.sessioneId) }); + if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length)); + return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`); +}; diff --git a/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts b/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts new file mode 100644 index 0000000..e10d011 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/clienti.ts @@ -0,0 +1,56 @@ +import type { APIRoute } from 'astro'; +import { apriFascicolo } from '../../../../lib/longevity/anagrafica'; + +export const prerender = false; + +// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/): +// il middleware nega prima che questo file veda la richiesta. + +const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/; + +const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } }); + +/** Sempre la stessa pagina di partenza: questo endpoint ha un solo modulo che lo chiama + * (pages/longevity/gestionale/nuovo.astro, unità 2b), quindi il ritorno in caso di errore + * non serve dedurlo da un Referer — si scrive qui, una volta. */ +const erroreAlModulo = (testo: string) => + redirect(`/longevity/gestionale/nuovo?errore=${encodeURIComponent(testo)}`); + +function isSesso(v: string): v is 'M' | 'F' { + return v === 'M' || v === 'F'; +} + +export const POST: APIRoute = async ({ request }) => { + const form = await request.formData(); + const leggi = (chiave: string) => String(form.get(chiave) ?? '').trim(); + + const nome = leggi('nome'); + const cognome = leggi('cognome'); + const sesso = leggi('sesso'); + const dataNascita = leggi('data_nascita'); + const email = leggi('email'); + const telefono = leggi('telefono'); + + if (!nome) return erroreAlModulo('Il nome è obbligatorio.'); + if (!cognome) return erroreAlModulo('Il cognome è obbligatorio.'); + if (!isSesso(sesso)) return erroreAlModulo('Il sesso deve essere M o F.'); + + if (dataNascita) { + if (!REGEX_DATA.test(dataNascita)) { + return erroreAlModulo('Data di nascita non valida (formato AAAA-MM-GG).'); + } + // La data del server, non quella (manomettibile) del form: stesso criterio di + // pages/api/longevity/questionario.ts per "oggi". + const oggi = new Date().toISOString().slice(0, 10); + if (dataNascita > oggi) return erroreAlModulo('La data di nascita non può essere nel futuro.'); + } + + const code = apriFascicolo({ + nome, cognome, sesso, + data_nascita: dataNascita || undefined, + email: email || undefined, + telefono: telefono || undefined, + }); + + return redirect(`/longevity/gestionale/cliente/${code}`); +}; diff --git a/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts new file mode 100644 index 0000000..d7f9ed5 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts @@ -0,0 +1,52 @@ +import type { APIRoute } from 'astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { verificaMisura } from '../../../../lib/longevity/misure'; + +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** + * La decisione del trainer su una misura fuori range (issue #7): confermarla o scartarla, + * dalla scheda cliente del gestionale (le pagine arrivano con #2, questo endpoint no). + * `canAccessPath` (auth.ts) gia' chiude /api/longevity/gestionale/** a chi non e' trainer + * o admin: qui non c'e' nessun controllo di ruolo da ripetere. + * + * Il codice cliente per il redirect si legge SEMPRE dalla misura nel database, mai da un + * campo del form — stesso principio di questionario.ts (il client_code non si fida di chi + * scrive la richiesta), applicato qui al lato trainer: un id di misura non e' un fascicolo + * che si possa dirottare scrivendo un altro codice nel form. + */ +export const POST: APIRoute = async ({ request, locals }) => { + const form = await request.formData(); + + const misuraIdRaw = form.get('misura_id'); + const misuraId = typeof misuraIdRaw === 'string' ? Number(misuraIdRaw) : NaN; + if (!Number.isInteger(misuraId)) return json(404, { error: 'misura inesistente' }); + + const db = getLongevityDb(); + + // Si legge il client_code PRIMA di chiamare verificaMisura: serve per il redirect anche + // quando la verifica viene rifiutata (gia' verificata, nel range...) — l'unico caso senza + // una scheda cliente su cui tornare e' proprio l'id che non esiste. + const riga = db.prepare(`SELECT client_code FROM misure WHERE id = ?`).get(misuraId) as + { client_code: string } | undefined; + if (!riga) return json(404, { error: `misura ${misuraId} inesistente` }); + + const tornaA = (query: string): Response => new Response(null, { + status: 303, + headers: { Location: `/longevity/gestionale/cliente/${riga.client_code}${query}` }, + }); + + const esitoRaw = form.get('esito'); + const esito: 'confermata' | 'scartata' | null = + esitoRaw === 'confermata' || esitoRaw === 'scartata' ? esitoRaw : null; + if (esito === null) { + return tornaA(`?errore=${encodeURIComponent('Esito non valido: deve essere confermata o scartata.')}`); + } + + const risultato = verificaMisura(db, { misuraId, esito, operatore: locals.user!.username }); + if (!risultato.ok) return tornaA(`?errore=${encodeURIComponent(risultato.errore)}`); + return tornaA(`?verificata=${esito}`); +}; diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro new file mode 100644 index 0000000..73bf6cf --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro @@ -0,0 +1,180 @@ +--- +// La scheda di un cliente: anagrafica, il referto dell'ultima sessione (lo stesso che il +// cliente vede da solo su /longevity/io, qui per il trainer), l'elenco delle sessioni e le +// misure fuori range. Un codice che non esiste in anagrafica è un 404, non uno schermo +// vuoto — non c'è niente da mostrare per un fascicolo che non c'è. +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 { clienteDaCodice, etaDelCliente } from '../../../../lib/longevity/anagrafica'; +import { sessioniDi } from '../../../../lib/longevity/gestionale'; +import { misureDaVerificare } from '../../../../lib/longevity/misure'; +import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista'; +import { formattaNumero } from '../../../../lib/longevity/formato'; + +export const prerender = false; + +const { code } = Astro.params; +const cliente = code ? clienteDaCodice(code) : null; +if (!cliente) return new Response('Cliente non trovato', { status: 404 }); + +const db = getLongevityDb(); +const oggi = new Date().toISOString().slice(0, 10); +const eta = etaDelCliente(cliente.client_code, oggi); +const sessioni = sessioniDi(db, cliente.client_code); +const daVerificare = misureDaVerificare(db, cliente.client_code); +const ultimaId = ultimaSessioneDi(db, cliente.client_code); +const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null; + +const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = { + checkup: 'Check-up', + questionario: 'Questionario', +}; +const formattaData = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }); + +const params = Astro.url.searchParams; +const salvato = params.get('salvato'); +const fuoriCount = params.get('fuori'); +const errore = params.get('errore'); +const verificata = params.get('verificata'); +const MESSAGGIO_VERIFICA: Record<'confermata' | 'scartata', string> = { + confermata: 'Misura confermata: punteggi ricalcolati.', + scartata: 'Misura scartata.', +}; +const messaggioVerifica = + verificata === 'confermata' || verificata === 'scartata' ? MESSAGGIO_VERIFICA[verificata] : null; +--- + +
+
+

{cliente.cognome} {cliente.nome}

+

+ {cliente.client_code} · {eta !== null ? `${eta} anni` : 'età non registrata'} · + accesso {cliente.ha_accesso ? 'attivo' : 'non ancora attivato'} +

+
+
+ {!cliente.ha_accesso && ( + Crea accesso al referto + )} + Nuovo check-up +
+
+ + {errore &&

{errore}

} + {salvato &&

Check-up salvato (sessione #{salvato}).

} + {messaggioVerifica &&

{messaggioVerifica}

} + {fuoriCount && ( +

+ {fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di + plausibilità: verificale più sotto. +

+ )} + +
+

Anagrafica

+

Email: {cliente.email ?? '—'}

+

Telefono: {cliente.telefono ?? '—'}

+

Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}

+
+ + {!referto && ( +
+

Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.

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

Fitness Age

+
+ + {referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'} + + {referto.fitnessAge !== null && anni} +
+

+ Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}% + {referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'} +

+
+ +
+

Radar

+ +
+ +
+ {referto.macro.map((m) => ( + + ))} +
+
+ )} + +
+

Sessioni

+ {sessioni.length === 0 ? ( +

Nessuna sessione.

+ ) : ( + + + + + + {sessioni.map((s) => ( + + + + + + + + ))} + +
DataTipoOperatoreMisureFitness Age
{formattaData(s.data)}{ETICHETTA_TIPO[s.tipo]}{s.operatore ?? '—'}{s.numeroMisure}{s.fitnessAge !== null ? formattaNumero(s.fitnessAge) : '—'}
+ )} +
+ + {daVerificare.length > 0 && ( +
+

Misure da verificare

+

+ Una misura confermata è un dato vero: entra nei punteggi e ricalcola quella sessione e + le successive. Una scartata è un errore di strumento o protocollo: resta fuori per sempre. +

+ + + + {daVerificare.map((m) => ( + + + + + + + + ))} + +
DataTestValoreRange attesoDecisione
{formattaData(m.data)}{m.etichetta} + {m.valore_num !== null ? formattaNumero(m.valore_num) : '—'}{m.unita ? ` ${m.unita}` : ''} + {m.range_min ?? '—'}–{m.range_max ?? '—'} +
+ + +
+
+ + +
+
+
+ )} +
diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro new file mode 100644 index 0000000..a06cf95 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro @@ -0,0 +1,86 @@ +--- +// Crea l'accesso al sito per un cliente che ha già il fascicolo: il trainer sceglie +// l'username, la password la genera il sistema e si mostra UNA volta sola, qui, mai +// via redirect (finirebbe nella query string dell'URL e nella cronologia del browser). +// ⚠️ Questa pagina non apre mai il database anagrafico da sé: passa solo da +// anagrafica.ts e accesso.ts, gli unici moduli autorizzati (li sorveglia un test). +import Longevity from '../../../../../layouts/Longevity.astro'; +import { accessoDelCliente } from '../../../../../lib/longevity/anagrafica'; +import { creaAccessoCliente } from '../../../../../lib/longevity/accesso'; + +export const prerender = false; + +const { code } = Astro.params as { code: string }; + +// La password passa da qui una volta sola: niente cache, né del browser né di un proxy +// davanti a Node, che altrimenti la servirebbe di nuovo a chi ripete la stessa GET. +Astro.response.headers.set('Cache-Control', 'no-store'); + +const fascicolo = accessoDelCliente(code); +if (!fascicolo) Astro.response.status = 404; + +let errore = ''; +let creato: { username: string; password: string } | null = null; + +if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + const username = String(form.get('username') ?? ''); + const esito = creaAccessoCliente(undefined, { code, username }); + if (esito.ok) creato = { username: esito.username, password: esito.password }; + else errore = esito.errore; +} +--- + + {!fascicolo && ( +
+

Nessun fascicolo con codice {code}.

+

Torna alla scheda

+
+ )} + + {fascicolo && fascicolo.userId !== null && ( +
+

Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.

+

Torna alla scheda

+
+ )} + + {fascicolo && fascicolo.userId === null && creato && ( +
+

Accesso creato. Comunicala al cliente: non verrà mostrata di nuovo.

+

Utente: {creato.username}

+

Password: {creato.password}

+

Torna alla scheda

+
+ )} + + {fascicolo && fascicolo.userId === null && !creato && ( +
+ {errore &&

{errore}

} +
+ Nome utente + +
+
+ Torna alla scheda + +
+
+ )} + + +
diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro new file mode 100644 index 0000000..35a41a4 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro @@ -0,0 +1,57 @@ +--- +// Il modulo del check-up manuale: i campi letti dal registro (campiDelCheckup), +// raggruppati per asse come il modulo cartaceo. L'invio va all'API del gestionale, che fa +// la validazione vera (pages/api/longevity/gestionale/checkup.ts) — qui c'è solo il form. +import Longevity from '../../../../../layouts/Longevity.astro'; +import { getLongevityDb } from '../../../../../lib/longevity/db'; +import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica'; +import { campiDelCheckup } from '../../../../../lib/longevity/gestionale'; + +export const prerender = false; + +const { code } = Astro.params; +const cliente = code ? clienteDaCodice(code) : null; +if (!cliente) return new Response('Cliente non trovato', { status: 404 }); + +const oggi = new Date().toISOString().slice(0, 10); +const gruppi = campiDelCheckup(getLongevityDb(), oggi); +const errore = Astro.url.searchParams.get('errore'); +--- + +
+

Nuovo check-up

+

{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato oggi.

+
+ + {errore &&

{errore}

} + +
+ + + + + {gruppi.map((g) => ( +
+

{g.asse}

+ {g.campi.map((c) => ( + + ))} +
+ ))} + +
+ Annulla + +
+
+
diff --git a/apps/platforms/src/pages/longevity/gestionale/index.astro b/apps/platforms/src/pages/longevity/gestionale/index.astro new file mode 100644 index 0000000..fe278e4 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/index.astro @@ -0,0 +1,71 @@ +--- +// L'elenco dei clienti del gestionale: la prima pagina che il trainer apre. Ogni riga +// porta anche data e tipo dell'ultima sessione — utile per capire a colpo d'occhio chi +// non si presenta da tempo, senza aprire ogni scheda. +import Longevity from '../../../layouts/Longevity.astro'; +import { getLongevityDb } from '../../../lib/longevity/db'; +import { elencoClienti, etaDelCliente } from '../../../lib/longevity/anagrafica'; +import { sessioniDi } from '../../../lib/longevity/gestionale'; + +export const prerender = false; + +const db = getLongevityDb(); +const oggi = new Date().toISOString().slice(0, 10); + +const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = { + checkup: 'Check-up', + questionario: 'Questionario', +}; +const formattaData = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }); + +// Una query in più per ogni cliente (sessioniDi, tenendo solo [0]): va bene finché i +// fascicoli sono pochi — non è pensata per scalare a centinaia di clienti. +const righe = elencoClienti().map((c) => ({ + ...c, + eta: etaDelCliente(c.client_code, oggi), + ultima: sessioniDi(db, c.client_code)[0] ?? null, +})); +--- + +
+
+

Clienti

+

{righe.length} {righe.length === 1 ? 'fascicolo' : 'fascicoli'}.

+
+ Nuovo cliente +
+ + {righe.length === 0 ? ( +

Nessun cliente ancora. Apri il primo fascicolo.

+ ) : ( + + + + + + + + + + + + {righe.map((c) => ( + + + + + + + + ))} + +
CodiceCognome e nomeEtàAccessoUltima sessione
{c.client_code}{c.cognome} {c.nome}{c.eta ?? '—'}{c.ha_accesso ? 'Sì' : 'No'} + {c.ultima ? ( + <>{formattaData(c.ultima.data)} — {ETICHETTA_TIPO[c.ultima.tipo]} + ) : ( + Nessuna + )} +
+ )} +
diff --git a/apps/platforms/src/pages/longevity/gestionale/nuovo.astro b/apps/platforms/src/pages/longevity/gestionale/nuovo.astro new file mode 100644 index 0000000..20458ea --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/nuovo.astro @@ -0,0 +1,52 @@ +--- +// Il modulo per aprire un fascicolo nuovo: posta all'API del gestionale, che fa la +// validazione vera (pages/api/longevity/gestionale/clienti.ts). Qui c'è solo il form. +import Longevity from '../../../layouts/Longevity.astro'; + +export const prerender = false; + +const errore = Astro.url.searchParams.get('errore'); +--- + +
+

Nuovo cliente

+

Nome, cognome e sesso bastano per aprire il fascicolo: il resto si completa dopo.

+
+ + {errore &&

{errore}

} + +
+
+ + +
+ Sesso + + +
+ + + +
+ +
+ Annulla + +
+
+
diff --git a/apps/platforms/src/pages/longevity/index.astro b/apps/platforms/src/pages/longevity/index.astro index 34516da..e33e3fb 100644 --- a/apps/platforms/src/pages/longevity/index.astro +++ b/apps/platforms/src/pages/longevity/index.astro @@ -1,7 +1,12 @@ --- -// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al -// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath -// e canAccessAdminPath in src/lib/auth.ts). +// Punto di ingresso della piattaforma: smista per ruolo, non è un indice proprio. +// Il trainer e l'admin lavorano nel gestionale (Unità 2b, #2); il cliente resta sulla +// scorciatoia al proprio referto, come prima. La protezione della rotta è già del +// middleware (isProtectedPath e canAccessPath in src/lib/auth.ts): qui arriva solo chi è +// già autenticato su una delle due sezioni. export const prerender = false; + +const ruolo = Astro.locals.user?.role; +if (ruolo === 'admin' || ruolo === 'trainer') return Astro.redirect('/longevity/gestionale'); return Astro.redirect('/longevity/io'); --- diff --git a/apps/platforms/src/pages/longevity/io.astro b/apps/platforms/src/pages/longevity/io.astro index 7adb7d4..e03e8c0 100644 --- a/apps/platforms/src/pages/longevity/io.astro +++ b/apps/platforms/src/pages/longevity/io.astro @@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;

InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni raccontano come cambi, non solo come stai oggi. - Compila di nuovo il questionario. + Guarda il tuo andamento nel tempo o + compila di nuovo il questionario.

diff --git a/apps/platforms/src/pages/longevity/progressione.astro b/apps/platforms/src/pages/longevity/progressione.astro new file mode 100644 index 0000000..b95d4b2 --- /dev/null +++ b/apps/platforms/src/pages/longevity/progressione.astro @@ -0,0 +1,47 @@ +--- +// L'andamento nel tempo del cliente: una sessione per punto, i punteggi congelati che +// quella sessione ha davvero. Stesso modello di io.astro: la pagina non interroga il +// database da sola (c'è un test che lo verifica, referto.test.ts, esteso a tutte le pagine +// sotto src/pages/longevity/), il codice cliente arriva da codicePerUtenteLoggato +// (anagrafica.ts, l'unico modulo autorizzato a toccare identity), i punti da progressioneDi +// (progressione.ts, che legge SOLO score — mai un ricalcolo). +import Longevity from '../../layouts/Longevity.astro'; +import Progressione from '../../components/longevity/Progressione'; +import { getLongevityDb } from '../../lib/longevity/db'; +import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica'; +import { progressioneDi } from '../../lib/longevity/progressione'; + +export const prerender = false; + +const user = Astro.locals.user!; +const codice = codicePerUtenteLoggato(user.id); +const db = getLongevityDb(); +const punti = codice ? progressioneDi(db, codice) : []; +--- + + {!codice && ( +
+

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

+
+ )} + + {codice && punti.length < 2 && ( +
+

Serve almeno un secondo check-up o questionario per vedere un andamento.

+

Torna al tuo referto.

+
+ )} + + {codice && punti.length >= 2 && ( +
+
+

Il tuo andamento

+ +
+ +
+

Torna al tuo referto.

+
+
+ )} +
diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index daaac5c..d3e4470 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -100,6 +100,22 @@ .lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } .lg-radar__lista li { margin-bottom: 2px; } +/* --- l'andamento nel tempo: il grafico vive nell'isola React (Progressione.tsx), + qui solo il testo introduttivo e la tabella per asse sotto al grafico. --- */ +.lg-progressione { display: flex; flex-direction: column; gap: 20px; } +.lg-progressione__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); } +.lg-progressione__grafico { width: 100%; } +.lg-progressione__tabella { overflow-x: auto; } +.lg-progressione__table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.lg-progressione__table th, .lg-progressione__table td { + padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--lg-line); + white-space: nowrap; +} +.lg-progressione__table th:first-child, .lg-progressione__table td:first-child { + text-align: left; color: var(--c-heading); font-weight: 500; +} +.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; } + /* --- 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; } @@ -116,6 +132,10 @@ .lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; } .lg-field > span { color: var(--c-heading); font-weight: 500; } .lg-field input[type="number"], +.lg-field input[type="text"], +.lg-field input[type="date"], +.lg-field input[type="email"], +.lg-field input[type="tel"], .lg-field textarea { border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface); } @@ -135,6 +155,79 @@ } .lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; } .lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; } +/* Il link "Annulla" accanto al bottone di invio (nuovo.astro, checkup.astro): stesso rigo, + peso visivo minore — non compete con l'azione che porta avanti il modulo. */ +.lg-form__nav a { align-self: center; font-size: .85rem; color: var(--c-text-light); text-decoration: none; } +.lg-form__nav a:hover { color: var(--c-heading); } + +/* --- il gestionale del trainer (Unità 2b, #2): barra laterale sul modello di + StressIndex.astro, ma nella palette di Longevity — niente sfondo scuro con colori + letterali, solo i token già ammessi qui. --- */ +.lg[data-variante="gestionale"] { justify-content: flex-start; align-items: flex-start; } + +.lg-side { + width: 240px; flex: 0 0 240px; height: 100vh; position: sticky; top: 0; overflow-y: auto; + background: var(--c-bg-alt); border-right: 1px solid var(--lg-line); + padding: 28px 20px 40px; display: flex; flex-direction: column; +} +.lg-brand { + display: block; font-family: var(--font-heading); font-size: 1.15rem; font-weight: 600; + letter-spacing: .06em; text-transform: uppercase; color: var(--c-heading); text-decoration: none; line-height: 1.15; +} +.lg-brand .eyebrow { display: block; font-size: .68rem; letter-spacing: .18em; color: var(--c-accent); margin-bottom: 5px; } +.lg-home { + display: inline-block; margin: 16px 0 22px; font-family: var(--font-heading); font-size: .66rem; + font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light); + text-decoration: none; border-bottom: 1px solid transparent; +} +.lg-home:hover { color: var(--c-heading); border-bottom-color: var(--c-accent); } + +.lg-nav { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid var(--lg-line); padding-top: 16px; } +.lg-nav__link { + padding: 9px 12px; text-decoration: none; font-family: var(--font-heading); font-size: .72rem; + font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--c-text-light); + border-left: 3px solid transparent; +} +.lg-nav__link:hover { color: var(--c-heading); background: var(--c-bg); } +.lg-nav__link.is-active { color: var(--c-heading); background: var(--lg-surface); border-left-color: var(--c-accent); } +.lg-nav__link--referto { margin-top: 16px; border-top: 1px dashed var(--lg-tratteggio); padding-top: 16px; } + +.lg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; } + +/* intestazione di pagina con un'azione a destra (es. "Nuovo cliente", "Nuovo check-up") */ +.lg-head--azioni { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } + +/* bottone pieno per un link che porta a un'azione (non un