Longevity: gestionale trainer, test fisici, progressione, verifica misure, accesso cliente (#1 #2 #5 #7 #8 #9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
dist/
|
||||
.astro/
|
||||
.env
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/opt/docker/insanitylab-apps/apps/platforms/node_modules
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
@@ -34,6 +42,36 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props;
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<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">
|
||||
<header class="lg-top">
|
||||
<p class="lg-top__crumbs">
|
||||
@@ -43,5 +81,6 @@ const { title, crumbs = [], variante = 'referto' } = Astro.props;
|
||||
</header>
|
||||
<main class="lg-main"><slot /></main>
|
||||
</div>
|
||||
)}
|
||||
</body>
|
||||
</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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, CampoQuestionario[]>();
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<string, number>, eta: number | null, sesso: Sesso
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
@@ -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<string, number> {
|
||||
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<string, number>();
|
||||
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<string, number> {
|
||||
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<string, number[]>();
|
||||
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<string, number> = {};
|
||||
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<string, number>(
|
||||
(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<string, number[]>();
|
||||
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<string, number> = {};
|
||||
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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string> {
|
||||
const righe = db.prepare(
|
||||
`SELECT DISTINCT rt.sotto_dominio AS sd
|
||||
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
|
||||
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
|
||||
AND rt.sotto_dominio IS NOT NULL`
|
||||
).all(sessioneId) as { sd: string }[];
|
||||
return new Set(righe.map((r) => r.sd));
|
||||
return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId)));
|
||||
}
|
||||
|
||||
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
|
||||
|
||||
@@ -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 `<input>` nascosto o un campo dismesso non
|
||||
* deve rompere il salvataggio del resto.
|
||||
*/
|
||||
export function misureDalForm(
|
||||
form: FormData,
|
||||
campiAttivi: Pick<VoceRegistro, 'test_id' | 'unita'>[]
|
||||
): { 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()}`);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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,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;
|
||||
---
|
||||
<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>
|
||||
<div>
|
||||
{!cliente.ha_accesso && (
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
|
||||
)}
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup`}>Nuovo check-up</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
|
||||
{messaggioVerifica && <p class="lg-card">{messaggioVerifica}</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>
|
||||
|
||||
{daVerificare.length > 0 && (
|
||||
<div class="lg-card">
|
||||
<p class="lg-score__label">Misure da verificare</p>
|
||||
<p class="lg-muted">
|
||||
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.
|
||||
</p>
|
||||
<table class="lg-table">
|
||||
<thead><tr><th>Data</th><th>Test</th><th>Valore</th><th>Range atteso</th><th>Decisione</th></tr></thead>
|
||||
<tbody>
|
||||
{daVerificare.map((m) => (
|
||||
<tr>
|
||||
<td>{formattaData(m.data)}</td>
|
||||
<td>{m.etichetta}</td>
|
||||
<td class="lg-misura--fuori-range">
|
||||
{m.valore_num !== null ? formattaNumero(m.valore_num) : '—'}{m.unita ? ` ${m.unita}` : ''}
|
||||
</td>
|
||||
<td>{m.range_min ?? '—'}–{m.range_max ?? '—'}</td>
|
||||
<td>
|
||||
<form method="post" action="/api/longevity/gestionale/verifica">
|
||||
<input type="hidden" name="misura_id" value={m.id} />
|
||||
<button type="submit" name="esito" value="confermata">Conferma: il dato è vero</button>
|
||||
</form>
|
||||
<form method="post" action="/api/longevity/gestionale/verifica">
|
||||
<input type="hidden" name="misura_id" value={m.id} />
|
||||
<button type="submit" name="esito" value="scartata">Scarta: è un errore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Longevity>
|
||||
@@ -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;
|
||||
}
|
||||
---
|
||||
<Longevity title="Crea accesso" crumbs={['Gestionale', code, 'Accesso']} variante="gestionale">
|
||||
{!fascicolo && (
|
||||
<div class="lg-card">
|
||||
<p>Nessun fascicolo con codice <strong>{code}</strong>.</p>
|
||||
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></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>
|
||||
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></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>
|
||||
<p><a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a></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">
|
||||
<a href={`/longevity/gestionale/cliente/${code}`}>Torna alla scheda</a>
|
||||
<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
|
||||
// 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');
|
||||
---
|
||||
|
||||
@@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
||||
<p>
|
||||
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
||||
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>
|
||||
</section>
|
||||
</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>
|
||||
@@ -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 <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) {
|
||||
.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(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// Unità 2a (#2): il modulo del check-up manuale del trainer e le letture dell'anagrafica che
|
||||
// gli servono. Stesso principio di salvataggio-e2e.test.ts — si parte dal modulo vero
|
||||
// (salvaCheckup), mai da calcolaSessione/salvaScore chiamati a mano — più la prova che
|
||||
// questo file esiste apposta a garantire: un check-up e un questionario sono sessioni
|
||||
// separate (#1, misureValideAlla), quindi il referto del secondo deve vedere il primo.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { salvaCheckup, sessioniDi } from '../../src/lib/longevity/gestionale';
|
||||
import { misureDaVerificare } from '../../src/lib/longevity/misure';
|
||||
import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista';
|
||||
import {
|
||||
creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo,
|
||||
} from '../../src/lib/longevity/anagrafica';
|
||||
import { canAccessPath } from '../../src/lib/auth';
|
||||
import { misureDalForm } from '../../src/pages/api/longevity/gestionale/checkup';
|
||||
|
||||
function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('salvaCheckup scrive la sessione e congela i punteggi da solo', () => {
|
||||
it('una sessione tipo checkup, misure di fonte manuale, almeno una riga in score', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
misure: [
|
||||
{ test_id: 'f_peso_kg', valore_num: 80 },
|
||||
{ test_id: 'f_handgrip_kg', valore_num: 45 },
|
||||
],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const sessione = db.prepare(`SELECT tipo, operatore FROM sessioni WHERE id = ?`).get(esito.sessioneId) as
|
||||
{ tipo: string; operatore: string };
|
||||
expect(sessione.tipo).toBe('checkup');
|
||||
expect(sessione.operatore).toBe('nicola');
|
||||
|
||||
const fonti = db.prepare(`SELECT DISTINCT fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as
|
||||
{ fonte: string }[];
|
||||
expect(fonti).toEqual([{ fonte: 'manuale' }]);
|
||||
|
||||
const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as
|
||||
{ n: number };
|
||||
expect(righeScore.n).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('dopo un questionario, il referto del check-up vede sia gli assi del questionario sia quelli fisici (sessioni combinate, #1)', () => {
|
||||
const db = dbPronto();
|
||||
const q = salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-01', eta: 38,
|
||||
risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 },
|
||||
consensoSanitario: 'Si', liberi: {},
|
||||
});
|
||||
expect(q.ok).toBe(true);
|
||||
|
||||
const c = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
misure: [
|
||||
{ test_id: 'f_handgrip_kg', valore_num: 48 },
|
||||
{ test_id: 'f_pushup_rip', valore_num: 24 },
|
||||
],
|
||||
});
|
||||
expect(c.ok).toBe(true);
|
||||
if (!c.ok) throw new Error(c.errore);
|
||||
|
||||
const referto = refertoDi(db, c.sessioneId);
|
||||
// 'Recupero & Sistema Nervoso' pesa questionario_sonno (dal questionario di prima, NON
|
||||
// di questa sessione): la copertura sale sopra zero solo se misureValideAlla porta
|
||||
// avanti la risposta del 2026-09-01 fino alla sessione del 2026-09-26.
|
||||
const recupero = referto.assi.find((a) => a.nome === 'Recupero & Sistema Nervoso')!;
|
||||
expect(recupero.copertura).toBeGreaterThan(0);
|
||||
// 'Forza & Struttura' vede handgrip e spinta, appena registrati in QUESTA sessione.
|
||||
const forza = referto.assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||
expect(forza.copertura).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('salvaCheckup rifiuta prima di scrivere: nessuna traccia parziale', () => {
|
||||
it('un test_id sconosciuto al registro: ok:false, nessuna sessione', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_non_esiste', valore_num: 1 }],
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
|
||||
});
|
||||
|
||||
it('un q_* del questionario nel check-up: ok:false, nessuna sessione', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }],
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
|
||||
});
|
||||
|
||||
it('nessuna misura: ok:false, nessuna sessione', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [],
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
|
||||
});
|
||||
|
||||
it('un soggetto che non esiste in longevity: ok:false, nessuna sessione', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-9999', data: '2026-09-26', eta: null, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () => {
|
||||
it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureDaVerificare', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
expect(esito.fuoriRange).toEqual(['f_peso_kg']);
|
||||
|
||||
const riga = db.prepare(
|
||||
`SELECT fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
|
||||
).get(esito.sessioneId) as { fuori_range: number };
|
||||
expect(riga.fuori_range).toBe(1);
|
||||
|
||||
const elenco = misureDaVerificare(db, 'ISL-0001');
|
||||
expect(elenco.length).toBe(1);
|
||||
expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore_num: 500, data: '2026-09-26' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessioniDi', () => {
|
||||
it('elenca le sessioni del cliente, più recente prima, con numero misure e fitness age congelata', () => {
|
||||
const db = dbPronto();
|
||||
salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
const seconda = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
|
||||
});
|
||||
expect(seconda.ok).toBe(true);
|
||||
|
||||
const elenco = sessioniDi(db, 'ISL-0001');
|
||||
expect(elenco.length).toBe(2);
|
||||
expect(elenco[0].data).toBe('2026-09-26'); // la più recente prima
|
||||
expect(elenco[0].numeroMisure).toBe(1);
|
||||
// Con una sola misura la copertura di ogni asse resta troppo bassa per una Fitness Age:
|
||||
// resta null, non un numero congetturato.
|
||||
expect(elenco[0].fitnessAge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('anagrafica per il gestionale, versioni parametrizzate (testabili su :memory:, come codicePerUtente accanto a codicePerUtenteLoggato)', () => {
|
||||
it('elencoClientiParam ordina per cognome poi nome, e dice chi ha già un accesso', () => {
|
||||
const id = createIdentityDb(':memory:');
|
||||
const lg = createLongevityDb(':memory:');
|
||||
const anna = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F' });
|
||||
const bruno = creaCliente(id, lg, { nome: 'Bruno', cognome: 'Rossi', sesso: 'M', user_id: 7 });
|
||||
|
||||
const elenco = elencoClientiParam(id);
|
||||
expect(elenco.map((c) => c.client_code)).toEqual([bruno, anna]); // Rossi prima di Verdi
|
||||
expect(elenco.find((c) => c.client_code === bruno)!.ha_accesso).toBe(true);
|
||||
expect(elenco.find((c) => c.client_code === anna)!.ha_accesso).toBe(false);
|
||||
});
|
||||
|
||||
it('clienteDaCodiceParam restituisce il dettaglio, null se il codice non esiste', () => {
|
||||
const id = createIdentityDb(':memory:');
|
||||
const lg = createLongevityDb(':memory:');
|
||||
const code = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F', email: 'a@b.it' });
|
||||
|
||||
const dettaglio = clienteDaCodiceParam(id, code);
|
||||
expect(dettaglio?.email).toBe('a@b.it');
|
||||
expect(dettaglio?.ha_accesso).toBe(false);
|
||||
expect(clienteDaCodiceParam(id, 'ISL-9999')).toBeNull();
|
||||
});
|
||||
|
||||
it('etaDelClienteParam calcola l età alla data, null senza data di nascita in anagrafica', () => {
|
||||
const id = createIdentityDb(':memory:');
|
||||
const lg = createLongevityDb(':memory:');
|
||||
const conData = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F', data_nascita: '1988-03-04' });
|
||||
const senzaData = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
|
||||
|
||||
expect(etaDelClienteParam(id, conData, '2026-08-21')).toBe(38);
|
||||
expect(etaDelClienteParam(id, senzaData, '2026-08-21')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apriFascicolo, la versione operativa sul singleton', () => {
|
||||
it('apre un fascicolo nuovo senza mai collegare uno user_id (l accesso è #8, non questa unità)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lg-gestionale-test-'));
|
||||
const idPath = join(dir, 'identity.db');
|
||||
const lgPath = join(dir, 'longevity.db');
|
||||
const primaId = process.env.IDENTITY_DB_PATH;
|
||||
const primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
try {
|
||||
// Stesso trucco di tests/longevity/questionario-pagina.test.ts: il singleton di
|
||||
// getIdentityDb/getLongevityDb si crea alla prima chiamata, quindi le variabili
|
||||
// d'ambiente vanno impostate PRIMA — e questo deve restare l'unico test del file
|
||||
// che chiama una funzione dell'anagrafica sul singleton, non su un DB :memory: proprio.
|
||||
process.env.IDENTITY_DB_PATH = idPath;
|
||||
process.env.LONGEVITY_DB_PATH = lgPath;
|
||||
|
||||
const code = apriFascicolo({ nome: 'Nuovo', cognome: 'Cliente', sesso: 'F' });
|
||||
expect(code).toMatch(/^ISL-\d{4}$/);
|
||||
|
||||
const id = createIdentityDb(idPath);
|
||||
const riga = id.prepare(`SELECT user_id FROM clienti WHERE client_code = ?`).get(code) as
|
||||
{ user_id: number | null };
|
||||
expect(riga.user_id).toBeNull();
|
||||
} finally {
|
||||
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('misureDalForm, il parsing puro del modulo di check-up', () => {
|
||||
const campiAttivi = [
|
||||
{ test_id: 'f_peso_kg', unita: 'kg' },
|
||||
{ test_id: 'f_handgrip_kg', unita: 'kg' },
|
||||
];
|
||||
|
||||
function formCon(dati: Record<string, string>): FormData {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries(dati)) f.set(k, v);
|
||||
return f;
|
||||
}
|
||||
|
||||
it('accetta la virgola decimale', () => {
|
||||
const r = misureDalForm(formCon({ f_peso_kg: '80,5' }), campiAttivi);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.misure).toEqual([{ test_id: 'f_peso_kg', valore_num: 80.5, unita: 'kg' }]);
|
||||
});
|
||||
|
||||
it('un campo vuoto = non misurato, nessuna misura per quella chiave', () => {
|
||||
const r = misureDalForm(formCon({ f_peso_kg: '', f_handgrip_kg: '45' }), campiAttivi);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg']);
|
||||
});
|
||||
|
||||
it('un valore non numerico ferma tutto: ok:false', () => {
|
||||
const r = misureDalForm(formCon({ f_peso_kg: 'ottanta' }), campiAttivi);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('una chiave che non è fra i campi attivi (client_code, un q_*, un refuso) viene ignorata in silenzio', () => {
|
||||
const r = misureDalForm(
|
||||
formCon({ client_code: 'ISL-0001', q_ore_sonno: '7', f_peso_kg: '80' }),
|
||||
campiAttivi
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_peso_kg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccessPath: il check-up è del trainer, non del cliente', () => {
|
||||
it('/api/longevity/gestionale/checkup nega il cliente, ammette il trainer', () => {
|
||||
expect(canAccessPath('cliente', '/api/longevity/gestionale/checkup')).toBe(false);
|
||||
expect(canAccessPath('trainer', '/api/longevity/gestionale/checkup')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
// Il piano degli import collega finalmente i test fisici (verificati contro l'oracolo in
|
||||
// motore-fisici.test.ts, ma finora IRRAGGIUNGIBILI dal motore) a una sessione vera: la
|
||||
// misura grezza del check-up (test_id f_*, registro.ts) arriva a un punteggio di
|
||||
// sotto-dominio tramite sottoDominiFisici (motore/fisici.ts), che calcolaSessione unisce
|
||||
// a quelli del questionario tramite sottoDominiDellaSessione. Questo file copre tre cose
|
||||
// che PRIMA di questa revisione non esistevano: il caso end-to-end confrontato con
|
||||
// l'oracolo, la regola "un ingresso mancante -> sotto-dominio assente" applicata a
|
||||
// funzioni con piu' argomenti (non solo alle curve del questionario), e il motivo per cui
|
||||
// serve tutto questo: un check-up e un questionario sono sessioni SEPARATE, e senza
|
||||
// misureValideAlla l'una non vedrebbe mai i dati dell'altra.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
|
||||
import { calcolaSessione, sottoDominiDellaSessione, misureValideAlla } from '../../src/lib/longevity/motore';
|
||||
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
|
||||
import { campiDelQuestionario } from '../../src/lib/longevity/vista';
|
||||
|
||||
function dbPronto(clientCode: string, sesso: 'M' | 'F' = 'M') {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('il caso demo() dell oracolo, calcolato da una sessione vera', () => {
|
||||
// Stesso cliente fittizio di demo() in isl_scoring_engine.py: M, 38 anni, peso 90.3.
|
||||
// Un solo check-up con TUTTE le misure fisiche della demo, nessun questionario.
|
||||
it('Forza & Struttura, Composizione Corporea e Stabilita & Mobilita Funzionale combaciano con l oracolo', () => {
|
||||
const db = dbPronto('ISL-DEMO', 'M');
|
||||
const s = apriSessione(db, { client_code: 'ISL-DEMO', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s, 'manuale', [
|
||||
{ test_id: 'f_peso_kg', valore_num: 90.3 },
|
||||
{ test_id: 'f_handgrip_kg', valore_num: 48 },
|
||||
{ test_id: 'f_pushup_rip', valore_num: 24 },
|
||||
{ test_id: 'f_row_carico_kg', valore_num: 80 }, { test_id: 'f_row_rip', valore_num: 5 },
|
||||
{ test_id: 'f_squat_carico_kg', valore_num: 140 }, { test_id: 'f_squat_rip', valore_num: 5 },
|
||||
{ test_id: 'f_plank_sec', valore_num: 110 },
|
||||
{ test_id: 'f_grasso_pct', valore_num: 12.8 },
|
||||
{ test_id: 'f_muscolo_pct', valore_num: 48.8 },
|
||||
{ test_id: 'f_whr', valore_num: 0.71 },
|
||||
{ test_id: 'f_mutt_vam', valore_num: 230 },
|
||||
{ test_id: 'f_spo2_pct', valore_num: 98 },
|
||||
{ test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 },
|
||||
{ test_id: 'f_hrr_1min_bpm', valore_num: 15 },
|
||||
{ test_id: 'f_flamingo_cadute', valore_num: 5 },
|
||||
{ test_id: 'f_sit_reach_cm', valore_num: 20 },
|
||||
{ test_id: 'f_back_scratch_cm', valore_num: -5 },
|
||||
{ test_id: 'f_spalla_outreach_deg', valore_num: 180 }, { test_id: 'f_spalla_buckling_deg', valore_num: 180 },
|
||||
]);
|
||||
registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
|
||||
// Valori attesi ricavati lanciando l'oracolo, non a mano:
|
||||
// cd tests/longevity/riferimento && python3 -c "
|
||||
// from isl_scoring_engine import *
|
||||
// eta, sex, peso = 38, 'M', 90.3
|
||||
// forza = {'handgrip': score_handgrip(48, eta, sex), 'spinta': score_pushup(24, eta, sex),
|
||||
// 'trazione': score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex),
|
||||
// 'arti_inferiori': score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex),
|
||||
// 'core': score_plank(110, sex)}
|
||||
// composizione = {'grasso': score_fat_percent(12.8, sex), 'muscolo': score_muscle_percent(48.8, sex),
|
||||
// 'whr': score_whr(0.71, sex)}
|
||||
// stabilita = {'flamingo': score_flamingo(5), 'sit_and_reach': score_sit_and_reach(20, sex),
|
||||
// 'plank': score_plank(110, sex), 'back_scratch': score_back_scratch(-5, eta, sex),
|
||||
// 'wellness_tower_shoulder': score_shoulder_mobility_wt(180, 180)}
|
||||
// print(compute_axis('Forza & Struttura', forza)) # -> score 70.4, coverage 1.0, ok
|
||||
// print(compute_axis('Composizione Corporea', composizione)) # -> score 94.8, coverage 1.0, ok
|
||||
// print(compute_axis('Stabilità & Mobilità Funzionale', stabilita)) # -> score 65.5, coverage 1.0, ok
|
||||
// "
|
||||
const forza = r.assi['Forza & Struttura'];
|
||||
expect(forza.stato).toBe('ok');
|
||||
if (forza.stato === 'ok') expect(forza.valore).toBe(70.4);
|
||||
|
||||
const composizione = r.assi['Composizione Corporea'];
|
||||
expect(composizione.stato).toBe('ok');
|
||||
if (composizione.stato === 'ok') expect(composizione.valore).toBe(94.8);
|
||||
|
||||
const stabilita = r.assi['Stabilità & Mobilità Funzionale'];
|
||||
expect(stabilita.stato).toBe('ok');
|
||||
if (stabilita.stato === 'ok') expect(stabilita.valore).toBe(65.5);
|
||||
});
|
||||
|
||||
// Cardio-Respiratorio, Recupero & Sistema Nervoso ed Energia & Regolazione Stress NON si
|
||||
// confrontano con demo() dell'oracolo: demo() li riempie con segnaposto dichiarati tali nel
|
||||
// codice Python (spirometria=75, questionario_sonno=72, questionario_energia_stress=60,
|
||||
// hrv=65 su Energia — un HRV diverso da quello di Recupero, altro segnaposto) per mostrare
|
||||
// un radar completo. Il nostro motore non inventa spirometria (fuori dal perimetro di
|
||||
// sottoDominiFisici, nessun calcolatore GLI) ne' un questionario mai compilato in questa
|
||||
// sessione: quei sotto-domini restano assenti DAVVERO, non finti, e il peso si ridistribuisce
|
||||
// sugli altri (regola di copertura, cascata.ts). Il numero che ne esce e' quindi
|
||||
// legittimamente diverso da quello della demo, ma calcolabile con la STESSA formula di
|
||||
// aggregazione (aggregate() in Python, aggrega() qui) sul sottoinsieme vero di dati:
|
||||
// cd tests/longevity/riferimento && python3 -c "
|
||||
// from isl_scoring_engine import *
|
||||
// eta, sex = 38, 'M'
|
||||
// vo2 = vo2max_from_mutt(230)
|
||||
// print(compute_axis('Cardio-Respiratorio', {'vo2max': score_vo2max(vo2, eta, sex), 'wellness_tower_cardio': score_spo2(98)}))
|
||||
// print(compute_axis('Recupero & Sistema Nervoso', {'hrv': 68, 'pressione': score_blood_pressure(114, 81), 'hrr': score_hrr(15)}))
|
||||
// print(compute_axis('Energia & Regolazione Stress', {'hrv': 68}))
|
||||
// "
|
||||
// -> Cardio 87.2 (copertura 0.8), Recupero 73.4 (copertura 0.8), Energia 68.0 (copertura 0.5).
|
||||
it('Cardio-Respiratorio, Recupero ed Energia sono ok ma con un numero diverso dalla demo(): mancano spirometria e questionario, non sono finti', () => {
|
||||
const db = dbPronto('ISL-DEMO2', 'M');
|
||||
const s = apriSessione(db, { client_code: 'ISL-DEMO2', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s, 'manuale', [
|
||||
{ test_id: 'f_mutt_vam', valore_num: 230 },
|
||||
{ test_id: 'f_spo2_pct', valore_num: 98 },
|
||||
{ test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 },
|
||||
{ test_id: 'f_hrr_1min_bpm', valore_num: 15 },
|
||||
]);
|
||||
registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
|
||||
const cardio = r.assi['Cardio-Respiratorio'];
|
||||
expect(cardio.stato).toBe('ok');
|
||||
if (cardio.stato === 'ok') { expect(cardio.valore).toBe(87.2); expect(cardio.copertura).toBe(0.8); }
|
||||
|
||||
const recupero = r.assi['Recupero & Sistema Nervoso'];
|
||||
expect(recupero.stato).toBe('ok');
|
||||
if (recupero.stato === 'ok') { expect(recupero.valore).toBe(73.4); expect(recupero.copertura).toBe(0.8); }
|
||||
|
||||
const energia = r.assi['Energia & Regolazione Stress'];
|
||||
expect(energia.stato).toBe('ok');
|
||||
if (energia.stato === 'ok') { expect(energia.valore).toBe(68); expect(energia.copertura).toBe(0.5); }
|
||||
|
||||
// Nessun questionario in questa sessione: l'asse che dipende solo da domande resta scoperto.
|
||||
expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sottoDominiFisici: un ingresso mancante lascia il sotto-dominio assente, mai un ripiego', () => {
|
||||
it('la trazione (sollevamento sul peso corporeo) manca se manca il peso, anche con carico e ripetizioni presenti', () => {
|
||||
const m = new Map([['f_row_carico_kg', 80], ['f_row_rip', 5]]); // manca f_peso_kg
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.trazione).toBeUndefined();
|
||||
});
|
||||
|
||||
it('senza eta: handgrip, spinta, vo2max e back_scratch assenti; il plank (che non la richiede) resta presente', () => {
|
||||
// Stesse quattro funzioni di test-fisici.ts che prendono eta come argomento
|
||||
// obbligatorio (scoreHandgrip, scorePushup, scoreVo2max, scoreBackScratch): con eta
|
||||
// null nessuna delle quattro puo' essere chiamata, quindi il sotto-dominio che ne
|
||||
// dipende resta assente. scorePlank non prende eta: il suo sotto-dominio non risente
|
||||
// per niente dell'eta mancante.
|
||||
const m = new Map([
|
||||
['f_handgrip_kg', 48], ['f_pushup_rip', 24], ['f_plank_sec', 110],
|
||||
['f_mutt_vam', 230], ['f_back_scratch_cm', -5],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, null, 'M');
|
||||
expect(r.handgrip).toBeUndefined();
|
||||
expect(r.spinta).toBeUndefined();
|
||||
expect(r.vo2max).toBeUndefined();
|
||||
expect(r.back_scratch).toBeUndefined();
|
||||
expect(r.core).toBe(47.8); // scorePlank(110, 'M'), verificato in motore-fisici.test.ts
|
||||
expect(r.plank).toBe(47.8);
|
||||
});
|
||||
|
||||
it('il plank pesa su due sotto-domini (core e plank) dalla STESSA misura, non da due misure diverse', () => {
|
||||
const m = new Map([['f_plank_sec', 79]]);
|
||||
const r = sottoDominiFisici(m, 38, 'F');
|
||||
expect(r.core).toBe(r.plank);
|
||||
});
|
||||
});
|
||||
|
||||
describe('un check-up e un questionario sono sessioni separate, ma un cliente e uno solo', () => {
|
||||
it('un check-up 10 giorni dopo il questionario vede ENTRAMBI i sotto-domini', () => {
|
||||
const db = dbPronto('ISL-0020', 'M');
|
||||
// Sessione D: questionario del giorno D.
|
||||
const sQuest = salvaCompilazione(db, {
|
||||
client_code: 'ISL-0020', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 },
|
||||
});
|
||||
// Sessione D+10: check-up, con una sola misura fisica.
|
||||
const sCheckup = apriSessione(db, { client_code: 'ISL-0020', data: '2026-08-11', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, sCheckup, 'manuale', [{ test_id: 'f_handgrip_kg', valore_num: 48 }]);
|
||||
|
||||
// Prova diretta che le due sessioni sono davvero due righe distinte in `sessioni`:
|
||||
// senza questo, "il check-up vede il questionario" sarebbe banale (sarebbe la STESSA
|
||||
// sessione) invece che il punto vero di misureValideAlla.
|
||||
expect(sQuest).not.toBe(sCheckup);
|
||||
|
||||
const sd = sottoDominiDellaSessione(db, sCheckup);
|
||||
expect(sd).toHaveProperty('questionario_lifestyle'); // dal questionario di D, non di questa sessione
|
||||
expect(sd).toHaveProperty('handgrip'); // dal check-up di questa sessione
|
||||
});
|
||||
|
||||
it('una sessione del giorno PRIMA del questionario non vede le sue risposte', () => {
|
||||
const db = dbPronto('ISL-0021', 'M');
|
||||
salvaCompilazione(db, { client_code: 'ISL-0021', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 } });
|
||||
// Aperta DOPO, ma con una data anteriore al questionario: il confronto e' sulla data
|
||||
// della sessione, non sull'ordine di inserimento.
|
||||
const sPrima = apriSessione(db, { client_code: 'ISL-0021', data: '2026-07-31', tipo: 'checkup', eta_alla_data: 38 });
|
||||
|
||||
const sd = sottoDominiDellaSessione(db, sPrima);
|
||||
expect(sd).not.toHaveProperty('questionario_lifestyle');
|
||||
});
|
||||
|
||||
it('due misure dello stesso test_id nella stessa sessione: vince quella inserita dopo', () => {
|
||||
// registraMisure non vieta di scrivere due volte lo stesso test_id nella stessa
|
||||
// sessione (es. una correzione manuale dopo un refuso di battitura). data di sessione
|
||||
// e id di sessione sono identici per le due righe: senza uno spareggio esplicito su
|
||||
// m.id (la riga in `misure`), l'ordine fra le due sarebbe indefinito.
|
||||
const db = dbPronto('ISL-0023', 'M');
|
||||
const s = apriSessione(db, { client_code: 'ISL-0023', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 20 }]); // scritta per prima, refuso
|
||||
registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // corretta subito dopo
|
||||
|
||||
const misurate = misureValideAlla(db, s);
|
||||
expect(misurate.get('f_grasso_pct')).toBe(12.8); // l'ultima scritta, non la prima
|
||||
});
|
||||
|
||||
it('una misura fuori range viene ignorata e sostituita dalla precedente misura valida', () => {
|
||||
const db = dbPronto('ISL-0022', 'M');
|
||||
const s1 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s1, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // valida
|
||||
|
||||
const s2 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-20', tipo: 'checkup', eta_alla_data: 38 });
|
||||
// f_grasso_pct ha range_max 60 (registro.ts): 999 e' un errore di battitura evidente.
|
||||
const esito = registraMisure(db, s2, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 999 }]);
|
||||
expect(esito.fuoriRange).toEqual(['f_grasso_pct']);
|
||||
|
||||
const misurate = misureValideAlla(db, s2);
|
||||
// NON 999 (fuori range, scartata) ne' assente (persa): la precedente misura valida,
|
||||
// quella di s1.
|
||||
expect(misurate.get('f_grasso_pct')).toBe(12.8);
|
||||
|
||||
const sd = sottoDominiDellaSessione(db, s2);
|
||||
expect(sd.grasso).toBe(100); // score_fat_percent(12.8, 'M') dell'oracolo: banda 5-13 -> 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('campiDelQuestionario resta un modulo di sole domande', () => {
|
||||
it('nessun campo f_* (test fisico) compare fra i campi del questionario', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||
expect(campi.length).toBeGreaterThan(0);
|
||||
expect(campi.some((c) => c.id.startsWith('f_'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// tests/longevity/pagine.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||
@@ -93,4 +93,77 @@ describe('il guscio della piattaforma', () => {
|
||||
const src = readFileSync(p, 'utf8');
|
||||
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['"`]/);
|
||||
});
|
||||
|
||||
it('la scheda cliente porta le azioni di verifica di una misura fuori range (#7) e il link per creare l\'accesso (#8)', () => {
|
||||
const src = leggi('src/pages/longevity/gestionale/cliente/[code].astro');
|
||||
expect(src).toMatch(/action=["']\/api\/longevity\/gestionale\/verifica["']/);
|
||||
expect(src).toMatch(/name=["']misura_id["']/);
|
||||
expect(src).toMatch(/value=["']confermata["']/);
|
||||
expect(src).toMatch(/value=["']scartata["']/);
|
||||
expect(src).toMatch(/\/longevity\/gestionale\/cliente\/\$\{cliente\.client_code\}\/accesso/);
|
||||
});
|
||||
|
||||
it('nessuna pagina longevity usa piu misureFuoriRange, rimossa da gestionale.ts a favore di misureDaVerificare (#7)', () => {
|
||||
// Scandisce TUTTE le pagine sotto src/pages/longevity/, stesso criterio di
|
||||
// referto.test.ts: un elenco scritto qui non protegge una pagina nuova.
|
||||
const dir = join(process.cwd(), 'src/pages/longevity');
|
||||
const pagine = (readdirSync(dir, { recursive: true }) as string[]).filter((f) => f.endsWith('.astro'));
|
||||
expect(pagine.length).toBeGreaterThan(0);
|
||||
for (const f of pagine) {
|
||||
expect(readFileSync(join(dir, f), 'utf8'), `src/pages/longevity/${f}`).not.toMatch(/misureFuoriRange/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user