13 Commits

Author SHA1 Message Date
Adriano 6cbb205064 Merge branch 'lavoro/goldrake-i8' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 8579199115 Merge branch 'lavoro/goldrake-i7' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano d2b508e8ac Merge branch 'lavoro/goldrake-i5' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 948068b312 longevity: index.astro smista per ruolo, test del gestionale
pages/longevity/index.astro: trainer e admin → /longevity/gestionale, cliente →
/longevity/io come prima (l'assert esistente sul redirect a /longevity/io resta
valido, il ramo cliente lo chiama ancora testualmente).
tests/longevity/pagine.test.ts: le quattro pagine del gestionale esistono, usano
variante="gestionale", non toccano il DB direttamente e non nominano il database
anagrafico riservato; index.astro smista per ruolo.

Refs #2
2026-09-26 13:05:52 +00:00
Adriano 9375a0380d longevity: pagina del gestionale — modulo di check-up manuale
pages/longevity/gestionale/cliente/[code]/checkup.astro: campi da campiDelCheckup,
raggruppati per asse, etichetta e unità, input inputmode="decimal" tutti facoltativi
(la virgola decimale e i campi vuoti li gestisce l'endpoint). Invio verso
/api/longevity/gestionale/checkup.

Refs #2
2026-09-26 13:05:47 +00:00
Adriano 179aeb442a longevity: pagina del gestionale — scheda cliente (referto, sessioni, fuori range)
pages/longevity/gestionale/cliente/[code].astro: anagrafica, il referto dell'ultima
sessione (stesso Radar/MacroScore di io.astro, qui per il trainer), la tabella delle
sessioni e le misure fuori range (colore --lg-fuori-range). Un codice inesistente è
404. Gli esiti ?salvato/?fuori/?errore dell'API di check-up sono mostrati qui.

Refs #2
2026-09-26 13:05:42 +00:00
Adriano fd56afdf44 longevity: pagine del gestionale — elenco clienti e apertura fascicolo
pages/longevity/gestionale/index.astro: tabella clienti (codice, cognome nome, età,
accesso, data e tipo dell'ultima sessione via sessioniDi per cliente — va bene finché
i fascicoli sono pochi, non pensata per scalare).
pages/longevity/gestionale/nuovo.astro: form verso /api/longevity/gestionale/clienti,
sesso a radio (non <select>: la parola inglese avrebbe fatto scattare il controllo
anti-SQL di referto.test.ts, che cerca \bSELECT\b senza distinguere l'HTML).

Refs #2
2026-09-26 13:05:38 +00:00
Adriano 8ee2c8a2b4 longevity: layout e stile del gestionale — barra laterale, tabelle, bottone d'azione
Variante "gestionale" di Longevity.astro (finora dichiarata e non implementata):
barra laterale sul modello di StressIndex.astro, con le voci Clienti, Nuovo cliente
e un link "Il mio referto". CSS nuovo in longevity.css, solo classi lg-* e solo i
colori già ammessi (nessun colore letterale nuovo: il bianco del testo sul bottone
pieno riusa var(--lg-surface), non un #fff aggiuntivo).

Refs #2
2026-09-26 13:05:32 +00:00
Adriano 5dc6b85d52 longevity: pagina /longevity/progressione, grafico e tabella dell'andamento
Progressione.tsx: una linea per macro-score (connectNulls=false: un dato
mancante è un buco, non una linea inventata), tratteggio e spessore a
distinguere le serie invece di un colore nuovo, riga verticale tratteggiata
sui cambi di versione; sotto, la tabella per asse. Colori identici a
Radar.tsx, nessuno nuovo.

progressione.astro: stesso modello di io.astro (codicePerUtenteLoggato,
nessuna query diretta); sotto i due punti mostra il messaggio che ne serve
almeno uno in più. io.astro linka la pagina nuova.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:57 +00:00
Adriano 4cd911098d longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo
Una sessione per punto (solo quelle con righe in score), i punteggi congelati
da leggiScore, e cambioVersione quando model_version o quest_version (non
nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con
quest_version NULL, non innesca né nasconde un cambio.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:50 +00:00
Adriano 922ab40eb2 longevity: verificaMisura, misureDaVerificare, endpoint di verifica dal gestionale
misure.ts: misureDaVerificare(db, clientCode) legge le misure fuori range mai
verificate; verificaMisura(db, {misuraId, esito, operatore}) conferma o scarta.
La conferma ricalcola e ricongela lo score (salvaScore + calcolaSessione) per
la sessione della misura e ogni sessione successiva dello stesso cliente, che
puo' averla ereditata via misureValideAlla; lo scarto non tocca score. Rifiuta
misura inesistente, gia' nel range, o gia' verificata.

pages/api/longevity/gestionale/verifica.ts: POST da form (misura_id, esito),
operatore dalla sessione (locals.user.username). Il client_code del redirect
si legge dalla misura nel database, mai dal form.

tests/longevity/verifica-misure.test.ts: migrazione (ALTER TABLE su un
longevity.db vecchio, idempotente), conferma che ricalcola la sessione e le
successive con sessioniRicalcolate, scarto che non scrive in score, i rifiuti
(doppia verifica, misura nel range, id inesistente), canAccessPath per
cliente/trainer sulla nuova rotta.

Suite: 219 passed (212 + 7 nuovi). tsc --noEmit: 0 errori.

Refs #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:16 +00:00
Adriano 77829b2d12 longevity: crea l'accesso al sito per un cliente che ha già il fascicolo
accessoDi/collegaAccesso in anagrafica.ts (e le varianti sul singleton
accessoDelCliente/collegaAccessoCliente) leggono e scrivono il legame
clienti.user_id restando l'unico punto che apre identity. accesso.ts
aggiunge creaAccessoCliente: crea l'utente cliente, lo collega, e se il
collegamento fallisce cancella l'utente appena creato (compensazione,
due database, stesso schema di creaCliente). Nuova pagina del
gestionale pages/longevity/gestionale/cliente/[code]/accesso.astro:
genera username e password una volta sola, niente redirect, no-store.

Refs #8
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:00:48 +00:00
Adriano f096807ed6 longevity: schema misure con verifica/verificata_da/verificata_at + migrazione
Aggiunge le tre colonne allo schema (db.ts) e una funzione migra() idempotente
che le aggiunge via ALTER TABLE a un longevity.db esistente (PRAGMA table_info
per capire cosa manca): CREATE TABLE IF NOT EXISTS non tocca una tabella gia'
creata, e in produzione un longevity.db esiste dal 03/09/2026.

misureValideAlla (motore/index.ts) lascia entrare anche le misure fuori range
confermate (verifica = 'confermata'), non solo quelle nel range.

Refs #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:58:25 +00:00
22 changed files with 1778 additions and 22 deletions
@@ -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<string, string | number | null> = { data: p.data };
for (const nome of nomiMacro) riga[nome] = p.macro[nome];
return riga;
});
const cambi = punti.filter((p) => p.cambioVersione !== null);
return (
<div className="lg-progressione">
<p className="lg-progressione__intro">
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.
</p>
<div className="lg-progressione__grafico">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={dati} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid stroke={LINEA} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="data" stroke={TESTO_CHIARO} fontSize={11} tickLine={false} />
<YAxis domain={[0, 100]} stroke={TESTO_CHIARO} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ border: `1px solid ${LINEA}`, borderRadius: 0, fontSize: 12 }}
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
/>
<Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />
{cambi.map((p) => (
<ReferenceLine
key={p.sessioneId}
x={p.data}
stroke={TRATTEGGIO}
strokeDasharray="4 3"
label={{ value: p.cambioVersione ?? '', position: 'top', fill: TESTO_CHIARO, fontSize: 10 }}
/>
))}
{nomiMacro.map((nome, i) => {
const stile = STILI_SERIE[i % STILI_SERIE.length];
return (
<Line
key={nome}
type="monotone"
dataKey={nome}
name={etichetta(nome)}
stroke={ACCENTO}
strokeWidth={stile.width}
strokeDasharray={stile.dash}
dot={{ r: 3 }}
connectNulls={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
{/* 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. */}
<div className="lg-progressione__tabella">
<table className="lg-progressione__table">
<thead>
<tr>
<th></th>
{punti.map((p) => (
<th key={p.sessioneId} className="lg-num">{p.data}</th>
))}
</tr>
</thead>
<tbody>
{nomiAssi.map((nome) => (
<tr key={nome}>
<td>{nome}</td>
{punti.map((p) => {
const v = p.assi[nome];
return (
<td key={p.sessioneId} className="lg-num">
{v !== null && v !== undefined ? formattaNumero(v) : '—'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+42 -3
View File
@@ -5,8 +5,8 @@
// dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro // 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 // volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno
// strumento di lavoro. Il layout prevede due forme: // strumento di lavoro. Il layout prevede due forme:
// - "referto": una colonna che scorre, senza navigazione (questo task) // - "referto": una colonna che scorre, senza navigazione (il cliente)
// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale) // - "gestionale": barra laterale sul modello di StressIndex.astro (il trainer, Unità 2b #2)
import '@fontsource/montserrat/400.css'; import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css'; import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css'; import '@fontsource/montserrat/600.css';
@@ -19,10 +19,18 @@ interface Props {
title: string; title: string;
/** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */ /** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */
crumbs?: string[]; 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'; variante?: 'referto' | 'gestionale';
} }
const { title, crumbs = [], variante = 'referto' } = Astro.props; 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' },
];
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -34,6 +42,36 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props;
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head> </head>
<body class="lg" data-variante={variante}> <body class="lg" data-variante={variante}>
{variante === 'gestionale' ? (
<>
<input type="checkbox" id="lg-menu" class="lg-menu-toggle" hidden />
<label for="lg-menu" class="lg-menu-btn" aria-label="Apri il menu">☰ Menu</label>
<aside class="lg-side">
<a class="lg-brand" href="/longevity/gestionale">
<span class="eyebrow">InsanityLab</span>
Longevity
</a>
<a class="lg-home" href="/piattaforme">Piattaforme</a>
<nav class="lg-nav">
{NAV_GESTIONALE.map((item) => (
<a href={item.href} class:list={['lg-nav__link', { 'is-active': path === item.href }]}>
{item.label}
</a>
))}
<a class="lg-nav__link lg-nav__link--referto" href="/longevity/io">Il mio referto</a>
</nav>
</aside>
<div class="lg-body">
<header class="lg-top">
<p class="lg-top__crumbs">
<strong>Longevity</strong>
{crumbs.map((c) => <span> / {c}</span>)}
</p>
</header>
<main class="lg-main"><slot /></main>
</div>
</>
) : (
<div class="lg-shell"> <div class="lg-shell">
<header class="lg-top"> <header class="lg-top">
<p class="lg-top__crumbs"> <p class="lg-top__crumbs">
@@ -43,5 +81,6 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props;
</header> </header>
<main class="lg-main"><slot /></main> <main class="lg-main"><slot /></main>
</div> </div>
)}
</body> </body>
</html> </html>
@@ -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 };
}
@@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null {
return codicePerUtente(getIdentityDb(), userId); 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. */ /** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number { export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita); const n = new Date(dataNascita);
+38
View File
@@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure (
fonte TEXT NOT NULL CHECK (fonte IN fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')), ('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); 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; 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. * ⚠️ 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 { export function createLongevityDb(path?: string): Database.Database {
const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
migra(db);
seedRegistro(db); seedRegistro(db);
seedPesi(db, MODEL_VERSION); seedPesi(db, MODEL_VERSION);
return db; return db;
+103
View File
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { esisteTest } from './registro'; import { esisteTest } from './registro';
import { calcolaSessione, salvaScore } from './motore';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
@@ -72,3 +73,105 @@ export function registraMisure(
return { scritte: misure.length, fuoriRange }; 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 };
}
@@ -40,8 +40,8 @@
* applicarla, e restituisce `null` se il registro (dato modificabile senza * applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude * `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma * `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer
* (punto aperto in §12). * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`).
*/ */
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
@@ -156,9 +156,11 @@ export const FINESTRA_GIORNI: number | null = null;
* *
* Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della * 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 * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da
* una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7): * una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`,
* quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate, * colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel
* non tolto del tutto. * 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<string, number> { export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const sessione = db.prepare( const sessione = db.prepare(
@@ -170,7 +172,7 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map
`SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data
FROM misure m FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id JOIN sessioni s2 ON s2.id = m.sessione_id
WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL 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 <= ?)) AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?))
ORDER BY s2.data DESC, s2.id DESC, m.id DESC` ORDER BY s2.data DESC, s2.id DESC, m.id DESC`
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as
@@ -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<nome, number|null>`: 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<string, number | null>;
macro: Record<string, number | null>;
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<string, number | null> = {};
for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null;
const macro: Record<string, number | null> = {};
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;
}
@@ -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}`);
};
@@ -0,0 +1,151 @@
---
// 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, misureFuoriRange } from '../../../../lib/longevity/gestionale';
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 fuoriRange = misureFuoriRange(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');
---
<Longevity
title={`${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`]}
variante="gestionale"
>
<div class="lg-head lg-head--azioni">
<div>
<h1>{cliente.cognome} {cliente.nome}</h1>
<p>
{cliente.client_code} · {eta !== null ? `${eta} anni` : 'età non registrata'} ·
accesso {cliente.ha_accesso ? 'attivo' : 'non ancora attivato'}
</p>
</div>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup`}>Nuovo check-up</a>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
{fuoriCount && (
<p class="lg-misura--fuori-range">
{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di
plausibilità: verificale più sotto.
</p>
)}
<div class="lg-card">
<p class="lg-score__label">Anagrafica</p>
<p>Email: {cliente.email ?? '—'}</p>
<p>Telefono: {cliente.telefono ?? '—'}</p>
<p>Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}</p>
</div>
{!referto && (
<div class="lg-card lg-card--incompleto">
<p>Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
</div>
)}
<div class="lg-card">
<p class="lg-score__label">Sessioni</p>
{sessioni.length === 0 ? (
<p class="lg-muted">Nessuna sessione.</p>
) : (
<table class="lg-table">
<thead>
<tr><th>Data</th><th>Tipo</th><th>Operatore</th><th>Misure</th><th>Fitness Age</th></tr>
</thead>
<tbody>
{sessioni.map((s) => (
<tr>
<td>{formattaData(s.data)}</td>
<td>{ETICHETTA_TIPO[s.tipo]}</td>
<td>{s.operatore ?? '—'}</td>
<td>{s.numeroMisure}</td>
<td>{s.fitnessAge !== null ? formattaNumero(s.fitnessAge) : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{fuoriRange.length > 0 && (
<div class="lg-card">
<p class="lg-score__label">Misure fuori range</p>
<table class="lg-table">
<thead><tr><th>Data</th><th>Test</th><th>Valore</th></tr></thead>
<tbody>
{fuoriRange.map((m) => (
<tr>
<td>{formattaData(m.data)}</td>
<td>{m.etichetta}</td>
<td class="lg-misura--fuori-range">
{m.valore !== null ? formattaNumero(m.valore) : '—'}{m.unita ? ` ${m.unita}` : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Longevity>
@@ -0,0 +1,82 @@
---
// 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;
}
---
<Longevity title="Crea accesso" crumbs={['Gestionale', code, 'Accesso']} variante="gestionale">
{!fascicolo && (
<div class="lg-card">
<p>Nessun fascicolo con codice <strong>{code}</strong>.</p>
</div>
)}
{fascicolo && fascicolo.userId !== null && (
<div class="lg-card">
<p>Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.</p>
</div>
)}
{fascicolo && fascicolo.userId === null && creato && (
<div class="lg-card">
<p><strong>Accesso creato.</strong> Comunicala al cliente: non verrà mostrata di nuovo.</p>
<p>Utente: <strong>{creato.username}</strong></p>
<p>Password: <strong>{creato.password}</strong></p>
</div>
)}
{fascicolo && fascicolo.userId === null && !creato && (
<form method="post" class="lg-form lg-form--accesso">
{errore && <p class="lg-form__errore">{errore}</p>}
<div class="lg-field">
<span>Nome utente</span>
<input
type="text"
name="username"
required
minlength="3"
maxlength="64"
pattern="[a-z0-9._@-]+"
value={fascicolo.email ?? ''}
/>
</div>
<div class="lg-form__nav">
<button type="submit">Crea accesso</button>
</div>
</form>
)}
<style>
/* .lg-field in longevity.css stila solo input[type="number"] e textarea (li usa il
questionario): qui serve un campo di testo, quindi lo stile arriva da qui. */
.lg-form--accesso .lg-field input[type="text"] {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
}
</style>
</Longevity>
@@ -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');
---
<Longevity
title={`Check-up — ${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Nuovo check-up']}
variante="gestionale"
>
<div class="lg-head">
<h1>Nuovo check-up</h1>
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato oggi.</p>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
<form class="lg-form" method="post" action="/api/longevity/gestionale/checkup">
<input type="hidden" name="client_code" value={cliente.client_code} />
<label class="lg-field">
<span>Data</span>
<input type="date" name="data" value={oggi} required />
</label>
{gruppi.map((g) => (
<section class="lg-form__blocco">
<h2>{g.asse}</h2>
{g.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} />
</label>
))}
</section>
))}
<div class="lg-form__nav">
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
<button type="submit">Salva check-up</button>
</div>
</form>
</Longevity>
@@ -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,
}));
---
<Longevity title="Clienti" crumbs={['Clienti']} variante="gestionale">
<div class="lg-head lg-head--azioni">
<div>
<h1>Clienti</h1>
<p>{righe.length} {righe.length === 1 ? 'fascicolo' : 'fascicoli'}.</p>
</div>
<a class="lg-btn" href="/longevity/gestionale/nuovo">Nuovo cliente</a>
</div>
{righe.length === 0 ? (
<p class="lg-muted">Nessun cliente ancora. <a href="/longevity/gestionale/nuovo">Apri il primo fascicolo</a>.</p>
) : (
<table class="lg-table">
<thead>
<tr>
<th>Codice</th>
<th>Cognome e nome</th>
<th>Età</th>
<th>Accesso</th>
<th>Ultima sessione</th>
</tr>
</thead>
<tbody>
{righe.map((c) => (
<tr>
<td><a href={`/longevity/gestionale/cliente/${c.client_code}`}>{c.client_code}</a></td>
<td>{c.cognome} {c.nome}</td>
<td>{c.eta ?? '—'}</td>
<td>{c.ha_accesso ? 'Sì' : 'No'}</td>
<td>
{c.ultima ? (
<>{formattaData(c.ultima.data)} — {ETICHETTA_TIPO[c.ultima.tipo]}</>
) : (
<span class="lg-muted">Nessuna</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Longevity>
@@ -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');
---
<Longevity title="Nuovo cliente" crumbs={['Clienti', 'Nuovo cliente']} variante="gestionale">
<div class="lg-head">
<h1>Nuovo cliente</h1>
<p>Nome, cognome e sesso bastano per aprire il fascicolo: il resto si completa dopo.</p>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
<form class="lg-form" method="post" action="/api/longevity/gestionale/clienti">
<section class="lg-form__blocco">
<label class="lg-field">
<span>Nome</span>
<input type="text" name="nome" required />
</label>
<label class="lg-field">
<span>Cognome</span>
<input type="text" name="cognome" required />
</label>
<fieldset class="lg-field lg-field--consenso">
<legend>Sesso</legend>
<label><input type="radio" name="sesso" value="F" required /> F</label>
<label><input type="radio" name="sesso" value="M" required /> M</label>
</fieldset>
<label class="lg-field">
<span>Data di nascita</span>
<input type="date" name="data_nascita" />
</label>
<label class="lg-field">
<span>Email</span>
<input type="email" name="email" />
</label>
<label class="lg-field">
<span>Telefono</span>
<input type="tel" name="telefono" />
</label>
</section>
<div class="lg-form__nav">
<a href="/longevity/gestionale">Annulla</a>
<button type="submit">Apri il fascicolo</button>
</div>
</form>
</Longevity>
@@ -1,7 +1,12 @@
--- ---
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al // Punto di ingresso della piattaforma: smista per ruolo, non è un indice proprio.
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath // Il trainer e l'admin lavorano nel gestionale (Unità 2b, #2); il cliente resta sulla
// e canAccessAdminPath in src/lib/auth.ts). // 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; 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'); return Astro.redirect('/longevity/io');
--- ---
+2 -1
View File
@@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
<p> <p>
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
raccontano come cambi, non solo come stai oggi. raccontano come cambi, non solo come stai oggi.
<a href="/longevity/questionario">Compila di nuovo il questionario</a>. <a href="/longevity/progressione">Guarda il tuo andamento nel tempo</a> o
<a href="/longevity/questionario">compila di nuovo il questionario</a>.
</p> </p>
</section> </section>
</div> </div>
@@ -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) : [];
---
<Longevity title="Andamento nel tempo" crumbs={['Andamento nel tempo']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && punti.length < 2 && (
<div class="lg-card lg-card--incompleto">
<p>Serve almeno un secondo check-up o questionario per vedere un andamento.</p>
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
</div>
)}
{codice && punti.length >= 2 && (
<div class="lg-referto">
<section class="lg-card">
<h2>Il tuo andamento</h2>
<Progressione client:load punti={punti} />
</section>
<section class="lg-card">
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
</section>
</div>
)}
</Longevity>
+93
View File
@@ -100,6 +100,22 @@
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); } .lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
.lg-radar__lista li { margin-bottom: 2px; } .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 --- */ /* --- le carte dei macro-score --- */
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; } .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; } .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 { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
.lg-field > span { color: var(--c-heading); font-weight: 500; } .lg-field > span { color: var(--c-heading); font-weight: 500; }
.lg-field input[type="number"], .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 { .lg-field textarea {
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface); 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: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; } .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 <button> di form) */
.lg-btn {
display: inline-block; font-family: var(--font-heading); font-size: .76rem; font-weight: 600;
letter-spacing: .06em; text-transform: uppercase; padding: 10px 20px; border: 1px solid var(--c-accent);
background: var(--c-accent); color: var(--lg-surface); text-decoration: none;
}
.lg-btn:hover { background: var(--c-accent-dark); border-color: var(--c-accent-dark); }
/* tabelle del gestionale: elenco clienti, sessioni, misure fuori range */
.lg-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.lg-table th, .lg-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--lg-line); }
.lg-table th {
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--c-text-light);
}
.lg-table tbody tr:hover { background: var(--c-bg-alt); }
.lg-table a { color: var(--c-heading); font-weight: 600; text-decoration: none; }
.lg-table a:hover { color: var(--c-accent-dark); }
.lg-menu-btn { display: none; }
@media (max-width: 900px) {
.lg[data-variante="gestionale"] { display: block; }
.lg-menu-btn {
display: block; position: sticky; top: 0; z-index: 20; cursor: pointer;
background: var(--lg-surface); color: var(--c-heading); border-bottom: 1px solid var(--lg-line);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; padding: 14px 20px;
}
.lg-side { display: none; width: auto; flex: none; height: auto; position: static; }
.lg-menu-toggle:checked ~ .lg-side { display: flex; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; } .lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createDb } from '../../src/lib/db';
import { login } from '../../src/lib/auth';
import { createIdentityDb, createLongevityDb } from '../../src/lib/longevity/db';
import { creaCliente, accessoDi, collegaAccesso } from '../../src/lib/longevity/anagrafica';
import { creaAccessoCliente, type DipendenzeAccesso } from '../../src/lib/longevity/accesso';
// Doppio delle dipendenze su DB in memoria: come in anagrafica.test.ts, connessioni proprie
// passate a mano, mai il singleton reale — questo è l'unico modo in cui accesso.ts si
// prova senza toccare data/identity.db o data/piattaforme.db.
function dipendenzeDiTest(identity: ReturnType<typeof createIdentityDb>, utenti: ReturnType<typeof createDb>): DipendenzeAccesso {
return {
utenti,
accessoDi: (code) => accessoDi(identity, code),
collega: (code, userId) => collegaAccesso(identity, code, userId),
};
}
function fascicoloPronto() {
const identity = createIdentityDb(':memory:');
const longevity = createLongevityDb(':memory:');
const utenti = createDb(':memory:');
const code = creaCliente(identity, longevity, { nome: 'Mario', cognome: 'Rossi', sesso: 'M' });
return { identity, longevity, utenti, code };
}
describe('creaAccessoCliente', () => {
it('crea l utente cliente, lo collega, e la password permette il login', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps = dipendenzeDiTest(identity, utenti);
const esito = creaAccessoCliente(deps, { code, username: 'MarioRossi' });
expect(esito.ok).toBe(true);
if (!esito.ok) return;
expect(esito.username).toBe('mariorossi'); // normalizzato minuscolo
const riga = utenti.prepare('SELECT id, password_hash, role FROM users WHERE username = ?').get('mariorossi') as
{ id: number; password_hash: string; role: string };
expect(riga.role).toBe('cliente');
// la password torna in chiaro nell'esito, ma non è quella salvata: quella è l'hash.
expect(riga.password_hash).not.toBe(esito.password);
// il collegamento è avvenuto davvero: accessoDi lo ritrova.
const dopo = accessoDi(identity, code);
expect(dopo?.userId).toBe(riga.id);
// e la password restituita fa davvero login.
const token = login(utenti, 'mariorossi', esito.password);
expect(token).not.toBeNull();
});
it('fascicolo già con un accesso: errore, nessun utente creato', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps = dipendenzeDiTest(identity, utenti);
const primo = creaAccessoCliente(deps, { code, username: 'uno' });
expect(primo.ok).toBe(true);
const secondo = creaAccessoCliente(deps, { code, username: 'due' });
expect(secondo.ok).toBe(false);
const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number };
expect(utentiDopo.n).toBe(1); // solo il primo, "due" non è mai stato creato
});
it('codice inesistente: errore', () => {
const { identity, utenti } = fascicoloPronto();
const deps = dipendenzeDiTest(identity, utenti);
const esito = creaAccessoCliente(deps, { code: 'ISL-9999', username: 'fantasma' });
expect(esito.ok).toBe(false);
});
it('username già esistente: errore, niente collegato', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps = dipendenzeDiTest(identity, utenti);
utenti.prepare("INSERT INTO users (username, password_hash, role) VALUES ('occupato', 'x', 'admin')").run();
const esito = creaAccessoCliente(deps, { code, username: 'occupato' });
expect(esito.ok).toBe(false);
const fascicolo = accessoDi(identity, code);
expect(fascicolo?.userId).toBeNull(); // resta senza accesso
});
it('username non valido (maiuscole non ammesse dopo la normalizzazione, o troppo corto): errore', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps = dipendenzeDiTest(identity, utenti);
const corto = creaAccessoCliente(deps, { code, username: 'ab' });
expect(corto.ok).toBe(false);
const conSpazio = creaAccessoCliente(deps, { code, username: 'ha spazi' });
expect(conSpazio.ok).toBe(false);
const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number };
expect(utentiDopo.n).toBe(0);
});
it('se il collegamento fallisce, l utente appena creato viene cancellato (compensazione)', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps: DipendenzeAccesso = {
utenti,
accessoDi: (c) => accessoDi(identity, c),
collega: () => false, // simula un collegamento rifiutato (es. fascicolo cambiato nel frattempo)
};
const esito = creaAccessoCliente(deps, { code, username: 'sfortunato' });
expect(esito.ok).toBe(false);
const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato');
expect(rimasto).toBeUndefined(); // nessun utente orfano
});
it('se il collegamento lancia, l utente appena creato viene comunque cancellato', () => {
const { identity, utenti, code } = fascicoloPronto();
const deps: DipendenzeAccesso = {
utenti,
accessoDi: (c) => accessoDi(identity, c),
collega: () => { throw new Error('identity irraggiungibile'); },
};
const esito = creaAccessoCliente(deps, { code, username: 'sfortunato2' });
expect(esito.ok).toBe(false);
const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato2');
expect(rimasto).toBeUndefined();
});
it('rete secondaria: la pagina del gestionale non mette la password in cache e non nomina identity', () => {
const path = join(process.cwd(), 'src/pages/longevity/gestionale/cliente/[code]/accesso.astro');
const src = readFileSync(path, 'utf8');
expect(src).toContain('no-store');
expect(src).not.toContain('Identity');
expect(src).not.toContain('.prepare(');
});
});
@@ -93,4 +93,57 @@ describe('il guscio della piattaforma', () => {
const src = readFileSync(p, 'utf8'); const src = readFileSync(p, 'utf8');
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/); expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
}); });
it('la pagina dell andamento non interroga il database e usa codicePerUtenteLoggato', () => {
// Rete secondaria alla stessa prova generica di referto.test.ts ("nessuna pagina
// interroga il database da sola", che scandisce TUTTE le .astro sotto longevity/):
// qui si controlla in piu' che il codice cliente arrivi dalla via autorizzata, non da
// un'altra query o da un parametro della richiesta.
const p = join(process.cwd(), 'src/pages/longevity/progressione.astro');
expect(existsSync(p)).toBe(true);
const src = readFileSync(p, 'utf8');
expect(src).not.toMatch(/\.prepare\(/);
expect(src).toMatch(/codicePerUtenteLoggato/);
// Stessa rete secondaria di anagrafica.test.ts: nessuna giunzione identity/longevity
// in un file fuori dai due moduli autorizzati.
expect(src).not.toContain('Identity');
});
});
describe('il gestionale del trainer (Unità 2b)', () => {
const PAGINE_GESTIONALE = [
'src/pages/longevity/gestionale/index.astro',
'src/pages/longevity/gestionale/nuovo.astro',
'src/pages/longevity/gestionale/cliente/[code].astro',
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
];
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {
for (const p of PAGINE_GESTIONALE) {
expect(existsSync(join(process.cwd(), p))).toBe(true);
expect(leggi(p)).toMatch(/variante=["']gestionale["']/);
}
});
it('nessuna pagina del gestionale interroga il database direttamente', () => {
// Il gestionale legge sempre attraverso lib/longevity (anagrafica, gestionale, vista):
// una .prepare( dentro una .astro sarebbe una query scritta a mano nella pagina.
for (const p of PAGINE_GESTIONALE) {
expect(leggi(p)).not.toMatch(/\.prepare\(/);
}
});
it('nessuna pagina del gestionale nomina il database anagrafico riservato', () => {
for (const p of PAGINE_GESTIONALE) {
expect(leggi(p)).not.toMatch(/Identity/);
}
});
it('index.astro smista per ruolo: trainer e admin al gestionale, il cliente al proprio referto', () => {
const src = leggi('src/pages/longevity/index.astro');
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/gestionale['"`]/);
expect(src).toMatch(/trainer/);
expect(src).toMatch(/admin/);
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
});
}); });
@@ -0,0 +1,148 @@
// tests/longevity/progressione.test.ts
// L'andamento nel tempo: progressioneDi deve leggere SOLO ciò che è congelato in `score`
// (mai un ricalcolo, stessa regola di refertoDi) e marcare correttamente dove il modo di
// calcolare è cambiato (quest_version/model_version), senza farsi confondere da un
// check-up in mezzo a due questionari (quest_version NULL per un check-up).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaDalForm } from '../../src/lib/longevity/vista';
import { apriSessione } from '../../src/lib/longevity/misure';
import { calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
import { progressioneDi } from '../../src/lib/longevity/progressione';
const RISPOSTE_COMPLETE = {
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
};
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
/** Una sessione di check-up, calcolata e congelata come farebbe l'operatore in sala:
* nessuna domanda del questionario, quindi `quest_version` resta NULL (vedi db.ts). */
function aggiungiCheckup(db: ReturnType<typeof dbPronto>, data: string) {
const sessioneId = apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 35 });
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
return sessioneId;
}
/** Un questionario compilato per intero, via salvaDalForm — la stessa funzione che
* chiama l'endpoint reale (vedi salvataggio-e2e.test.ts): calcola e congela da sola. */
function aggiungiQuestionario(db: ReturnType<typeof dbPronto>, data: string) {
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data, eta: 35,
risposte: RISPOSTE_COMPLETE, consensoSanitario: 'Si', liberi: {},
});
if (!esito.ok) throw new Error(esito.errore);
return esito.sessioneId;
}
describe('progressioneDi: due questionari e un check-up', () => {
it('produce tre punti, in ordine cronologico', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const s2 = aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(3);
expect(punti.map((p) => p.sessioneId)).toEqual([s1, s2, s3]);
expect(punti.map((p) => p.data)).toEqual(['2026-01-10', '2026-02-10', '2026-03-10']);
expect(punti[0].tipo).toBe('questionario');
expect(punti[1].tipo).toBe('checkup');
});
it('i valori coincidono con leggiScore, mai un ricalcolo', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
const punteggi = leggiScore(db, s1);
for (const [nome, p] of Object.entries(punteggi.assi)) {
expect(punti[0].assi[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
for (const [nome, p] of Object.entries(punteggi.macro)) {
expect(punti[0].macro[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
expect(punti[0].fitnessAge).toBe(punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null);
});
it('un asse senza dati sufficienti resta null, mai un numero inventato', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
// 'Forza & Struttura' non ha nessuna misura fisica in questa sessione (solo questionario).
expect(punti[0].assi['Forza & Struttura']).toBeNull();
});
it('quest_version NULL per il check-up', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[1].questVersion).toBeNull();
});
it('nessun cambio di versione: cambioVersione resta null su ogni punto dopo il primo', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[0].cambioVersione).toBeNull(); // primo punto: mai un "prima" da confrontare
expect(punti[1].cambioVersione).toBeNull(); // il check-up in mezzo
expect(punti[2].cambioVersione).toBeNull(); // il secondo questionario, stessa versione
});
it('un check-up fra due questionari alla stessa versione non produce un cambio', () => {
// Stesso scenario del test sopra, isolato: qui si verifica in particolare che il
// check-up (quest_version NULL) non "nasconda" né inventi un cambio fra i due
// questionari v1.0 che lo circondano.
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
const puntoCheckup = punti.find((p) => p.tipo === 'checkup')!;
const puntoSecondoQuest = punti.find((p) => p.sessioneId === s3)!;
expect(puntoCheckup.cambioVersione).toBeNull();
expect(puntoSecondoQuest.cambioVersione).toBeNull();
});
it('un cambio di quest_version produce il testo del cambio', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
// Nessuna seconda versione delle domande esiste davvero oggi (QUEST_VERSION è una sola
// costante): si simula il cambio scrivendo direttamente la riga congelata, come farebbe
// un domani un vero v1.1 del questionario.
db.prepare(`UPDATE score SET quest_version = 'v1.1' WHERE sessione_id = ?`).run(s3);
const punti = progressioneDi(db, 'ISL-0001');
const punto = punti.find((p) => p.sessioneId === s3)!;
expect(punto.cambioVersione).toMatch(/questionario v1\.0 → v1\.1/);
});
it('una sessione senza righe in score resta esclusa dalla progressione', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
// Sessione aperta ma mai calcolata: nessuna riga in score (consenso non dato, o un
// calcolo mai lanciato — non un caso teorico, vedi il commento in salvaDalForm).
apriSessione(db, { client_code: 'ISL-0001', data: '2026-02-01', tipo: 'checkup', eta_alla_data: 35 });
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(1);
});
});
@@ -0,0 +1,226 @@
// Issue #7: verifica delle misure fuori range (confermata/scartata), migrazione dello
// schema, e ricalcolo dei punteggi congelati che una conferma cambia.
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure, verificaMisura } from '../../src/lib/longevity/misure';
import { leggiScore, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
import { canAccessPath } from '../../src/lib/auth';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('migrazione: un longevity.db esistente riceve le tre colonne di verifica', () => {
it('aggiunge verifica/verificata_da/verificata_at via ALTER TABLE, senza perdere le righe', () => {
const dir = mkdtempSync(join(tmpdir(), 'lg-migra-test-'));
const path = join(dir, 'longevity.db');
try {
// Schema PRIMA di questa issue: la tabella misure senza le colonne di verifica —
// lo stesso longevity.db che gira in produzione dal 03/09/2026 (vedi commento su
// migra() in db.ts). Costruito a mano, non con createLongevityDb, che oggi crea
// gia' le colonne nuove.
// Le altre tabelle sono quelle vere (SCHEMA_LONGEVITY in db.ts, invariate da questa
// issue): solo `misure` qui e' la versione vecchia, senza le tre colonne. Con uno
// schema ridotto a mano seedRegistro/seedPesi (chiamati da createLongevityDb subito
// dopo la migrazione) fallirebbero su colonne che si aspettano e non trovano.
const vecchio = new Database(path);
vecchio.pragma('foreign_keys = ON');
vecchio.exec(`
CREATE TABLE soggetti (
client_code TEXT PRIMARY KEY,
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE registro_test (
test_id TEXT PRIMARY KEY,
etichetta TEXT NOT NULL,
unita TEXT,
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
curva TEXT,
params TEXT,
asse TEXT,
sotto_dominio TEXT,
range_min REAL,
range_max REAL,
attivo_da TEXT NOT NULL,
attivo_a TEXT,
note TEXT
);
CREATE TABLE sessioni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
eta_alla_data INTEGER,
operatore TEXT,
note TEXT,
quest_version TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE misure (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
valore_num REAL,
valore_txt TEXT,
unita TEXT,
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
vecchio.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
vecchio.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, attivo_da) VALUES ('q_ore_sonno','Ore di sonno','num','2026-01-01')`
).run();
vecchio.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001','2026-08-21','questionario')`).run();
vecchio.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte, fuori_range)
VALUES (1, 'ISL-0001', 'q_ore_sonno', 26, 'questionario', 1)`
).run();
vecchio.close();
const db = createLongevityDb(path);
const colonne = (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name);
expect(colonne).toEqual(expect.arrayContaining(['verifica', 'verificata_da', 'verificata_at']));
const riga = db.prepare(
`SELECT valore_num, fuori_range, verifica FROM misure WHERE test_id = 'q_ore_sonno'`
).get() as { valore_num: number; fuori_range: number; verifica: string | null };
expect(riga.valore_num).toBe(26);
expect(riga.fuori_range).toBe(1);
expect(riga.verifica).toBeNull();
db.close();
// Riaprirla una seconda volta (le colonne ci sono gia') non deve fallire: migra()
// deve saltare l'ALTER TABLE quando non serve, non ripeterlo alla cieca.
const db2 = createLongevityDb(path);
expect(() => db2.prepare(`SELECT verifica, verificata_da, verificata_at FROM misure`).all()).not.toThrow();
db2.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('verificaMisura: conferma', () => {
it('una misura fuori range confermata entra nello score, e la sessione successiva che la eredita viene ricalcolata', () => {
const db = dbPronto();
const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' });
registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); // fuori range (0-14)
salvaScore(db, s1, calcolaSessione(db, s1));
const s2 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-15', tipo: 'questionario' });
salvaScore(db, s2, calcolaSessione(db, s2));
// Baseline: 'questionario_sonno' pesa 0.20 nell'asse 'Recupero & Sistema Nervoso'
// (registro.ts, PESI_ASSE) e non e' coperto ne' da s1 (misura esclusa, fuori range non
// confermato) ne' da s2 (che la erediterebbe da s1 se fosse valida).
const asse = 'Recupero & Sistema Nervoso';
expect(leggiScore(db, s1).assi[asse].copertura).toBe(0);
expect(leggiScore(db, s2).assi[asse].copertura).toBe(0);
const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number };
const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' });
expect(esito.ok).toBe(true);
if (esito.ok) expect(esito.sessioniRicalcolate.sort((a, b) => a - b)).toEqual([s1, s2].sort((a, b) => a - b));
const rigaMisura = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as
{ verifica: string; verificata_da: string };
expect(rigaMisura.verifica).toBe('confermata');
expect(rigaMisura.verificata_da).toBe('nicola');
// Dopo la conferma la copertura sale a 0.20 (il peso di 'questionario_sonno') sia
// nella sessione della misura sia in quella successiva, che la eredita da
// misureValideAlla — senza aver ripetuto il test.
expect(leggiScore(db, s1).assi[asse].copertura).toBe(0.2);
expect(leggiScore(db, s2).assi[asse].copertura).toBe(0.2);
});
});
describe('verificaMisura: scarto', () => {
it('una misura scartata resta esclusa, e non scrive nessuna riga nuova in score', () => {
const db = dbPronto();
const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' });
registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
const primaN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n;
const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number };
const esito = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'nicola' });
expect(esito.ok).toBe(true);
if (esito.ok) expect(esito.sessioniRicalcolate).toEqual([]);
const dopoN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n;
expect(dopoN).toBe(primaN); // nessun ricalcolo per uno scarto
const rigaMisura = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string };
expect(rigaMisura.verifica).toBe('scartata');
// Ancora esclusa da misureValideAlla, esattamente come prima della verifica.
const dominio = calcolaSessione(db, s1);
expect(dominio.assi['Recupero & Sistema Nervoso'].copertura).toBe(0);
});
});
describe('verificaMisura: rifiuti', () => {
it('rifiuta una seconda verifica sulla stessa misura', () => {
const db = dbPronto();
const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' });
registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number };
const prima = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' });
expect(prima.ok).toBe(true);
const seconda = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'donata' });
expect(seconda.ok).toBe(false);
if (!seconda.ok) expect(seconda.errore).toMatch(/gia.*verificat/i);
// La seconda chiamata non ha scritto niente: resta la decisione della prima.
const riga = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as
{ verifica: string; verificata_da: string };
expect(riga.verifica).toBe('confermata');
expect(riga.verificata_da).toBe('nicola');
});
it('rifiuta una misura che e gia nel range: non c e niente da verificare', () => {
const db = dbPronto();
const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' });
registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 7.5 }]); // nel range 0-14
const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number };
const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' });
expect(esito.ok).toBe(false);
const riga = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string | null };
expect(riga.verifica).toBeNull(); // nessuna scrittura
});
it('rifiuta un id di misura inesistente', () => {
const db = dbPronto();
const esito = verificaMisura(db, { misuraId: 999999, esito: 'confermata', operatore: 'nicola' });
expect(esito.ok).toBe(false);
if (!esito.ok) expect(esito.errore).toMatch(/inesistente/);
});
});
describe('accesso: la verifica e roba da trainer, non da cliente', () => {
it('canAccessPath chiude /api/longevity/gestionale/verifica al cliente e la apre al trainer', () => {
expect(canAccessPath('cliente', '/api/longevity/gestionale/verifica')).toBe(false);
expect(canAccessPath('trainer', '/api/longevity/gestionale/verifica')).toBe(true);
});
});