Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 680224e773 | |||
| 8aeb25f930 | |||
| 575a3988b7 | |||
| 498395dddb | |||
| b0f30f9e4c | |||
| 81021d18f3 | |||
| c8187ce4db | |||
| cffb0a624d | |||
| f57a07a964 | |||
| abd0626ab0 | |||
| 2231a476c7 | |||
| cef08aaf9f | |||
| 37b1a316d8 | |||
| 00ecfe86c3 | |||
| 6bbfcd8f3c | |||
| 9e566ad7b1 | |||
| 19035e67b0 | |||
| 880c928f43 | |||
| 8f95a385d9 | |||
| 12b2e91503 |
@@ -0,0 +1,157 @@
|
||||
// La prima consulenza (#15): una scheda sola per cliente — livello, obiettivo, note dello
|
||||
// screening iniziale — che il trainer aggiorna in ogni follow-up. Correggere e salvare
|
||||
// SOVRASCRIVE, nessuno storico (decisione di Adriano, 27/09/2026): il racconto dei cambi va
|
||||
// nel giornale di bordo (giornale.ts), non qui. Stesso vincolo del resto di longevity.db:
|
||||
// solo client_code, mai un nome.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const TESTO_MAX = 5000;
|
||||
|
||||
// Elenco chiuso dell'obiettivo primario (foglio «Profilo Cliente», call del 27/09): chiave
|
||||
// salvata nel DB, etichetta mostrata nel form — stesso schema di ETICHETTA_TIPO altrove nel
|
||||
// gestionale.
|
||||
export const OBIETTIVI: { chiave: string; etichetta: string }[] = [
|
||||
{ chiave: 'performance_sportiva', etichetta: 'Performance sportiva' },
|
||||
{ chiave: 'energia_quotidiana', etichetta: 'Energia quotidiana' },
|
||||
{ chiave: 'recupero_benessere', etichetta: 'Recupero e benessere' },
|
||||
{ chiave: 'riabilitazione', etichetta: 'Riabilitazione' },
|
||||
{ chiave: 'ricomposizione_corporea', etichetta: 'Ricomposizione corporea' },
|
||||
{ chiave: 'dimagrimento', etichetta: 'Dimagrimento' },
|
||||
{ chiave: 'altro', etichetta: 'Altro' },
|
||||
];
|
||||
const CHIAVI_OBIETTIVI = new Set(OBIETTIVI.map((o) => o.chiave));
|
||||
|
||||
export type Consulenza = {
|
||||
client_code: string;
|
||||
livello: number | null;
|
||||
obiettivo_primario: string | null;
|
||||
obiettivo_specifico: string | null;
|
||||
data_target: string | null;
|
||||
note_mediche: string | null;
|
||||
storia_sportiva: string | null;
|
||||
limitazioni: string | null;
|
||||
disponibilita_giorni: number | null;
|
||||
fascia_oraria: string | null;
|
||||
note_screening: string | null;
|
||||
aggiornata_da: string;
|
||||
aggiornata_at: string;
|
||||
};
|
||||
|
||||
/** La scheda del cliente, o null se non è mai stata compilata. */
|
||||
export function consulenzaDi(db: Database.Database, clientCode: string): Consulenza | null {
|
||||
return (db.prepare(`SELECT * FROM consulenza WHERE client_code = ?`).get(clientCode) as
|
||||
Consulenza | undefined) ?? null;
|
||||
}
|
||||
|
||||
// I campi grezzi, tutti facoltativi e tutti stringhe: chi chiama (l'API) li legge dal
|
||||
// FormData così come sono, la validazione vera è qui — stesso confine di aggiungiVoce
|
||||
// (giornale.ts), che riceve stringhe e decide se sono valide.
|
||||
export type CampiConsulenza = {
|
||||
livello?: string;
|
||||
obiettivoPrimario?: string;
|
||||
obiettivoSpecifico?: string;
|
||||
dataTarget?: string;
|
||||
noteMediche?: string;
|
||||
storiaSportiva?: string;
|
||||
limitazioni?: string;
|
||||
disponibilitaGiorni?: string;
|
||||
fasciaOraria?: string;
|
||||
noteScreening?: string;
|
||||
};
|
||||
|
||||
export type EsitoSalvataggio = { ok: true } | { ok: false; errore: string };
|
||||
|
||||
// Una stringa vuota (dopo trim) diventa NULL: tutti i campi sono facoltativi (decisione di
|
||||
// Adriano, 27/09/2026), e NULL è "non compilato", non un errore.
|
||||
const nullSeVuoto = (v: string | undefined): string | null => {
|
||||
const t = (v ?? '').trim();
|
||||
return t === '' ? null : t;
|
||||
};
|
||||
|
||||
const testoValido = (v: string | null): v is string | null => v === null || v.length <= TESTO_MAX;
|
||||
|
||||
/**
|
||||
* Scrive (o riscrive) la scheda di prima consulenza. Rifiuta PRIMA di scrivere: soggetto
|
||||
* inesistente, livello fuori 1-3, obiettivo fuori elenco, data_target malformata,
|
||||
* disponibilità fuori 0-7 o non intera, un testo libero oltre TESTO_MAX — stesso schema di
|
||||
* validazione di aggiungiVoce (giornale.ts) e salvaCheckup (gestionale.ts). UPSERT: una
|
||||
* seconda chiamata sullo stesso client_code sovrascrive la prima, niente storico.
|
||||
*/
|
||||
export function salvaConsulenza(
|
||||
db: Database.Database,
|
||||
input: { clientCode: string; autore: string; campi: CampiConsulenza }
|
||||
): EsitoSalvataggio {
|
||||
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
|
||||
if (!soggetto) {
|
||||
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
|
||||
}
|
||||
|
||||
const c = input.campi;
|
||||
|
||||
let livello: number | null = null;
|
||||
const livelloGrezzo = (c.livello ?? '').trim();
|
||||
if (livelloGrezzo !== '') {
|
||||
livello = Number(livelloGrezzo);
|
||||
if (!Number.isInteger(livello) || livello < 1 || livello > 3) {
|
||||
return { ok: false, errore: 'Livello non valido: deve essere 1, 2 o 3.' };
|
||||
}
|
||||
}
|
||||
|
||||
const obiettivoPrimario = nullSeVuoto(c.obiettivoPrimario);
|
||||
if (obiettivoPrimario !== null && !CHIAVI_OBIETTIVI.has(obiettivoPrimario)) {
|
||||
return { ok: false, errore: `Obiettivo primario non valido: ${obiettivoPrimario}` };
|
||||
}
|
||||
|
||||
const dataTarget = nullSeVuoto(c.dataTarget);
|
||||
if (dataTarget !== null && !REGEX_DATA.test(dataTarget)) {
|
||||
return { ok: false, errore: 'Data target non valida (formato AAAA-MM-GG).' };
|
||||
}
|
||||
|
||||
let disponibilitaGiorni: number | null = null;
|
||||
const disponibilitaGrezza = (c.disponibilitaGiorni ?? '').trim();
|
||||
if (disponibilitaGrezza !== '') {
|
||||
disponibilitaGiorni = Number(disponibilitaGrezza);
|
||||
if (!Number.isInteger(disponibilitaGiorni) || disponibilitaGiorni < 0 || disponibilitaGiorni > 7) {
|
||||
return { ok: false, errore: 'Disponibilità non valida: deve essere un numero intero fra 0 e 7.' };
|
||||
}
|
||||
}
|
||||
|
||||
const obiettivoSpecifico = nullSeVuoto(c.obiettivoSpecifico);
|
||||
const noteMediche = nullSeVuoto(c.noteMediche);
|
||||
const storiaSportiva = nullSeVuoto(c.storiaSportiva);
|
||||
const limitazioni = nullSeVuoto(c.limitazioni);
|
||||
const fasciaOraria = nullSeVuoto(c.fasciaOraria);
|
||||
const noteScreening = nullSeVuoto(c.noteScreening);
|
||||
|
||||
for (const [nome, valore] of [
|
||||
['Obiettivo specifico', obiettivoSpecifico], ['Note mediche', noteMediche],
|
||||
['Storia sportiva', storiaSportiva], ['Limitazioni', limitazioni],
|
||||
['Fascia oraria', fasciaOraria], ['Note screening', noteScreening],
|
||||
] as const) {
|
||||
if (!testoValido(valore)) {
|
||||
return { ok: false, errore: `${nome} non può superare ${TESTO_MAX} caratteri.` };
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO consulenza (
|
||||
client_code, livello, obiettivo_primario, obiettivo_specifico, data_target,
|
||||
note_mediche, storia_sportiva, limitazioni, disponibilita_giorni, fascia_oraria,
|
||||
note_screening, aggiornata_da, aggiornata_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (client_code) DO UPDATE SET
|
||||
livello = excluded.livello, obiettivo_primario = excluded.obiettivo_primario,
|
||||
obiettivo_specifico = excluded.obiettivo_specifico, data_target = excluded.data_target,
|
||||
note_mediche = excluded.note_mediche, storia_sportiva = excluded.storia_sportiva,
|
||||
limitazioni = excluded.limitazioni, disponibilita_giorni = excluded.disponibilita_giorni,
|
||||
fascia_oraria = excluded.fascia_oraria, note_screening = excluded.note_screening,
|
||||
aggiornata_da = excluded.aggiornata_da, aggiornata_at = excluded.aggiornata_at`
|
||||
).run(
|
||||
input.clientCode, livello, obiettivoPrimario, obiettivoSpecifico, dataTarget,
|
||||
noteMediche, storiaSportiva, limitazioni, disponibilitaGiorni, fasciaOraria,
|
||||
noteScreening, input.autore
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -110,6 +110,36 @@ CREATE TABLE IF NOT EXISTS score (
|
||||
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
|
||||
-- Giornale di bordo per cliente (issue #16, solo staff): una nota di testo libero, mai
|
||||
-- modificata — sbagliata si cancella e si riscrive (decisione di Adriano, 27/09/2026). Come
|
||||
-- il resto di questo schema, solo client_code: nessun nome qui, mai in longevity.db.
|
||||
CREATE TABLE IF NOT EXISTS giornale (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
||||
data TEXT NOT NULL,
|
||||
autore TEXT NOT NULL,
|
||||
testo TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC);
|
||||
-- Prima consulenza (issue #15): una scheda sola per cliente, sovrascritta a ogni modifica —
|
||||
-- nessuno storico (decisione di Adriano, 27/09/2026, come il giornale qui sopra ma senza
|
||||
-- neanche l'aggiungi/cancella: qui è un UPSERT unico). Solo client_code, mai un nome.
|
||||
CREATE TABLE IF NOT EXISTS consulenza (
|
||||
client_code TEXT PRIMARY KEY REFERENCES soggetti(client_code),
|
||||
livello INTEGER CHECK (livello IN (1,2,3)),
|
||||
obiettivo_primario TEXT,
|
||||
obiettivo_specifico TEXT,
|
||||
data_target TEXT,
|
||||
note_mediche TEXT,
|
||||
storia_sportiva TEXT,
|
||||
limitazioni TEXT,
|
||||
disponibilita_giorni INTEGER CHECK (disponibilita_giorni BETWEEN 0 AND 7),
|
||||
fascia_oraria TEXT,
|
||||
note_screening TEXT,
|
||||
aggiornata_da TEXT NOT NULL,
|
||||
aggiornata_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
const SCHEMA_IDENTITY = `
|
||||
|
||||
@@ -7,12 +7,23 @@
|
||||
// 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 { apriSessione, registraMisure, fuoriRangeDi, type MisuraIn, type Fonte } from './misure';
|
||||
import { calcolaSessione, salvaScore, leggiScore } from './motore';
|
||||
import type { CampoQuestionario } from './vista';
|
||||
|
||||
export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] };
|
||||
|
||||
/** L'ordine degli assi dai pesi (livello 'asse'), nell'ordine in cui compaiono per la prima
|
||||
* volta — stesso criterio di `contenitoriDi` in vista.ts. Estratto qui perché lo usano sia
|
||||
* `campiDelCheckup` sia `misureDellaSessione` (#12): due elenchi di assi che potrebbero
|
||||
* divergere sarebbero peggio di uno solo. */
|
||||
function ordineAssiDaiPesi(db: Database.Database): string[] {
|
||||
return (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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_*`),
|
||||
@@ -26,10 +37,7 @@ export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] };
|
||||
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 ordineAssi = ordineAssiDaiPesi(db);
|
||||
|
||||
const perAsse = new Map<string, CampoQuestionario[]>();
|
||||
for (const v of fisici) {
|
||||
@@ -48,6 +56,201 @@ export function campiDelCheckup(db: Database.Database, alla: string): GruppoChec
|
||||
return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! }));
|
||||
}
|
||||
|
||||
// --- Struttura del modulo di check-up (#13, manuale di Nicola del 27/09/2026) --------------
|
||||
// A differenza di campiDelCheckup (raggruppato per asse, invariato: lo usa ancora la pagina
|
||||
// di dettaglio sessione, sessione/[id].astro, per la vista di sola lettura), qui l'ordine e
|
||||
// il raggruppamento sono quelli del modulo cartaceo: Pre-test -> Mobilità -> Equilibrio ->
|
||||
// Presa -> Core -> Forza spinta -> Forza trazione -> Arti inferiori -> Aerobico -> Strumenti.
|
||||
// Una sezione o e' a campi fissi, o e' "ad alternative" (piu' protocolli per la stessa
|
||||
// sotto-metrica, uno solo si compila: radio button, mai <select> — tests/longevity/
|
||||
// referto.test.ts intercetta la parola). test_id qui sono stringhe: si risolvono contro
|
||||
// testAttivi in strutturaCheckup, cosi' un test dismesso sparisce da solo dal modulo.
|
||||
|
||||
type AlternativaRaw = { chiave: string; nome: string; soglia: string; campi: string[]; dettagli?: string[] };
|
||||
type SezioneRaw =
|
||||
| { titolo: string; tipo: 'campi'; campi: string[] }
|
||||
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaRaw[] };
|
||||
|
||||
// Pressione e SpO2 NON compaiono nel modulo (decisione di Adriano, 27/09/2026: arrivano
|
||||
// dall'import Wellness Tower di #3, non da inserimento manuale) — esclusi anche da
|
||||
// «Altre misure», non solo dalle sezioni: sono attivi nel registro ma il modulo non li mostra.
|
||||
const ESCLUSI_DAL_MODULO = new Set(['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']);
|
||||
|
||||
const STRUTTURA_RAW: SezioneRaw[] = [
|
||||
{ titolo: 'Pre-test', tipo: 'campi', campi: ['f_fc_riposo_bpm', 'f_peso_kg', 'f_altezza_cm'] },
|
||||
{ titolo: 'Mobilità', tipo: 'campi', campi: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm', 'f_sit_reach_cm'] },
|
||||
{ titolo: 'Equilibrio', tipo: 'alternative', chiave: 'equilibrio', alternative: [
|
||||
{ chiave: 'occhi_aperti', nome: 'Flamingo, occhi aperti',
|
||||
soglia: 'Soglia: 4-7 cadute a occhi aperti → passa a occhi chiusi.',
|
||||
campi: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
|
||||
{ chiave: 'occhi_chiusi', nome: 'Flamingo, occhi chiusi', soglia: '',
|
||||
campi: ['f_flamingo_oc_dx', 'f_flamingo_oc_sx'] },
|
||||
] },
|
||||
{ titolo: 'Presa', tipo: 'campi', campi: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
|
||||
{ titolo: 'Core', tipo: 'alternative', chiave: 'core', alternative: [
|
||||
{ chiave: 'ginocchia', nome: 'Plank sulle ginocchia',
|
||||
soglia: 'Soglia: ≥30-45 s sulle ginocchia → passa a plank pieno.', campi: ['f_plank_ginocchia_sec'] },
|
||||
{ chiave: 'pieno', nome: 'Plank pieno', soglia: '', campi: ['f_plank_sec'] },
|
||||
] },
|
||||
{ titolo: 'Forza spinta', tipo: 'alternative', chiave: 'spinta', alternative: [
|
||||
{ chiave: 'manubri', nome: 'Panca con manubri (5RM, kg per manubrio)',
|
||||
soglia: 'Soglia: esegue ≥1 push-up corretto → prova push-up.',
|
||||
campi: ['f_panca_man_carico_kg', 'f_panca_man_rip'] },
|
||||
{ chiave: 'pushup', nome: 'Push-up (donne sulle ginocchia)',
|
||||
soglia: 'Soglia: fascia «Buono» o oltre → passa a panca con bilanciere.', campi: ['f_pushup_rip'] },
|
||||
{ chiave: 'bilanciere', nome: 'Panca con bilanciere (5RM)', soglia: '',
|
||||
campi: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
|
||||
] },
|
||||
{ titolo: 'Forza trazione', tipo: 'alternative', chiave: 'trazione', alternative: [
|
||||
{ chiave: 'rematore', nome: 'Rematore (5RM)',
|
||||
soglia: 'Soglia: ≥1 trazione, o carico 0,95-1,19× peso corporeo → passa a trazioni.',
|
||||
campi: ['f_row_carico_kg', 'f_row_rip'] },
|
||||
{ chiave: 'trazioni', nome: 'Trazioni alla sbarra', soglia: '', campi: ['f_trazioni_rip'] },
|
||||
] },
|
||||
{ titolo: 'Arti inferiori', tipo: 'alternative', chiave: 'arti_inferiori', alternative: [
|
||||
{ chiave: 'sit_to_stand', nome: 'Sit-to-stand, 1 minuto (sedia 43-45 cm)',
|
||||
soglia: 'Soglia: rapporto risultato/predetto ≥1,0 → passa a squat.', campi: ['f_sit_to_stand_rip'] },
|
||||
{ chiave: 'squat', nome: 'Squat (5RM)', soglia: '', campi: ['f_squat_carico_kg', 'f_squat_rip'] },
|
||||
] },
|
||||
{ titolo: 'Aerobico', tipo: 'alternative', chiave: 'aerobico', alternative: [
|
||||
{ chiave: '6mwt', nome: '6 Minute Walk Test',
|
||||
soglia: 'Soglia: ≥80% del predetto → passa a Queen\'s College o 2 km.', campi: ['f_6mwt_m'] },
|
||||
{ chiave: 'queens', nome: 'Queen\'s College Step Test',
|
||||
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_step_hr_recovery'] },
|
||||
{ chiave: '2km', nome: 'Camminata 2 km (UKK)',
|
||||
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
|
||||
{ chiave: 'mutt', nome: 'MUTT', soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).',
|
||||
campi: ['f_mutt_vam', 'f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm', 'f_mutt_fc_2min_bpm'],
|
||||
dettagli: [
|
||||
'f_mutt_fc_7_5', 'f_mutt_fc_8_5', 'f_mutt_fc_9_5', 'f_mutt_fc_10_5',
|
||||
'f_mutt_fc_11_5', 'f_mutt_fc_12_5', 'f_mutt_fc_13_5', 'f_mutt_fc_14_5',
|
||||
] },
|
||||
{ chiave: 'milfit', nome: 'MILFIT',
|
||||
soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).', campi: ['f_milfit_watt'] },
|
||||
{ chiave: 'calibre', nome: 'Calibre (VO2max misurato)', soglia: '', campi: ['f_vo2max'] },
|
||||
] },
|
||||
{ titolo: 'Strumenti (Wellness Tower, Stress Index)', tipo: 'campi',
|
||||
campi: ['f_grasso_pct', 'f_muscolo_pct', 'f_whr', 'f_hrv_score', 'f_spalla_outreach_deg', 'f_spalla_buckling_deg'] },
|
||||
];
|
||||
|
||||
export type AlternativaCheckup = {
|
||||
chiave: string; nome: string; soglia: string;
|
||||
campi: CampoQuestionario[]; dettagli?: CampoQuestionario[];
|
||||
};
|
||||
export type SezioneCheckup =
|
||||
| { titolo: string; tipo: 'campi'; campi: CampoQuestionario[] }
|
||||
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaCheckup[] };
|
||||
export type ModuloCheckup = { sezioni: SezioneCheckup[]; altreMisure: CampoQuestionario[] };
|
||||
|
||||
/**
|
||||
* Il modulo del check-up nell'ordine del manuale (#13): solo i test `f_*` attivi alla data
|
||||
* indicata. Un test attivo ma assente da STRUTTURA_RAW (i dismessi, correggendo una sessione
|
||||
* vecchia) finisce in `altreMisure`, mai perso — tranne pressione e SpO2 (ESCLUSI_DAL_MODULO),
|
||||
* che non compaiono da nessuna parte qui: restano nel registro, li scrive l'import #3.
|
||||
*/
|
||||
export function strutturaCheckup(db: Database.Database, alla: string): ModuloCheckup {
|
||||
const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_'));
|
||||
const perId = new Map<string, CampoQuestionario>(fisici.map((v) => [v.test_id, {
|
||||
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,
|
||||
}]));
|
||||
|
||||
const usati = new Set<string>(ESCLUSI_DAL_MODULO);
|
||||
const risolvi = (ids: string[]): CampoQuestionario[] => {
|
||||
const out: CampoQuestionario[] = [];
|
||||
for (const id of ids) {
|
||||
usati.add(id);
|
||||
const c = perId.get(id);
|
||||
if (c) out.push(c); // non attivo alla data: si salta, non un campo vuoto
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sezioni: SezioneCheckup[] = [];
|
||||
for (const s of STRUTTURA_RAW) {
|
||||
if (s.tipo === 'campi') {
|
||||
const campi = risolvi(s.campi);
|
||||
if (campi.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'campi', campi });
|
||||
} else {
|
||||
const alternative: AlternativaCheckup[] = [];
|
||||
for (const a of s.alternative) {
|
||||
const campi = risolvi(a.campi);
|
||||
const dettagli = a.dettagli ? risolvi(a.dettagli) : undefined;
|
||||
if (campi.length > 0 || (dettagli && dettagli.length > 0)) {
|
||||
alternative.push({ chiave: a.chiave, nome: a.nome, soglia: a.soglia, campi, dettagli });
|
||||
}
|
||||
}
|
||||
if (alternative.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'alternative', chiave: s.chiave, alternative });
|
||||
}
|
||||
}
|
||||
|
||||
const altreMisure = fisici.filter((v) => !usati.has(v.test_id)).map((v) => perId.get(v.test_id)!);
|
||||
|
||||
return { sezioni, altreMisure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Quale alternativa preselezionare in una sezione ad alternative (#13): la prima, in ordine
|
||||
* di elencazione, che ha almeno uno dei suoi test_id (campi o dettagli) fra `valori` — in
|
||||
* correzione `valori` sono le misure della sessione, in un check-up nuovo quelle dell'ultimo
|
||||
* check-up del cliente (`valoriUltimoCheckup`). Senza corrispondenza vince la prima
|
||||
* dell'elenco (il livello neofita, come dice l'analisi).
|
||||
*/
|
||||
export function alternativaSelezionata(
|
||||
sezione: Extract<SezioneCheckup, { tipo: 'alternative' }>, valori: Map<string, number>
|
||||
): string {
|
||||
for (const alt of sezione.alternative) {
|
||||
const ids = [...alt.campi.map((c) => c.id), ...(alt.dettagli?.map((c) => c.id) ?? [])];
|
||||
if (ids.some((id) => valori.has(id))) return alt.chiave;
|
||||
}
|
||||
return sezione.alternative[0]?.chiave ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Le misure dell'ultimo check-up del cliente (più recente prima, come `sessioniDi`), per
|
||||
* precompilare `alternativaSelezionata` in un check-up NUOVO — mappa vuota se il cliente non
|
||||
* ha ancora un check-up.
|
||||
*/
|
||||
export function valoriUltimoCheckup(db: Database.Database, clientCode: string): Map<string, number> {
|
||||
const riga = db.prepare(
|
||||
`SELECT id FROM sessioni WHERE client_code = ? AND tipo = 'checkup' ORDER BY data DESC, id DESC LIMIT 1`
|
||||
).get(clientCode) as { id: number } | undefined;
|
||||
if (!riga) return new Map();
|
||||
|
||||
const dettaglio = misureDellaSessione(db, riga.id);
|
||||
const out = new Map<string, number>();
|
||||
for (const m of dettaglio?.misure ?? []) {
|
||||
if (m.valore_num !== null) out.set(m.test_id, m.valore_num);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13):
|
||||
* `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel
|
||||
* form). Una sezione ASSENTE da `scelte` (nessuna radio arrivata: form senza JS che non ha
|
||||
* ancora marcato nulla, o client non-browser) non scarta NIENTE — ripiegare sulla prima
|
||||
* alternativa butterebbe via in silenzio campi compilati di un'altra (es. f_plank_sec senza
|
||||
* alt_core), correzione del passo 3 su #13. Una sezione a campi fissi non contribuisce: nulla
|
||||
* da scartare lì.
|
||||
*/
|
||||
export function testIdScartatiDalleAlternative(
|
||||
sezioni: SezioneCheckup[], scelte: Map<string, string>
|
||||
): Set<string> {
|
||||
const scartati = new Set<string>();
|
||||
for (const s of sezioni) {
|
||||
if (s.tipo !== 'alternative') continue;
|
||||
const scelta = scelte.get(s.chiave);
|
||||
if (scelta === undefined) continue; // nessuna radio per questa sezione: non si scarta nulla
|
||||
for (const alt of s.alternative) {
|
||||
if (alt.chiave === scelta) continue;
|
||||
for (const c of alt.campi) scartati.add(c.id);
|
||||
for (const c of alt.dettagli ?? []) scartati.add(c.id);
|
||||
}
|
||||
}
|
||||
return scartati;
|
||||
}
|
||||
|
||||
export type EsitoCheckup =
|
||||
| { ok: true; sessioneId: number; fuoriRange: string[] }
|
||||
| { ok: false; errore: string };
|
||||
@@ -102,6 +305,163 @@ export function salvaCheckup(
|
||||
return { ok: true, sessioneId, fuoriRange };
|
||||
}
|
||||
|
||||
export type MisuraDiSessione = {
|
||||
id: number; test_id: string; etichetta: string;
|
||||
valore_num: number | null; valore_txt: string | null; unita: string | null;
|
||||
fonte: Fonte; fuori_range: number; verifica: 'confermata' | 'scartata' | null;
|
||||
};
|
||||
|
||||
export type SessioneDettaglio = {
|
||||
id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario';
|
||||
operatore: string | null; misure: MisuraDiSessione[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Il dettaglio di una sessione con le sue misure (#12, pagina di dettaglio e precompilazione
|
||||
* della correzione): null se la sessione non esiste. Le misure sono ordinate per asse —
|
||||
* stesso criterio di `campiDelCheckup`, via `ordineAssiDaiPesi` — poi per test_id, così il
|
||||
* dettaglio di un check-up si legge negli stessi raggruppamenti del modulo che lo ha
|
||||
* compilato.
|
||||
*/
|
||||
export function misureDellaSessione(db: Database.Database, sessioneId: number): SessioneDettaglio | null {
|
||||
const sess = db.prepare(
|
||||
`SELECT id, client_code, data, tipo, operatore FROM sessioni WHERE id = ?`
|
||||
).get(sessioneId) as
|
||||
{ id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null }
|
||||
| undefined;
|
||||
if (!sess) return null;
|
||||
|
||||
const righe = db.prepare(
|
||||
`SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, r.asse AS asse,
|
||||
m.valore_num AS valore_num, m.valore_txt AS valore_txt, m.unita AS unita,
|
||||
m.fonte AS fonte, m.fuori_range AS fuori_range, m.verifica AS verifica
|
||||
FROM misure m JOIN registro_test r ON r.test_id = m.test_id
|
||||
WHERE m.sessione_id = ?`
|
||||
).all(sessioneId) as (MisuraDiSessione & { asse: string | null })[];
|
||||
|
||||
const posizioneAsse = new Map(ordineAssiDaiPesi(db).map((a, i) => [a, i]));
|
||||
righe.sort((a, b) => {
|
||||
const pa = posizioneAsse.get(a.asse ?? '') ?? Infinity;
|
||||
const pb = posizioneAsse.get(b.asse ?? '') ?? Infinity;
|
||||
return pa - pb || a.test_id.localeCompare(b.test_id);
|
||||
});
|
||||
|
||||
const misure: MisuraDiSessione[] = righe.map(({ asse: _asse, ...m }) => m);
|
||||
return { id: sess.id, client_code: sess.client_code, data: sess.data, tipo: sess.tipo, operatore: sess.operatore, misure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregge un check-up già salvato (#12): stesse validazioni di `salvaCheckup`, poi
|
||||
* un'unica transazione che aggiorna la sessione e le sue misure e ricalcola i punteggi.
|
||||
* Nessuno storico delle misure: la correzione SOVRASCRIVE (decisione di Adriano, 27/09/2026)
|
||||
* — una misura cambiata perde la verifica precedente (il trainer deve rivederla sul nuovo
|
||||
* valore, non su quello vecchio), una tolta dal form si cancella, una nuova si inserisce
|
||||
* con `registraMisure` (fonte 'manuale': resta l'unico INSERT di `misure`, vedi misure.ts).
|
||||
*
|
||||
* Rifiuta PRIMA di scrivere: nessuna misura, un test_id fuori dal registro o non fisico
|
||||
* (stesso criterio di `salvaCheckup`), una sessione inesistente, o una sessione che non è
|
||||
* un check-up (un questionario non si corregge da qui).
|
||||
*/
|
||||
export function correggiCheckup(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessioneId: number; 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 sessione = db.prepare(
|
||||
`SELECT id, client_code, data, tipo FROM sessioni WHERE id = ?`
|
||||
).get(input.sessioneId) as
|
||||
{ id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario' } | undefined;
|
||||
if (!sessione) {
|
||||
return { ok: false, errore: `Sessione inesistente: ${input.sessioneId}` };
|
||||
}
|
||||
if (sessione.tipo !== 'checkup') {
|
||||
return { ok: false, errore: 'Solo un check-up si corregge da qui, non un questionario.' };
|
||||
}
|
||||
|
||||
// Solo le misure fisiche (f_*) della sessione entrano nel confronto: di tipo checkup non
|
||||
// ce ne sono altre, ma per sicurezza (e per lo stesso motivo di salvaCheckup) una misura
|
||||
// non f_* eventualmente presente non si tocca.
|
||||
const esistenti = (db.prepare(
|
||||
`SELECT id, test_id, valore_num, unita FROM misure WHERE sessione_id = ?`
|
||||
).all(input.sessioneId) as { id: number; test_id: string; valore_num: number | null; unita: string | null }[])
|
||||
.filter((r) => r.test_id.startsWith('f_'));
|
||||
const esistentiPerTestId = new Map(esistenti.map((r) => [r.test_id, r]));
|
||||
const nuoviPerTestId = new Map(input.misure.map((m) => [m.test_id, m]));
|
||||
|
||||
const fuoriRange: string[] = [];
|
||||
const daInserire: MisuraIn[] = [];
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE sessioni SET data = ?, eta_alla_data = ?, operatore = ? WHERE id = ?`
|
||||
).run(input.data, input.eta ?? null, input.operatore, input.sessioneId);
|
||||
|
||||
const tuttiTestId = new Set([...esistentiPerTestId.keys(), ...nuoviPerTestId.keys()]);
|
||||
for (const testId of tuttiTestId) {
|
||||
const vecchia = esistentiPerTestId.get(testId);
|
||||
const nuova = nuoviPerTestId.get(testId);
|
||||
|
||||
if (vecchia && !nuova) {
|
||||
db.prepare(`DELETE FROM misure WHERE id = ?`).run(vecchia.id); // tolto dal form
|
||||
continue;
|
||||
}
|
||||
if (!vecchia && nuova) {
|
||||
daInserire.push(nuova); // nuovo campo compilato: un solo INSERT via registraMisure
|
||||
continue;
|
||||
}
|
||||
if (vecchia && nuova) {
|
||||
const invariato = (vecchia.valore_num ?? null) === (nuova.valore_num ?? null);
|
||||
if (invariato) continue; // la riga resta com'è: mantiene verifica/verificata_*
|
||||
|
||||
const fuori = fuoriRangeDi(db, testId, nuova.valore_num);
|
||||
if (fuori) fuoriRange.push(testId);
|
||||
db.prepare(
|
||||
`UPDATE misure SET valore_num = ?, unita = ?, fuori_range = ?,
|
||||
verifica = NULL, verificata_da = NULL, verificata_at = NULL
|
||||
WHERE id = ?`
|
||||
).run(nuova.valore_num ?? null, nuova.unita ?? null, fuori ? 1 : 0, vecchia.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (daInserire.length > 0) {
|
||||
const { fuoriRange: fuoriNuove } = registraMisure(db, input.sessioneId, 'manuale', daInserire);
|
||||
fuoriRange.push(...fuoriNuove);
|
||||
}
|
||||
|
||||
// Ricalcolo: la sessione stessa più ogni sessione del cliente dalla posizione minima fra
|
||||
// quella vecchia e quella nuova (stesso criterio di verificaMisura, misure.ts) — l'id
|
||||
// non cambia con una correzione, solo la data può spostare la sessione: il minimo copre
|
||||
// entrambi i lati.
|
||||
const dataMin = input.data < sessione.data ? input.data : sessione.data;
|
||||
const daRicalcolare = db.prepare(
|
||||
`SELECT id FROM sessioni
|
||||
WHERE client_code = ? AND (data > ? OR (data = ? AND id >= ?))
|
||||
ORDER BY data ASC, id ASC`
|
||||
).all(sessione.client_code, dataMin, dataMin, input.sessioneId) as { id: number }[];
|
||||
|
||||
for (const s of daRicalcolare) {
|
||||
salvaScore(db, s.id, calcolaSessione(db, s.id));
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
return { ok: true, sessioneId: input.sessioneId, fuoriRange };
|
||||
}
|
||||
|
||||
export type SessioneElenco = {
|
||||
id: number;
|
||||
data: string;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Il giornale di bordo per cliente (#16, solo staff — il cliente non lo vede): una nota di
|
||||
// testo libero legata a una data, che il trainer scrive per tenere la storia del percorso.
|
||||
// Stesso vincolo di gestionale.ts: solo il database clinico (vedi db.ts), mai un nome — un
|
||||
// cliente qui è solo un client_code, mai un nome. Nessuna voce automatica (decisione di
|
||||
// Adriano, 27/09/2026): si aggiunge e si cancella, non si modifica — una nota sbagliata si
|
||||
// cancella e si riscrive, così non c'è uno storico di modifiche da tenere.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
|
||||
// Esportato (#13, passo 3): l'API del check-up valida la nota PRIMA di salvare, con lo
|
||||
// stesso limite di aggiungiVoce — un numero duplicato qui e lì marcirebbe silenziosamente.
|
||||
export const TESTO_MAX = 5000;
|
||||
|
||||
export type VoceGiornale = {
|
||||
id: number;
|
||||
data: string;
|
||||
autore: string;
|
||||
testo: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/** Le voci di un cliente, più recente prima (data DESC, a parità id DESC — stesso criterio
|
||||
* di sessioniDi in gestionale.ts). */
|
||||
export function vociDelGiornale(db: Database.Database, clientCode: string): VoceGiornale[] {
|
||||
return db.prepare(
|
||||
`SELECT id, data, autore, testo, created_at FROM giornale
|
||||
WHERE client_code = ? ORDER BY data DESC, id DESC`
|
||||
).all(clientCode) as VoceGiornale[];
|
||||
}
|
||||
|
||||
export type EsitoAggiunta = { ok: true; id: number } | { ok: false; errore: string };
|
||||
|
||||
/**
|
||||
* Aggiunge una voce al giornale. Rifiuta PRIMA di scrivere: testo vuoto dopo trim, testo
|
||||
* troppo lungo, data malformata o nel futuro, cliente senza soggetto in longevity — stesso
|
||||
* schema di validazione di salvaCheckup (gestionale.ts).
|
||||
*/
|
||||
export function aggiungiVoce(
|
||||
db: Database.Database,
|
||||
input: { clientCode: string; data: string; autore: string; testo: string }
|
||||
): EsitoAggiunta {
|
||||
const testo = input.testo.trim();
|
||||
if (testo === '') {
|
||||
return { ok: false, errore: 'Il testo della nota non può essere vuoto.' };
|
||||
}
|
||||
if (testo.length > TESTO_MAX) {
|
||||
return { ok: false, errore: `Il testo della nota non può superare ${TESTO_MAX} caratteri.` };
|
||||
}
|
||||
if (!REGEX_DATA.test(input.data)) {
|
||||
return { ok: false, errore: 'Data non valida (formato AAAA-MM-GG).' };
|
||||
}
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
if (input.data > oggi) {
|
||||
return { ok: false, errore: 'La data della nota non può essere nel futuro.' };
|
||||
}
|
||||
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
|
||||
if (!soggetto) {
|
||||
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
|
||||
}
|
||||
|
||||
const r = db.prepare(
|
||||
`INSERT INTO giornale (client_code, data, autore, testo) VALUES (?, ?, ?, ?)`
|
||||
).run(input.clientCode, input.data, input.autore, testo);
|
||||
return { ok: true, id: Number(r.lastInsertRowid) };
|
||||
}
|
||||
|
||||
export type EsitoCancellazione = { ok: true; clientCode: string } | { ok: false; errore: string };
|
||||
|
||||
/**
|
||||
* Cancella una voce: solo chi l'ha scritta o un admin può farlo (decisione di Adriano,
|
||||
* 27/09/2026) — un altro trainer non cancella la nota di un collega. Restituisce il
|
||||
* client_code della voce cancellata, letto dal database, per il redirect alla scheda giusta
|
||||
* — stesso principio di verificaMisura (misure.ts): l'id non porta con sé il fascicolo, lo
|
||||
* si legge da dove è scritto.
|
||||
*/
|
||||
export function cancellaVoce(
|
||||
db: Database.Database,
|
||||
input: { id: number; utente: string; ruolo: string }
|
||||
): EsitoCancellazione {
|
||||
const voce = db.prepare(
|
||||
`SELECT client_code, autore FROM giornale WHERE id = ?`
|
||||
).get(input.id) as { client_code: string; autore: string } | undefined;
|
||||
if (!voce) {
|
||||
return { ok: false, errore: `Voce inesistente: ${input.id}` };
|
||||
}
|
||||
if (voce.autore !== input.utente && input.ruolo !== 'admin') {
|
||||
return { ok: false, errore: 'Solo l\'autore della nota o un admin possono cancellarla.' };
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM giornale WHERE id = ?`).run(input.id);
|
||||
return { ok: true, clientCode: voce.client_code };
|
||||
}
|
||||
@@ -25,6 +25,19 @@ export function apriSessione(
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
/**
|
||||
* True se un valore numerico esce dal range di plausibilità del test (registro_test).
|
||||
* Unico punto di calcolo: usato da `registraMisure` all'inserimento e da `correggiCheckup`
|
||||
* (gestionale.ts, #12) alla correzione, così il confronto min/max non vive in due posti.
|
||||
*/
|
||||
export function fuoriRangeDi(db: Database.Database, testId: string, valoreNum: number | undefined): boolean {
|
||||
if (valoreNum === undefined) return false;
|
||||
const r = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`).get(testId) as
|
||||
{ range_min: number | null; range_max: number | null };
|
||||
return (r.range_min !== null && valoreNum < r.range_min) ||
|
||||
(r.range_max !== null && valoreNum > r.range_max);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'unico punto in cui si scrive nella tabella `misure`.
|
||||
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
|
||||
@@ -47,7 +60,6 @@ export function registraMisure(
|
||||
}
|
||||
}
|
||||
|
||||
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
@@ -56,17 +68,10 @@ export function registraMisure(
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const m of misure) {
|
||||
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null };
|
||||
let fuori = 0;
|
||||
if (m.valore_num !== undefined) {
|
||||
if ((r.range_min !== null && m.valore_num < r.range_min) ||
|
||||
(r.range_max !== null && m.valore_num > r.range_max)) {
|
||||
fuori = 1;
|
||||
fuoriRange.push(m.test_id);
|
||||
}
|
||||
}
|
||||
const fuori = fuoriRangeDi(db, m.test_id, m.valore_num);
|
||||
if (fuori) fuoriRange.push(m.test_id);
|
||||
ins.run(sessioneId, sess.client_code, m.test_id,
|
||||
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori);
|
||||
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori ? 1 : 0);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
@@ -17,60 +17,156 @@
|
||||
* 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.
|
||||
*
|
||||
* REGOLA DEI GRUPPI (issue #13, manuale di Nicola del 27/09/2026): alcune sotto-metriche
|
||||
* hanno più test alternativi (es. handgrip = media dx/sx oggi, o il vecchio f_handgrip_kg
|
||||
* storico). Per ognuna si considerano solo le alternative COMPLETE (tutti gli ingressi
|
||||
* presenti, età/peso/altezza comprese quando la funzione le richiede — un'alternativa
|
||||
* "solo registrata", senza tabella nel manuale, non entra mai nel gruppo), e fra quelle si
|
||||
* sceglie la cui misura più recente vince (`recenza`, da `recenzaValideAlla` in
|
||||
* `motore/index.ts`: 0 = più recente). A parità — o quando `recenza` non è passata, cioè il
|
||||
* comportamento di oggi — vince l'ordine in cui le alternative sono elencate nel gruppo.
|
||||
* Vedi `sceglieAlternativa` più sotto.
|
||||
*
|
||||
* 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.
|
||||
* GLI che non esiste ancora), 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. (Milfit, 2km walk e sit-to-stand erano qui
|
||||
* fino al #13: dal manuale hanno una tabella di riferimento e sono entrati nella regola dei
|
||||
* gruppi qui sotto.)
|
||||
*/
|
||||
|
||||
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,
|
||||
TIERS_SQUAT_M, TIERS_SQUAT_F, TIERS_BENCH_M, TIERS_BENCH_F, scoreSitToStand, scorePlank,
|
||||
scoreGrassoPercento, scoreMuscoloPercento, scoreWhr, scoreVo2max, vo2maxDaMutt,
|
||||
vo2maxDaMilfit, vo2maxDa2kmWalk, vo2maxDaStepTest, scoreSpo2, scorePressione,
|
||||
scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla,
|
||||
} from './test-fisici';
|
||||
|
||||
export type { Sesso };
|
||||
|
||||
/** Un'alternativa possibile per una sotto-metrica di gruppo: `valore` è `undefined` se
|
||||
* mancava anche un solo ingresso (alternativa incompleta, non entra nel gruppo);
|
||||
* `idRecenza` sono i test_id "propri" di questa alternativa — non le variabili di contesto
|
||||
* condivise come peso/età/altezza — usati per confrontarne la freschezza. */
|
||||
type Alternativa = { valore: number | undefined; idRecenza: string[] };
|
||||
|
||||
/**
|
||||
* Sceglie, fra le alternative COMPLETE di un gruppo, quella con la misura più recente:
|
||||
* la posizione di un'alternativa è il MASSIMO (il più vecchio) fra le posizioni di
|
||||
* `recenza` dei suoi `idRecenza` — un'alternativa è fresca quanto il suo ingresso più
|
||||
* vecchio, non quanto il più nuovo. Senza `recenza` (firma opzionale di
|
||||
* `sottoDominiFisici`, o nessuno dei suoi id in mappa) vale solo l'ordine di elencazione
|
||||
* (indice più basso vince): è il comportamento di prima del #13, invariato per i
|
||||
* chiamanti che non passano `recenza`.
|
||||
*/
|
||||
function sceglieAlternativa(alternative: Alternativa[], recenza?: Map<string, number>): number | undefined {
|
||||
let scelto: { valore: number; posizione: number; indice: number } | undefined;
|
||||
alternative.forEach((alt, indice) => {
|
||||
if (alt.valore === undefined) return; // alternativa incompleta: esclusa dal gruppo
|
||||
let posizione = indice;
|
||||
if (recenza) {
|
||||
let massimo = -1;
|
||||
for (const id of alt.idRecenza) {
|
||||
const p = recenza.get(id);
|
||||
if (p !== undefined && p > massimo) massimo = p;
|
||||
}
|
||||
if (massimo !== -1) posizione = massimo;
|
||||
}
|
||||
if (!scelto || posizione < scelto.posizione || (posizione === scelto.posizione && indice < scelto.indice)) {
|
||||
scelto = { valore: alt.valore, posizione, indice };
|
||||
}
|
||||
});
|
||||
return scelto?.valore;
|
||||
}
|
||||
|
||||
/** Media di due misure, solo se ENTRAMBE sono presenti — un solo lato non produce mai un
|
||||
* punteggio (regola dei gruppi, issue #13). */
|
||||
function mediaCoppia(a: number | undefined, b: number | undefined): number | undefined {
|
||||
return a !== undefined && b !== undefined ? (a + b) / 2 : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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).
|
||||
* `recenza` (opzionale, `recenzaValideAlla`) alimenta la regola dei gruppi: senza, vale solo
|
||||
* l'ordine di elencazione di ogni gruppo, cioè il comportamento di prima del #13.
|
||||
*
|
||||
* 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
|
||||
m: Map<string, number>, eta: number | null, sesso: Sesso, recenza?: Map<string, number>
|
||||
): 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 altezzaCm = m.get('f_altezza_cm');
|
||||
// Nessun BMI di ripiego (DECISIONI d nell'issue): senza peso E altezza, resta `undefined`
|
||||
// e tutto ciò che lo richiede (sit-to-stand, 2km walk) non dà punteggio.
|
||||
const bmi = (pesoKg !== undefined && altezzaCm !== undefined) ? pesoKg / (altezzaCm / 100) ** 2 : undefined;
|
||||
|
||||
// --- Forza & Struttura ----------------------------------------------------
|
||||
// handgrip: media dx/sx (manuale) se entrambi presenti, altrimenti lo storico f_handgrip_kg.
|
||||
const handgripMedia = mediaCoppia(m.get('f_handgrip_dx_kg'), m.get('f_handgrip_sx_kg'));
|
||||
const handgripStorico = m.get('f_handgrip_kg');
|
||||
if (eta !== null) {
|
||||
const handgrip = sceglieAlternativa([
|
||||
{ valore: handgripMedia !== undefined ? scoreHandgrip(handgripMedia, eta, sesso) : undefined,
|
||||
idRecenza: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
|
||||
{ valore: handgripStorico !== undefined ? scoreHandgrip(handgripStorico, eta, sesso) : undefined,
|
||||
idRecenza: ['f_handgrip_kg'] },
|
||||
], recenza);
|
||||
if (handgrip !== undefined) out.handgrip = handgrip;
|
||||
}
|
||||
|
||||
// spinta: panca con bilanciere (carico/rip/peso, tabella TIERS_BENCH) oppure push-up.
|
||||
const pancaBilCarico = m.get('f_panca_bil_carico_kg');
|
||||
const pancaBilRip = m.get('f_panca_bil_rip');
|
||||
const pancaValore = (pancaBilCarico !== undefined && pesoKg !== undefined && pancaBilRip !== undefined)
|
||||
? scoreSollevamentoSuPeso(pancaBilCarico, pesoKg, pancaBilRip, TIERS_BENCH_M, TIERS_BENCH_F, sesso)
|
||||
: undefined;
|
||||
const pushup = m.get('f_pushup_rip');
|
||||
const pushupValore = (pushup !== undefined && eta !== null) ? scorePushup(pushup, eta, sesso) : undefined;
|
||||
const spinta = sceglieAlternativa([
|
||||
{ valore: pancaValore, idRecenza: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
|
||||
{ valore: pushupValore, idRecenza: ['f_pushup_rip'] },
|
||||
], recenza);
|
||||
if (spinta !== undefined) out.spinta = spinta;
|
||||
|
||||
// trazione: invariata (solo f_row). f_trazioni_rip si registra ma non dà punteggio
|
||||
// (nessuna tabella di riferimento nel manuale, DECISIONI a).
|
||||
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);
|
||||
}
|
||||
|
||||
// arti_inferiori: squat (invariato) oppure sit-to-stand (rip, età, sesso, bmi — SENZA
|
||||
// ripiego: bmi assente = alternativa incompleta).
|
||||
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);
|
||||
}
|
||||
const squatValore = (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined)
|
||||
? scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso)
|
||||
: undefined;
|
||||
const sitToStandRip = m.get('f_sit_to_stand_rip');
|
||||
const sitToStandValore = (sitToStandRip !== undefined && eta !== null && bmi !== undefined)
|
||||
? scoreSitToStand(sitToStandRip, eta, sesso, bmi)
|
||||
: undefined;
|
||||
const artiInferiori = sceglieAlternativa([
|
||||
{ valore: squatValore, idRecenza: ['f_squat_carico_kg', 'f_squat_rip'] },
|
||||
{ valore: sitToStandValore, idRecenza: ['f_sit_to_stand_rip'] },
|
||||
], recenza);
|
||||
if (artiInferiori !== undefined) out.arti_inferiori = artiInferiori;
|
||||
|
||||
// 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.
|
||||
// f_plank_ginocchia_sec (ginocchia) si registra ma non dà punteggio (DECISIONI a).
|
||||
const plank = m.get('f_plank_sec');
|
||||
if (plank !== undefined) {
|
||||
const punteggio = scorePlank(plank, sesso);
|
||||
@@ -89,16 +185,29 @@ export function sottoDominiFisici(
|
||||
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).
|
||||
// vo2max: diretto, MUTT, MILFIT, 2km walk (UKK) o step test — in quest'ordine di
|
||||
// priorità, la regola dei gruppi sceglie fra le alternative complete quella più recente.
|
||||
const vo2Diretto = m.get('f_vo2max');
|
||||
const muttVam = m.get('f_mutt_vam');
|
||||
const milfitWatt = m.get('f_milfit_watt');
|
||||
const walk2kmMin = m.get('f_walk2km_min');
|
||||
const walk2kmFc = m.get('f_walk2km_fc_bpm');
|
||||
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);
|
||||
|
||||
const vo2MuttValore = muttVam !== undefined ? vo2maxDaMutt(muttVam) : undefined;
|
||||
const vo2MilfitValore = (milfitWatt !== undefined && pesoKg !== undefined)
|
||||
? vo2maxDaMilfit(milfitWatt, pesoKg) : undefined;
|
||||
const vo2WalkValore = (walk2kmMin !== undefined && walk2kmFc !== undefined && eta !== null && bmi !== undefined)
|
||||
? vo2maxDa2kmWalk(walk2kmMin, walk2kmFc, eta, bmi) : undefined;
|
||||
const vo2StepValore = stepHr !== undefined ? vo2maxDaStepTest(stepHr, sesso) : undefined;
|
||||
|
||||
const vo2max = sceglieAlternativa([
|
||||
{ valore: vo2Diretto, idRecenza: ['f_vo2max'] },
|
||||
{ valore: vo2MuttValore, idRecenza: ['f_mutt_vam'] },
|
||||
{ valore: vo2MilfitValore, idRecenza: ['f_milfit_watt'] },
|
||||
{ valore: vo2WalkValore, idRecenza: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
|
||||
{ valore: vo2StepValore, idRecenza: ['f_step_hr_recovery'] },
|
||||
], recenza);
|
||||
if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso);
|
||||
|
||||
const spo2 = m.get('f_spo2_pct');
|
||||
@@ -116,18 +225,47 @@ export function sottoDominiFisici(
|
||||
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);
|
||||
// hrr: MUTT (FC max - FC dopo 1', manuale) oppure lo storico f_hrr_1min_bpm (già un calo).
|
||||
const muttFcMax = m.get('f_mutt_fc_max_bpm');
|
||||
const muttFc1min = m.get('f_mutt_fc_1min_bpm');
|
||||
const hrrMuttValore = (muttFcMax !== undefined && muttFc1min !== undefined)
|
||||
? scoreRecuperoCardiaco(muttFcMax - muttFc1min) : undefined;
|
||||
const hrrStorico = m.get('f_hrr_1min_bpm');
|
||||
const hrrStoricoValore = hrrStorico !== undefined ? scoreRecuperoCardiaco(hrrStorico) : undefined;
|
||||
const hrr = sceglieAlternativa([
|
||||
{ valore: hrrMuttValore, idRecenza: ['f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm'] },
|
||||
{ valore: hrrStoricoValore, idRecenza: ['f_hrr_1min_bpm'] },
|
||||
], recenza);
|
||||
if (hrr !== undefined) out.hrr = hrr;
|
||||
|
||||
// --- Stabilità & Mobilità Funzionale ------------------------------------------
|
||||
const flamingo = m.get('f_flamingo_cadute');
|
||||
if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo);
|
||||
// flamingo: media dx/sx a occhi APERTI (la tabella Eurofit è a occhi aperti, DECISIONI b;
|
||||
// occhi chiusi si registra ma non dà punteggio) oppure lo storico f_flamingo_cadute.
|
||||
const flamingoOaMedia = mediaCoppia(m.get('f_flamingo_oa_dx'), m.get('f_flamingo_oa_sx'));
|
||||
const flamingoOaValore = flamingoOaMedia !== undefined ? scoreFlamingo(flamingoOaMedia) : undefined;
|
||||
const flamingoCaduteStorico = m.get('f_flamingo_cadute');
|
||||
const flamingoStoricoValore = flamingoCaduteStorico !== undefined ? scoreFlamingo(flamingoCaduteStorico) : undefined;
|
||||
const flamingo = sceglieAlternativa([
|
||||
{ valore: flamingoOaValore, idRecenza: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
|
||||
{ valore: flamingoStoricoValore, idRecenza: ['f_flamingo_cadute'] },
|
||||
], recenza);
|
||||
if (flamingo !== undefined) out.flamingo = 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);
|
||||
// back_scratch: media dx/sx (manuale) oppure lo storico f_back_scratch_cm.
|
||||
const backMedia = mediaCoppia(m.get('f_back_scratch_dx_cm'), m.get('f_back_scratch_sx_cm'));
|
||||
const backStorico = m.get('f_back_scratch_cm');
|
||||
if (eta !== null) {
|
||||
const backScratch = sceglieAlternativa([
|
||||
{ valore: backMedia !== undefined ? scoreBackScratch(backMedia, eta, sesso) : undefined,
|
||||
idRecenza: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm'] },
|
||||
{ valore: backStorico !== undefined ? scoreBackScratch(backStorico, eta, sesso) : undefined,
|
||||
idRecenza: ['f_back_scratch_cm'] },
|
||||
], recenza);
|
||||
if (backScratch !== undefined) out.back_scratch = backScratch;
|
||||
}
|
||||
|
||||
const outreach = m.get('f_spalla_outreach_deg');
|
||||
const buckling = m.get('f_spalla_buckling_deg');
|
||||
|
||||
@@ -162,21 +162,37 @@ export const FINESTRA_GIORNI: number | null = null;
|
||||
* 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> {
|
||||
/** Una riga grezza di `misure`, con la sessione di provenienza: usata sia da
|
||||
* `misureValideAlla` che da `recenzaValideAlla` (stessa query, stesso ORDER BY). */
|
||||
type RigaMisuraGrezza = { test_id: string; valore_num: number; data: string; sessione_id: number };
|
||||
|
||||
/** Fattorizza la query condivisa fra `misureValideAlla` e `recenzaValideAlla`: stessa
|
||||
* sessione, stesse righe, stesso ordine. Aggiunto `s2.id AS sessione_id` (assente
|
||||
* nella query originale) perche' `recenzaValideAlla` deve sapere DA QUALE sessione viene
|
||||
* ogni riga, non solo la data — due sessioni nello stesso giorno sono comunque due
|
||||
* posizioni di recenza diverse (lo stesso criterio di `ORDER BY s2.id DESC`). */
|
||||
function righeMisureGrezze(
|
||||
db: Database.Database, sessioneId: number
|
||||
): { sessione: { client_code: string; data: string; id: number }; righe: RigaMisuraGrezza[] } {
|
||||
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
|
||||
`SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data, s2.id AS sessione_id
|
||||
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 }[];
|
||||
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as RigaMisuraGrezza[];
|
||||
|
||||
return { sessione, righe };
|
||||
}
|
||||
|
||||
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
|
||||
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
|
||||
|
||||
const mappa = new Map<string, number>();
|
||||
for (const r of righe) {
|
||||
@@ -193,6 +209,39 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map
|
||||
return mappa;
|
||||
}
|
||||
|
||||
/**
|
||||
* La posizione di recenza di ogni test_id (0 = il piu' recente), sullo STESSO ordine di
|
||||
* `misureValideAlla` (`righeMisureGrezze`: data di sessione DESC, poi id di sessione DESC).
|
||||
* Non e' una data: e' un rango relativo fra le SESSIONI (data, sessione_id) di questo
|
||||
* cliente fino a questa sessione compresa — due test_id misurati nella stessa sessione
|
||||
* hanno la STESSA posizione (sono ugualmente "recenti": la regola dei gruppi di
|
||||
* `sottoDominiFisici`, issue #13, rompe quel pareggio con l'ordine di priorita' del
|
||||
* gruppo, non con l'id di riga in `misure`). Serve a scegliere, fra piu' alternative
|
||||
* complete per una stessa sotto-metrica (es. panca vs push-up), quella la cui misura piu'
|
||||
* recente vince.
|
||||
*/
|
||||
export function recenzaValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
|
||||
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
|
||||
|
||||
const mappa = new Map<string, number>();
|
||||
let posizione = -1;
|
||||
let chiaveSessionePrecedente: string | null = null;
|
||||
for (const r of righe) {
|
||||
if (FINESTRA_GIORNI !== null) {
|
||||
const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000;
|
||||
if (giorni > FINESTRA_GIORNI) continue;
|
||||
}
|
||||
if (mappa.has(r.test_id)) continue; // gia' assegnata la sua posizione (la piu' recente)
|
||||
const chiaveSessione = `${r.data}|${r.sessione_id}`;
|
||||
if (chiaveSessione !== chiaveSessionePrecedente) {
|
||||
posizione++;
|
||||
chiaveSessionePrecedente = chiaveSessione;
|
||||
}
|
||||
mappa.set(r.test_id, posizione);
|
||||
}
|
||||
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
|
||||
@@ -209,6 +258,9 @@ export function sottoDominiDellaSessione(db: Database.Database, sessioneId: numb
|
||||
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||
|
||||
const misurate = misureValideAlla(db, sessioneId);
|
||||
// Recenza per test_id (issue #13): serve alla regola dei gruppi di sottoDominiFisici,
|
||||
// per scegliere fra alternative COMPLETE quella con la misura piu' recente.
|
||||
const recenza = recenzaValideAlla(db, sessioneId);
|
||||
|
||||
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
|
||||
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
|
||||
@@ -234,7 +286,7 @@ export function sottoDominiDellaSessione(db: Database.Database, sessioneId: numb
|
||||
// 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) : {};
|
||||
const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso, recenza) : {};
|
||||
|
||||
return { ...daCurva, ...daFisici };
|
||||
}
|
||||
|
||||
@@ -110,9 +110,13 @@ const QUESTIONARIO: VoceRegistro[] = [
|
||||
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 },
|
||||
// Dismesso il 28/09 (#13): sostituito da f_handgrip_dx_kg/f_handgrip_sx_kg (media dx/sx,
|
||||
// manuale di Nicola). Mai cancellato: le sessioni passate lo usano ancora (limite #12);
|
||||
// sottoDominiFisici lo legge come alternativa storica in fisici.ts.
|
||||
{ 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: 100, attivo_da: DA, attivo_a: '2026-09-28' },
|
||||
// Etichetta corretta (#13): il manuale prevede push-up sulle ginocchia per le donne.
|
||||
{ test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime (donne sulle ginocchia)', 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 },
|
||||
@@ -144,18 +148,87 @@ const FISICI: VoceRegistro[] = [
|
||||
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 },
|
||||
// Dismesso il 28/09 (#13): sostituito da f_mutt_fc_max_bpm - f_mutt_fc_1min_bpm (manuale).
|
||||
// Mai cancellato, resta come alternativa storica in fisici.ts (hrr).
|
||||
{ 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 },
|
||||
asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA, attivo_a: '2026-09-28' },
|
||||
// Dismesso il 28/09 (#13): sostituito da media f_flamingo_oa_dx/f_flamingo_oa_sx (occhi
|
||||
// aperti, manuale). Mai cancellato, resta come alternativa storica in fisici.ts (flamingo).
|
||||
{ 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 },
|
||||
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA, attivo_a: '2026-09-28' },
|
||||
{ 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 },
|
||||
// Dismesso il 28/09 (#13): sostituito da media f_back_scratch_dx_cm/f_back_scratch_sx_cm.
|
||||
// Mai cancellato, resta come alternativa storica in fisici.ts (back_scratch).
|
||||
{ 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 },
|
||||
asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA, attivo_a: '2026-09-28' },
|
||||
{ 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 },
|
||||
|
||||
// Voci nuove del 28/09 (#13), dal manuale batteria test completo di Nicola (27/09/2026).
|
||||
// Alcune alimentano sottoDominiFisici (vedi motore/fisici.ts), altre si registrano soltanto
|
||||
// perche' il manuale non da' una tabella di riferimento per loro (DECISIONI a nell'issue):
|
||||
// panca manubri, trazioni, plank sulle ginocchia, 6MWT, flamingo occhi chiusi, FC per step
|
||||
// MUTT, FC a riposo, FC MUTT a 2'.
|
||||
{ test_id: 'f_altezza_cm', etichetta: 'Altezza', unita: 'cm', tipo_valore: 'num',
|
||||
asse: 'Composizione Corporea', range_min: 100, range_max: 230, attivo_da: DA },
|
||||
{ test_id: 'f_fc_riposo_bpm', etichetta: 'Frequenza cardiaca a riposo', unita: 'bpm', tipo_valore: 'num',
|
||||
asse: 'Cardio-Respiratorio', range_min: 30, range_max: 130, attivo_da: DA },
|
||||
{ test_id: 'f_back_scratch_dx_cm', etichetta: 'Back scratch (mobilità spalle), lato destro', unita: 'cm',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'f_back_scratch_sx_cm', etichetta: 'Back scratch (mobilità spalle), lato sinistro', unita: 'cm',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'f_flamingo_oa_dx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato destro',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
|
||||
{ test_id: 'f_flamingo_oa_sx', etichetta: 'Test del fenicottero, cadute occhi aperti, lato sinistro',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
|
||||
{ test_id: 'f_flamingo_oc_dx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato destro',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
|
||||
{ test_id: 'f_flamingo_oc_sx', etichetta: 'Test del fenicottero, cadute occhi chiusi, lato sinistro',
|
||||
tipo_valore: 'num', asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 60, attivo_da: DA },
|
||||
{ test_id: 'f_handgrip_dx_kg', etichetta: 'Forza di presa (handgrip), lato destro', unita: 'kg',
|
||||
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
|
||||
{ test_id: 'f_handgrip_sx_kg', etichetta: 'Forza di presa (handgrip), lato sinistro', unita: 'kg',
|
||||
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 5, range_max: 100, attivo_da: DA },
|
||||
{ test_id: 'f_panca_man_carico_kg', etichetta: 'Panca con manubri, carico (per manubrio)', unita: 'kg',
|
||||
tipo_valore: 'num', asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA },
|
||||
{ test_id: 'f_panca_man_rip', etichetta: 'Panca con manubri, ripetizioni', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'f_panca_bil_carico_kg', etichetta: 'Panca con bilanciere, carico', unita: 'kg', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA },
|
||||
{ test_id: 'f_panca_bil_rip', etichetta: 'Panca con bilanciere, ripetizioni', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 1, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'f_trazioni_rip', etichetta: 'Trazioni alla sbarra, ripetizioni massime', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 0, range_max: 60, attivo_da: DA },
|
||||
// Etichetta corretta (#13): il test dura 1 minuto, non 30 secondi.
|
||||
{ test_id: 'f_sit_to_stand_rip', etichetta: 'Sit-to-stand, ripetizioni in 1 minuto (sedia 43-45 cm)', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 0, range_max: 80, attivo_da: DA },
|
||||
{ test_id: 'f_plank_ginocchia_sec', etichetta: 'Plank sulle ginocchia, tenuta', unita: 'sec', tipo_valore: 'num',
|
||||
asse: 'Forza & Struttura', range_min: 0, range_max: 600, attivo_da: DA },
|
||||
{ test_id: 'f_6mwt_m', etichetta: '6 Minute Walk Test, distanza percorsa', unita: 'm', tipo_valore: 'num',
|
||||
asse: 'Cardio-Respiratorio', range_min: 50, range_max: 1200, attivo_da: DA },
|
||||
{ test_id: 'f_walk2km_min', etichetta: 'Camminata 2 km (UKK), tempo', unita: 'min', tipo_valore: 'num',
|
||||
asse: 'Cardio-Respiratorio', range_min: 8, range_max: 40, attivo_da: DA },
|
||||
{ test_id: 'f_walk2km_fc_bpm', etichetta: 'Camminata 2 km (UKK), frequenza cardiaca finale', unita: 'bpm',
|
||||
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 60, range_max: 220, attivo_da: DA },
|
||||
{ test_id: 'f_milfit_watt', etichetta: 'MILFIT, potenza', unita: 'watt', tipo_valore: 'num',
|
||||
asse: 'Cardio-Respiratorio', range_min: 20, range_max: 500, attivo_da: DA },
|
||||
{ test_id: 'f_mutt_fc_max_bpm', etichetta: 'MUTT, frequenza cardiaca massima', unita: 'bpm', tipo_valore: 'num',
|
||||
asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
|
||||
{ test_id: 'f_mutt_fc_1min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 1\' di recupero', unita: 'bpm',
|
||||
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
|
||||
{ test_id: 'f_mutt_fc_2min_bpm', etichetta: 'MUTT, frequenza cardiaca dopo 2\' di recupero', unita: 'bpm',
|
||||
tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 230, attivo_da: DA },
|
||||
// Gli otto step del MUTT, un km/h alla volta da 7,5 a 14,5: solo registrati (DECISIONI a),
|
||||
// nessuna tabella di riferimento nel manuale per la FC punto per punto.
|
||||
...([7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5].map((kmh): VoceRegistro => ({
|
||||
test_id: `f_mutt_fc_${String(kmh).replace('.', '_')}`,
|
||||
etichetta: `MUTT, frequenza cardiaca allo step ${kmh} km/h`,
|
||||
unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio',
|
||||
range_min: 40, range_max: 230, attivo_da: DA,
|
||||
}))),
|
||||
];
|
||||
|
||||
export function seedRegistro(db: Database.Database): void {
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 {
|
||||
salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
|
||||
type SezioneCheckup,
|
||||
} from '../../../../lib/longevity/gestionale';
|
||||
import { aggiungiVoce, TESTO_MAX as NOTA_TESTO_MAX } from '../../../../lib/longevity/giornale';
|
||||
import type { MisuraIn } from '../../../../lib/longevity/misure';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -52,39 +56,118 @@ export function misureDalForm(
|
||||
return { ok: true, misure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
|
||||
* `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non
|
||||
* ha ancora marcato nulla, o un refuso) resta assente dalla mappa: `testIdScartatiDalleAlternative`
|
||||
* non scarta NULLA per quella sezione, invece di ripiegare sulla prima alternativa e buttare
|
||||
* via in silenzio campi compilati di un'altra — correzione del passo 3 su #13.
|
||||
*/
|
||||
function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> {
|
||||
const scelte = new Map<string, string>();
|
||||
for (const s of sezioni) {
|
||||
if (s.tipo !== 'alternative') continue;
|
||||
const v = form.get(`alt_${s.chiave}`);
|
||||
if (typeof v === 'string' && v !== '') scelte.set(s.chiave, v);
|
||||
}
|
||||
return scelte;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const clientCode = String(form.get('client_code') ?? '').trim();
|
||||
const clientCodeForm = String(form.get('client_code') ?? '').trim();
|
||||
const data = String(form.get('data') ?? '').trim();
|
||||
// Un sessione_id nel form (issue #12) è una correzione: stesso form, stessa API, solo
|
||||
// l'hidden field in più — checkup.astro lo mette quando arriva con ?sessione=<id>.
|
||||
const sessioneIdRaw = String(form.get('sessione_id') ?? '').trim();
|
||||
const sessioneId = sessioneIdRaw !== '' ? Number(sessioneIdRaw) : null;
|
||||
|
||||
// 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)) {
|
||||
if (!REGEX_CODE.test(clientCodeForm)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
|
||||
}
|
||||
const erroreAllaScheda = (testo: string) =>
|
||||
redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?errore=${encodeURIComponent(testo)}`);
|
||||
|
||||
const db = getLongevityDb();
|
||||
|
||||
// In correzione il client_code per validazioni e redirect è SEMPRE quello letto dalla
|
||||
// sessione nel database, mai quello del form (stesso principio di verifica.ts): un
|
||||
// sessione_id non è un fascicolo che si possa dirottare scrivendo un altro codice cliente.
|
||||
let clientCode = clientCodeForm;
|
||||
if (sessioneId !== null) {
|
||||
if (!Number.isInteger(sessioneId)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione non valida.')}`);
|
||||
}
|
||||
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
|
||||
{ client_code: string } | undefined;
|
||||
if (!sessione) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione inesistente.')}`);
|
||||
}
|
||||
clientCode = sessione.client_code;
|
||||
if (clientCodeForm !== clientCode) {
|
||||
return redirect(
|
||||
`/longevity/gestionale/cliente/${clientCode}?errore=${encodeURIComponent('Codice cliente non coerente con la sessione.')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const erroreAllaScheda = (testo: string) => {
|
||||
const query = new URLSearchParams({ errore: testo });
|
||||
if (sessioneId !== null) query.set('sessione', String(sessioneId));
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?${query.toString()}`);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Sezioni ad alternative (#13): si tengono solo i campi dell'alternativa scelta dalla
|
||||
// radio, i compilati di altre alternative della stessa sezione si scartano — anche se il
|
||||
// trainer li ha riempiti (JS disattivato, o un fieldset lasciato visibile per errore).
|
||||
const { sezioni } = strutturaCheckup(db, data);
|
||||
const scartati = testIdScartatiDalleAlternative(sezioni, sceltePerAlternative(sezioni, form));
|
||||
const misure = parse.misure.filter((m) => !scartati.has(m.test_id));
|
||||
|
||||
// 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);
|
||||
// Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro),
|
||||
// quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge.
|
||||
const nota = String(form.get('nota') ?? '').trim();
|
||||
// Validata PRIMA di salvare (correzione del passo 3 su #13): se aggiungiVoce fallisse DOPO
|
||||
// il check-up (nota troppo lunga), il check-up resterebbe salvato ma la nota andrebbe persa
|
||||
// in silenzio — stesso limite di aggiungiVoce (giornale.ts), non un numero duplicato qui.
|
||||
if (nota.length > NOTA_TESTO_MAX) {
|
||||
return erroreAllaScheda(`Il testo della nota non può superare ${NOTA_TESTO_MAX} caratteri.`);
|
||||
}
|
||||
|
||||
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure });
|
||||
if (sessioneId !== null) {
|
||||
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure });
|
||||
if (!esito.ok) return erroreAllaScheda(esito.errore);
|
||||
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
|
||||
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
|
||||
}
|
||||
|
||||
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure });
|
||||
if (!esito.ok) return erroreAllaScheda(esito.errore);
|
||||
// Voce di giornale SOLO dopo il salvataggio riuscito (#13): la lunghezza è già validata
|
||||
// sopra, la data è la stessa già validata per il check-up — aggiungiVoce qui non dovrebbe
|
||||
// più rifiutare nulla.
|
||||
if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota });
|
||||
|
||||
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
|
||||
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
||||
// Passo 3 → 4 del flusso guidato (#15): solo un check-up NUOVO (mai una correzione, che
|
||||
// non passa da questo ramo) con flusso=nuovo prosegue verso il questionario.
|
||||
const flusso = String(form.get('flusso') ?? '').trim();
|
||||
if (flusso === 'nuovo') params.set('flusso', 'questionario');
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -52,5 +52,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
telefono: telefono || undefined,
|
||||
});
|
||||
|
||||
return redirect(`/longevity/gestionale/cliente/${code}`);
|
||||
// Dopo l'apertura del fascicolo comincia il flusso guidato (#15, passo 1 → 2): prima
|
||||
// consulenza, poi check-up, poi questionario — non più direttamente alla scheda.
|
||||
return redirect(`/longevity/gestionale/cliente/${code}/consulenza?flusso=nuovo`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getLongevityDb } from '../../../../lib/longevity/db';
|
||||
import { salvaConsulenza } from '../../../../lib/longevity/consulenza';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
|
||||
|
||||
const REGEX_CODE = /^ISL-\d{4}$/;
|
||||
|
||||
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
|
||||
|
||||
/**
|
||||
* Salva la scheda di prima consulenza (#15). Autore SEMPRE da `locals.user`, mai dal form —
|
||||
* stesso principio di giornale.ts e checkup.ts. Il flusso guidato (`flusso=nuovo`, passo 1
|
||||
* → 2) prosegue verso il check-up; fuori dal flusso torna alla scheda cliente, sezione
|
||||
* #consulenza dove il form e la lettura vivono.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const clientCode = String(form.get('client_code') ?? '').trim();
|
||||
const flusso = String(form.get('flusso') ?? '').trim();
|
||||
|
||||
if (!REGEX_CODE.test(clientCode)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
|
||||
}
|
||||
|
||||
// Il ritorno in caso di errore resta sulla stessa pagina del form, conservando `flusso`:
|
||||
// altrimenti un errore fuori dal flusso guidato riporterebbe dentro il flusso, o viceversa.
|
||||
const query = new URLSearchParams();
|
||||
if (flusso !== '') query.set('flusso', flusso);
|
||||
const tornaAlModulo = (testo: string) => {
|
||||
const q = new URLSearchParams(query);
|
||||
q.set('errore', testo);
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}/consulenza?${q.toString()}`);
|
||||
};
|
||||
|
||||
const db = getLongevityDb();
|
||||
const autore = locals.user!.username;
|
||||
|
||||
const esito = salvaConsulenza(db, {
|
||||
clientCode,
|
||||
autore,
|
||||
campi: {
|
||||
livello: String(form.get('livello') ?? ''),
|
||||
obiettivoPrimario: String(form.get('obiettivo_primario') ?? ''),
|
||||
obiettivoSpecifico: String(form.get('obiettivo_specifico') ?? ''),
|
||||
dataTarget: String(form.get('data_target') ?? ''),
|
||||
noteMediche: String(form.get('note_mediche') ?? ''),
|
||||
storiaSportiva: String(form.get('storia_sportiva') ?? ''),
|
||||
limitazioni: String(form.get('limitazioni') ?? ''),
|
||||
disponibilitaGiorni: String(form.get('disponibilita_giorni') ?? ''),
|
||||
fasciaOraria: String(form.get('fascia_oraria') ?? ''),
|
||||
noteScreening: String(form.get('note_screening') ?? ''),
|
||||
},
|
||||
});
|
||||
if (!esito.ok) return tornaAlModulo(esito.errore);
|
||||
|
||||
if (flusso === 'nuovo') {
|
||||
// Passo 2 → 3 del flusso guidato (#15): dopo la consulenza si va al check-up.
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?flusso=nuovo`);
|
||||
}
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?consulenza=salvata#consulenza`);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getLongevityDb } from '../../../../lib/longevity/db';
|
||||
import { aggiungiVoce, cancellaVoce } from '../../../../lib/longevity/giornale';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const REGEX_CODE = /^ISL-\d{4}$/;
|
||||
|
||||
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
|
||||
|
||||
/**
|
||||
* Aggiunge o cancella una voce del giornale di bordo (#16, solo staff): un unico endpoint
|
||||
* per le due azioni, come verifica.ts fa per conferma/scarto — un campo `azione` invece di
|
||||
* due rotte. Autore e ruolo SEMPRE da `locals.user`, mai dal form (stesso principio di
|
||||
* checkup.ts e verifica.ts: chi scrive la richiesta non decide chi ne è l'autore).
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const db = getLongevityDb();
|
||||
const autore = locals.user!.username;
|
||||
const ruolo = locals.user!.role;
|
||||
|
||||
const azione = form.get('azione');
|
||||
|
||||
if (azione === 'aggiungi') {
|
||||
const clientCode = String(form.get('client_code') ?? '').trim();
|
||||
// Senza un codice valido non c'è una scheda a cui tornare — stesso trattamento di
|
||||
// checkup.ts: l'unico ritorno sensato è l'indice del gestionale.
|
||||
if (!REGEX_CODE.test(clientCode)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
|
||||
}
|
||||
|
||||
const data = String(form.get('data') ?? '').trim();
|
||||
const testo = String(form.get('testo') ?? '');
|
||||
|
||||
const esito = aggiungiVoce(db, { clientCode, data, autore, testo });
|
||||
if (!esito.ok) {
|
||||
return tornaA(clientCode, `?errore=${encodeURIComponent(esito.errore)}`);
|
||||
}
|
||||
return tornaA(clientCode, '?giornale=aggiunta');
|
||||
}
|
||||
|
||||
if (azione === 'cancella') {
|
||||
const idRaw = form.get('id');
|
||||
const id = typeof idRaw === 'string' ? Number(idRaw) : NaN;
|
||||
if (!Number.isInteger(id)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Voce non valida.')}`);
|
||||
}
|
||||
|
||||
const esito = cancellaVoce(db, { id, utente: autore, ruolo });
|
||||
if (!esito.ok) {
|
||||
// Nessuna voce da cui leggere il client_code (id inesistente, o non c'era già): non
|
||||
// c'è una scheda su cui tornare, come per un id di misura inesistente in verifica.ts.
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent(esito.errore)}`);
|
||||
}
|
||||
return tornaA(esito.clientCode, '?giornale=cancellata');
|
||||
}
|
||||
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Azione non valida.')}`);
|
||||
};
|
||||
|
||||
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
|
||||
|
||||
// Il redirect torna sempre alla sezione #giornale della scheda, dove il form e l'elenco
|
||||
// vivono — così chi aggiunge o cancella una nota si ritrova esattamente dove era.
|
||||
const tornaA = (clientCode: string, query: string): Response =>
|
||||
redirect(`/longevity/gestionale/cliente/${clientCode}${query}#giornale`);
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
import { vociDelGiornale } from '../../../../lib/longevity/giornale';
|
||||
import { consulenzaDi, OBIETTIVI } from '../../../../lib/longevity/consulenza';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -26,6 +28,9 @@ 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 voci = vociDelGiornale(db, cliente.client_code); // #16: giornale di bordo, solo staff
|
||||
const consulenza = consulenzaDi(db, cliente.client_code); // #15: prima consulenza, solo staff
|
||||
const ETICHETTA_OBIETTIVO = new Map(OBIETTIVI.map((o) => [o.chiave, o.etichetta]));
|
||||
|
||||
const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = {
|
||||
checkup: 'Check-up',
|
||||
@@ -36,6 +41,7 @@ const formattaData = (iso: string) =>
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
const salvato = params.get('salvato');
|
||||
const corretto = params.get('corretto'); // #12: redirect di correggiCheckup dopo una correzione
|
||||
const fuoriCount = params.get('fuori');
|
||||
const errore = params.get('errore');
|
||||
const verificata = params.get('verificata');
|
||||
@@ -45,6 +51,17 @@ const MESSAGGIO_VERIFICA: Record<'confermata' | 'scartata', string> = {
|
||||
};
|
||||
const messaggioVerifica =
|
||||
verificata === 'confermata' || verificata === 'scartata' ? MESSAGGIO_VERIFICA[verificata] : null;
|
||||
const giornaleEsito = params.get('giornale'); // #16: 'aggiunta' | 'cancellata', dal redirect dell'API
|
||||
const MESSAGGIO_GIORNALE: Record<'aggiunta' | 'cancellata', string> = {
|
||||
aggiunta: 'Nota aggiunta al giornale di bordo.',
|
||||
cancellata: 'Nota cancellata dal giornale di bordo.',
|
||||
};
|
||||
const messaggioGiornale =
|
||||
giornaleEsito === 'aggiunta' || giornaleEsito === 'cancellata' ? MESSAGGIO_GIORNALE[giornaleEsito] : null;
|
||||
const consulenzaEsito = params.get('consulenza'); // #15: 'salvata', dal redirect dell'API consulenza
|
||||
// Passo 4 del flusso guidato per un cliente nuovo (#15): dopo il check-up si torna qui con
|
||||
// ?flusso=questionario — il questionario resta del cliente, quindi qui c'è solo l'indicazione.
|
||||
const flusso = params.get('flusso');
|
||||
---
|
||||
<Longevity
|
||||
title={`${cliente.cognome} ${cliente.nome}`}
|
||||
@@ -69,7 +86,23 @@ const messaggioVerifica =
|
||||
|
||||
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
|
||||
{corretto && <p class="lg-card">Check-up corretto (sessione #{corretto}): punteggi ricalcolati.</p>}
|
||||
{messaggioVerifica && <p class="lg-card">{messaggioVerifica}</p>}
|
||||
{messaggioGiornale && <p class="lg-card">{messaggioGiornale}</p>}
|
||||
{consulenzaEsito === 'salvata' && <p class="lg-card">Prima consulenza salvata.</p>}
|
||||
{flusso === 'questionario' && (
|
||||
<div class="lg-card">
|
||||
<p class="lg-score__label">Passo 4 di 4 — Questionario</p>
|
||||
{cliente.ha_accesso ? (
|
||||
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro.</p>
|
||||
) : (
|
||||
<>
|
||||
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro: crealo per continuare.</p>
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fuoriCount && (
|
||||
<p class="lg-misura--fuori-range">
|
||||
{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di
|
||||
@@ -84,6 +117,78 @@ const messaggioVerifica =
|
||||
<p>Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}</p>
|
||||
</div>
|
||||
|
||||
<div class="lg-card" id="consulenza">
|
||||
<div class="lg-head--azioni">
|
||||
<p class="lg-score__label">Prima consulenza</p>
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/consulenza`}>
|
||||
{consulenza ? 'Modifica' : 'Compila'}
|
||||
</a>
|
||||
</div>
|
||||
{!consulenza ? (
|
||||
<p class="lg-muted">Non ancora compilata.</p>
|
||||
) : (
|
||||
<>
|
||||
<p>Livello: {consulenza.livello ?? '—'}</p>
|
||||
<p>Obiettivo primario: {consulenza.obiettivo_primario ? ETICHETTA_OBIETTIVO.get(consulenza.obiettivo_primario) ?? consulenza.obiettivo_primario : '—'}</p>
|
||||
<p>Obiettivo specifico: {consulenza.obiettivo_specifico ?? '—'}</p>
|
||||
<p>Data target: {consulenza.data_target ? formattaData(consulenza.data_target) : '—'}</p>
|
||||
<p>Note mediche: {consulenza.note_mediche ?? '—'}</p>
|
||||
<p>Storia sportiva: {consulenza.storia_sportiva ?? '—'}</p>
|
||||
<p>Limitazioni fisiche attuali: {consulenza.limitazioni ?? '—'}</p>
|
||||
<p>Disponibilità: {consulenza.disponibilita_giorni ?? '—'} giorni/settimana</p>
|
||||
<p>Fascia oraria preferita: {consulenza.fascia_oraria ?? '—'}</p>
|
||||
<p>Note screening: {consulenza.note_screening ?? '—'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="lg-card" id="giornale">
|
||||
<p class="lg-score__label">Giornale di bordo</p>
|
||||
<p class="lg-muted">Solo lo staff lo vede. Una nota sbagliata si cancella e si riscrive: non si modifica.</p>
|
||||
|
||||
<form class="lg-form" method="post" action="/api/longevity/gestionale/giornale">
|
||||
<input type="hidden" name="azione" value="aggiungi" />
|
||||
<input type="hidden" name="client_code" value={cliente.client_code} />
|
||||
<label class="lg-field">
|
||||
<span>Data</span>
|
||||
<input type="date" name="data" value={oggi} max={oggi} required />
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Nota</span>
|
||||
<textarea name="testo" rows="3" required></textarea>
|
||||
</label>
|
||||
<div class="lg-form__nav">
|
||||
<button type="submit">Aggiungi nota</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{voci.length === 0 ? (
|
||||
<p class="lg-muted">Nessuna nota ancora.</p>
|
||||
) : (
|
||||
<table class="lg-table">
|
||||
<thead><tr><th>Data</th><th>Autore</th><th>Nota</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{voci.map((v) => (
|
||||
<tr>
|
||||
<td>{formattaData(v.data)}</td>
|
||||
<td>{v.autore}</td>
|
||||
<td style="white-space: pre-wrap;">{v.testo}</td>
|
||||
<td>
|
||||
{(v.autore === Astro.locals.user!.username || Astro.locals.user!.role === 'admin') && (
|
||||
<form method="post" action="/api/longevity/gestionale/giornale">
|
||||
<input type="hidden" name="azione" value="cancella" />
|
||||
<input type="hidden" name="id" value={v.id} />
|
||||
<button type="submit">Cancella</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!referto && (
|
||||
<div class="lg-card lg-card--incompleto">
|
||||
<p>Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.</p>
|
||||
@@ -131,7 +236,9 @@ const messaggioVerifica =
|
||||
<tbody>
|
||||
{sessioni.map((s) => (
|
||||
<tr>
|
||||
<td>{formattaData(s.data)}</td>
|
||||
<td>
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/sessione/${s.id}`}>{formattaData(s.data)}</a>
|
||||
</td>
|
||||
<td>{ETICHETTA_TIPO[s.tipo]}</td>
|
||||
<td>{s.operatore ?? '—'}</td>
|
||||
<td>{s.numeroMisure}</td>
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
// 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.
|
||||
//
|
||||
// Con ?sessione=<id> (#12) lo stesso form diventa una correzione: data e campi
|
||||
// precompilati dalla sessione, hidden sessione_id in più, campi attivi alla data DELLA
|
||||
// SESSIONE (non a oggi) — un test dismesso da allora resterebbe comunque compilabile.
|
||||
import Longevity from '../../../../../layouts/Longevity.astro';
|
||||
import { getLongevityDb } from '../../../../../lib/longevity/db';
|
||||
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
|
||||
import { campiDelCheckup } from '../../../../../lib/longevity/gestionale';
|
||||
import {
|
||||
misureDellaSessione, strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup,
|
||||
} from '../../../../../lib/longevity/gestionale';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -13,45 +19,145 @@ 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 gruppi = campiDelCheckup(getLongevityDb(), oggi);
|
||||
|
||||
const sessioneParam = Astro.url.searchParams.get('sessione');
|
||||
const sessioneId = sessioneParam !== null ? Number(sessioneParam) : null;
|
||||
const sessione = sessioneId !== null && Number.isInteger(sessioneId) ? misureDellaSessione(db, sessioneId) : null;
|
||||
// Una sessione di un altro cliente o non di tipo checkup non è un modulo modificabile da
|
||||
// questa scheda: stesso trattamento di un codice cliente inesistente.
|
||||
if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client_code || sessione.tipo !== 'checkup')) {
|
||||
return new Response('Sessione non trovata', { status: 404 });
|
||||
}
|
||||
|
||||
const dataForm = sessione?.data ?? oggi;
|
||||
const modulo = strutturaCheckup(db, dataForm);
|
||||
const valori = new Map(sessione?.misure.map((m) => [m.test_id, m.valore_num]) ?? []);
|
||||
// Preselezione (#13): in correzione le misure della sessione, in un check-up nuovo l'ultimo
|
||||
// check-up del cliente — altrimenti la prima alternativa (il livello neofita).
|
||||
const valoriPreselezione = sessione
|
||||
? new Map([...valori.entries()].filter((kv): kv is [string, number] => kv[1] !== null))
|
||||
: valoriUltimoCheckup(db, cliente.client_code);
|
||||
const errore = Astro.url.searchParams.get('errore');
|
||||
// Passo 3 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): solo per un
|
||||
// check-up NUOVO — in correzione (?sessione=<id>) non si è mai dentro il flusso guidato.
|
||||
const flusso = !sessione ? Astro.url.searchParams.get('flusso') ?? '' : '';
|
||||
---
|
||||
<Longevity
|
||||
title={`Check-up — ${cliente.cognome} ${cliente.nome}`}
|
||||
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Nuovo check-up']}
|
||||
title={`${sessione ? 'Modifica' : 'Nuovo'} check-up — ${cliente.cognome} ${cliente.nome}`}
|
||||
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, sessione ? 'Modifica check-up' : '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>
|
||||
<h1>{sessione ? 'Modifica check-up' : 'Nuovo check-up'}</h1>
|
||||
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato.</p>
|
||||
{flusso === 'nuovo' && <p class="lg-muted">Passo 3 di 4 — Scheda Rapida</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} />
|
||||
{sessione && <input type="hidden" name="sessione_id" value={sessione.id} />}
|
||||
{flusso === 'nuovo' && <input type="hidden" name="flusso" value="nuovo" />}
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Data</span>
|
||||
<input type="date" name="data" value={oggi} required />
|
||||
<input type="date" name="data" value={dataForm} required />
|
||||
</label>
|
||||
|
||||
{gruppi.map((g) => (
|
||||
{modulo.sezioni.map((s) => (
|
||||
<section class="lg-form__blocco">
|
||||
<h2>{g.asse}</h2>
|
||||
{g.campi.map((c) => (
|
||||
<h2>{s.titolo}</h2>
|
||||
{s.tipo === 'campi' ? s.campi.map((c) => (
|
||||
<label class="lg-field">
|
||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
<input type="text" inputmode="decimal" name={c.id} />
|
||||
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
||||
</label>
|
||||
))}
|
||||
)) : (
|
||||
<div class="lg-form__alternative" data-alt-sezione={s.chiave}>
|
||||
{s.alternative.map((alt) => (
|
||||
<fieldset class="lg-form__alternativa" data-alt-fieldset={alt.chiave}>
|
||||
<legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio" name={`alt_${s.chiave}`} value={alt.chiave}
|
||||
checked={alt.chiave === alternativaSelezionata(s, valoriPreselezione)}
|
||||
/>
|
||||
{alt.nome}
|
||||
</label>
|
||||
</legend>
|
||||
{alt.soglia && <p class="lg-form__soglia">{alt.soglia}</p>}
|
||||
{alt.campi.map((c) => (
|
||||
<label class="lg-field">
|
||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
||||
</label>
|
||||
))}
|
||||
{alt.dettagli && alt.dettagli.length > 0 && (
|
||||
<details>
|
||||
<summary>Frequenza cardiaca per step (7,5-14,5 km/h)</summary>
|
||||
{alt.dettagli.map((c) => (
|
||||
<label class="lg-field">
|
||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
||||
</label>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{modulo.altreMisure.length > 0 && (
|
||||
<section class="lg-form__blocco">
|
||||
<h2>Altre misure</h2>
|
||||
{modulo.altreMisure.map((c) => (
|
||||
<label class="lg-field">
|
||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!sessione && (
|
||||
<section class="lg-form__blocco">
|
||||
<h2>Note operatore</h2>
|
||||
<label class="lg-field">
|
||||
<span>Nota per il giornale di bordo (facoltativa)</span>
|
||||
<textarea name="nota" rows="4" maxlength="5000"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div class="lg-form__nav">
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
||||
<button type="submit">Salva check-up</button>
|
||||
{flusso === 'nuovo' ? (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}?flusso=questionario`}>Salta</a>
|
||||
) : (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
||||
)}
|
||||
<button type="submit">{sessione ? 'Salva correzione' : 'Salva check-up'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Longevity>
|
||||
|
||||
<script>
|
||||
// Nasconde i fieldset delle alternative non scelte (#13): senza JS restano tutti visibili
|
||||
// e il form funziona lo stesso — lo scarto vero è server-side (checkup.ts), questo è solo
|
||||
// per non far compilare al trainer due protocolli della stessa sotto-metrica per sbaglio.
|
||||
document.querySelectorAll<HTMLElement>('[data-alt-sezione]').forEach((sezioneEl) => {
|
||||
const aggiorna = () => {
|
||||
const scelta = sezioneEl.querySelector<HTMLInputElement>('input[type=radio]:checked')?.value;
|
||||
sezioneEl.querySelectorAll<HTMLElement>('[data-alt-fieldset]').forEach((fs) => {
|
||||
fs.hidden = fs.dataset.altFieldset !== scelta;
|
||||
});
|
||||
};
|
||||
sezioneEl.addEventListener('change', aggiorna);
|
||||
aggiorna();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
// La scheda di prima consulenza (#15): livello, obiettivo, note dello screening iniziale.
|
||||
// Precompilata se esiste già — correggere e salvare SOVRASCRIVE, nessuno storico (decisione
|
||||
// di Adriano, 27/09/2026). L'invio va all'API del gestionale, che fa la validazione vera
|
||||
// (pages/api/longevity/gestionale/consulenza.ts) — qui c'è solo il form.
|
||||
//
|
||||
// Passo 2 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): il parametro passa
|
||||
// di pagina in pagina fino al questionario, e ogni passo ha un link «Salta».
|
||||
import Longevity from '../../../../../layouts/Longevity.astro';
|
||||
import { getLongevityDb } from '../../../../../lib/longevity/db';
|
||||
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
|
||||
import { consulenzaDi, OBIETTIVI } from '../../../../../lib/longevity/consulenza';
|
||||
|
||||
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 scheda = consulenzaDi(db, cliente.client_code);
|
||||
const errore = Astro.url.searchParams.get('errore');
|
||||
const flusso = Astro.url.searchParams.get('flusso') ?? '';
|
||||
const query = flusso ? `?flusso=${encodeURIComponent(flusso)}` : '';
|
||||
---
|
||||
<Longevity
|
||||
title={`Prima consulenza — ${cliente.cognome} ${cliente.nome}`}
|
||||
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Prima consulenza']}
|
||||
variante="gestionale"
|
||||
>
|
||||
<div class="lg-head">
|
||||
<h1>Prima consulenza</h1>
|
||||
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Tutti i campi sono facoltativi.</p>
|
||||
{flusso === 'nuovo' && <p class="lg-muted">Passo 2 di 4 — Prima consulenza</p>}
|
||||
</div>
|
||||
|
||||
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||
|
||||
<form class="lg-form" method="post" action="/api/longevity/gestionale/consulenza">
|
||||
<input type="hidden" name="client_code" value={cliente.client_code} />
|
||||
<input type="hidden" name="flusso" value={flusso} />
|
||||
|
||||
<section class="lg-form__blocco">
|
||||
<fieldset class="lg-field lg-field--consenso">
|
||||
<legend>Livello</legend>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<label>
|
||||
<input type="radio" name="livello" value={n} checked={scheda?.livello === n} /> {n}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="lg-field lg-field--consenso">
|
||||
<legend>Obiettivo primario</legend>
|
||||
{OBIETTIVI.map((o) => (
|
||||
<label>
|
||||
<input
|
||||
type="radio" name="obiettivo_primario" value={o.chiave}
|
||||
checked={scheda?.obiettivo_primario === o.chiave}
|
||||
/> {o.etichetta}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Obiettivo specifico concordato</span>
|
||||
<textarea name="obiettivo_specifico" rows="3">{scheda?.obiettivo_specifico ?? ''}</textarea>
|
||||
</label>
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Data target</span>
|
||||
<input type="date" name="data_target" value={scheda?.data_target ?? ''} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="lg-form__blocco">
|
||||
<h2>Screening</h2>
|
||||
<label class="lg-field">
|
||||
<span>Note mediche, allergie, infortuni pregressi</span>
|
||||
<textarea name="note_mediche" rows="3">{scheda?.note_mediche ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Storia sportiva</span>
|
||||
<textarea name="storia_sportiva" rows="3">{scheda?.storia_sportiva ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Limitazioni fisiche attuali</span>
|
||||
<textarea name="limitazioni" rows="3">{scheda?.limitazioni ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Disponibilità (giorni/settimana)</span>
|
||||
<input type="text" inputmode="numeric" name="disponibilita_giorni" value={scheda?.disponibilita_giorni ?? ''} />
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Fascia oraria preferita</span>
|
||||
<input type="text" name="fascia_oraria" value={scheda?.fascia_oraria ?? ''} />
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Note screening</span>
|
||||
<textarea name="note_screening" rows="3">{scheda?.note_screening ?? ''}</textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="lg-form__nav">
|
||||
{flusso === 'nuovo' ? (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup${query}`}>Salta</a>
|
||||
) : (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}#consulenza`}>Annulla</a>
|
||||
)}
|
||||
<button type="submit">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</Longevity>
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
// Il dettaglio di una sessione (#12): data, tipo, operatore, misure con valore/unità e
|
||||
// stato di fuori-range/verifica. Da qui si apre la correzione di un check-up
|
||||
// (checkup.astro?sessione=<id>) — un questionario resta di sola lettura, la sua correzione
|
||||
// non è questa issue. Un id che non esiste, o di un altro cliente, è un 404: non c'è niente
|
||||
// da mostrare per una sessione che non è la sua.
|
||||
import Longevity from '../../../../../../layouts/Longevity.astro';
|
||||
import { getLongevityDb } from '../../../../../../lib/longevity/db';
|
||||
import { clienteDaCodice } from '../../../../../../lib/longevity/anagrafica';
|
||||
import { misureDellaSessione, campiDelCheckup, type MisuraDiSessione } from '../../../../../../lib/longevity/gestionale';
|
||||
import { formattaNumero } from '../../../../../../lib/longevity/formato';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { code, id } = Astro.params;
|
||||
const cliente = code ? clienteDaCodice(code) : null;
|
||||
if (!cliente) return new Response('Cliente non trovato', { status: 404 });
|
||||
|
||||
const sessioneId = id ? Number(id) : NaN;
|
||||
const db = getLongevityDb();
|
||||
const sessione = Number.isInteger(sessioneId) ? misureDellaSessione(db, sessioneId) : null;
|
||||
if (!sessione || sessione.client_code !== cliente.client_code) {
|
||||
return new Response('Sessione non trovata', { status: 404 });
|
||||
}
|
||||
|
||||
const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = {
|
||||
checkup: 'Check-up', questionario: 'Questionario',
|
||||
};
|
||||
const ETICHETTA_VERIFICA: Record<'confermata' | 'scartata', string> = {
|
||||
confermata: 'Confermata', scartata: 'Scartata',
|
||||
};
|
||||
const formattaData = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
|
||||
// Un check-up si raggruppa per asse come il modulo che lo ha compilato (campiDelCheckup);
|
||||
// un questionario non ha un raggruppamento del genere nel codice — resta un'unica sezione,
|
||||
// già ordinata per asse da misureDellaSessione.
|
||||
type Sezione = { asse: string | null; misure: MisuraDiSessione[] };
|
||||
let sezioni: Sezione[];
|
||||
if (sessione.tipo === 'checkup') {
|
||||
const perTestId = new Map(sessione.misure.map((m) => [m.test_id, m]));
|
||||
sezioni = campiDelCheckup(db, sessione.data)
|
||||
.map((g) => ({
|
||||
asse: g.asse,
|
||||
misure: g.campi.map((c) => perTestId.get(c.id)).filter((m): m is MisuraDiSessione => m !== undefined),
|
||||
}))
|
||||
.filter((s) => s.misure.length > 0);
|
||||
} else {
|
||||
sezioni = [{ asse: null, misure: sessione.misure }];
|
||||
}
|
||||
---
|
||||
<Longevity
|
||||
title={`Sessione del ${formattaData(sessione.data)} — ${cliente.cognome} ${cliente.nome}`}
|
||||
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, `Sessione del ${formattaData(sessione.data)}`]}
|
||||
variante="gestionale"
|
||||
>
|
||||
<div class="lg-head lg-head--azioni">
|
||||
<div>
|
||||
<h1>{ETICHETTA_TIPO[sessione.tipo]} del {formattaData(sessione.data)}</h1>
|
||||
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code} · operatore {sessione.operatore ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
{sessione.tipo === 'checkup' && (
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup?sessione=${sessione.id}`}>Modifica</a>
|
||||
)}
|
||||
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Torna alla scheda</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sezioni.map((s) => (
|
||||
<div class="lg-card">
|
||||
{s.asse && <p class="lg-score__label">{s.asse}</p>}
|
||||
<table class="lg-table">
|
||||
<thead><tr><th>Test</th><th>Valore</th><th>Stato</th></tr></thead>
|
||||
<tbody>
|
||||
{s.misure.map((m) => (
|
||||
<tr>
|
||||
<td>{m.etichetta}</td>
|
||||
<td class:list={{ 'lg-misura--fuori-range': m.fuori_range === 1 }}>
|
||||
{m.valore_num !== null ? formattaNumero(m.valore_num) : (m.valore_txt ?? '—')}
|
||||
{m.unita ? ` ${m.unita}` : ''}
|
||||
</td>
|
||||
<td>
|
||||
{m.fuori_range === 0
|
||||
? '—'
|
||||
: m.verifica
|
||||
? ETICHETTA_VERIFICA[m.verifica]
|
||||
: 'Da verificare'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</Longevity>
|
||||
@@ -154,7 +154,18 @@
|
||||
|
||||
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
|
||||
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
||||
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||
/* Bersaglio touch >= 44px (#15, tablet del centro): l'input radio/checkbox resta piccolo,
|
||||
ma l'intera label (dove il dito tocca davvero) ha l'altezza minima del criterio WCAG. */
|
||||
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; min-height: 44px; }
|
||||
|
||||
/* Sezioni ad alternative del check-up (#13): un fieldset per protocollo, radio in legenda. */
|
||||
.lg-form__alternative { display: flex; flex-direction: column; gap: 12px; }
|
||||
.lg-form__alternativa { border: 1px solid var(--lg-line); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lg-form__alternativa legend { padding: 0 4px; }
|
||||
.lg-form__alternativa legend label { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--c-heading); min-height: 44px; } /* bersaglio touch (#15) */
|
||||
.lg-form__soglia { margin: 0; font-size: .8rem; color: var(--c-text-light); }
|
||||
.lg-form__alternativa details { font-size: .85rem; }
|
||||
.lg-form__alternativa summary { cursor: pointer; color: var(--c-text-light); }
|
||||
|
||||
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
||||
|
||||
@@ -163,6 +174,7 @@
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
|
||||
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
|
||||
background: transparent; color: var(--c-heading); cursor: pointer;
|
||||
min-height: 44px; /* bersaglio touch (#15) */
|
||||
}
|
||||
.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; }
|
||||
@@ -210,9 +222,10 @@
|
||||
|
||||
/* 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;
|
||||
display: inline-flex; align-items: center; 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;
|
||||
min-height: 44px; box-sizing: border-box; /* bersaglio touch (#15) */
|
||||
}
|
||||
.lg-btn:hover { background: var(--c-accent-dark); border-color: var(--c-accent-dark); }
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Test B (#13): l'API checkup.ts scarta i campi di un'alternativa non scelta e aggiunge una
|
||||
// voce di giornale dalla nota operatore. File separato (stesso motivo di
|
||||
// correzione-checkup-api.test.ts): l'endpoint usa il singleton getLongevityDb(), e
|
||||
// LONGEVITY_DB_PATH va impostata PRIMA che lo apra — non nello stesso file di altri test che
|
||||
// lo inizializzano prima. A differenza di correzione-checkup-api.test.ts (un solo test) qui
|
||||
// ce ne sono diversi: il singleton si apre UNA volta per tutto il file (beforeAll/afterAll,
|
||||
// non chiuso e riaperto a ogni test — getLongevityDb() lo terrebbe chiuso dopo il primo
|
||||
// close()), e ogni test usa un client_code proprio per non leggere le voci di giornale di un
|
||||
// altro test.
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { vociDelGiornale } from '../../src/lib/longevity/giornale';
|
||||
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
|
||||
|
||||
// La data d'oggi vera del sistema che esegue i test (non una finta): un checkup con questa
|
||||
// data non è "nel futuro" per la validazione dell'API.
|
||||
const OGGI = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let dir: string;
|
||||
let primaLg: string | undefined;
|
||||
let lg: ReturnType<typeof getLongevityDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'lg-api-alt-test-'));
|
||||
primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
|
||||
lg = getLongevityDb();
|
||||
seedRegistro(lg);
|
||||
seedPesi(lg, MODEL_VERSION);
|
||||
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005', 'ISL-0006']) {
|
||||
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
lg.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () => {
|
||||
it('scarta i campi di un\'alternativa non scelta, anche se compilati', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0001');
|
||||
form.set('data', OGGI);
|
||||
form.set('alt_spinta', 'pushup'); // scelta: push-up
|
||||
form.set('f_pushup_rip', '15'); // dell'alternativa scelta: deve restare
|
||||
form.set('f_panca_bil_carico_kg', '60'); // di un'altra alternativa (bilanciere): va scartato
|
||||
form.set('f_panca_bil_rip', '5');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/salvato=/);
|
||||
const sessioneId = Number(new URL(loc, 'http://localhost').searchParams.get('salvato'));
|
||||
|
||||
const righe = lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[];
|
||||
const ids = righe.map((r) => r.test_id);
|
||||
expect(ids).toContain('f_pushup_rip');
|
||||
expect(ids).not.toContain('f_panca_bil_carico_kg');
|
||||
expect(ids).not.toContain('f_panca_bil_rip');
|
||||
});
|
||||
|
||||
it('senza radio inviata (nessun alt_*), non si scarta nulla: un campo di un\'alternativa non-prima resta salvato (correzione passo 3, #13)', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0002');
|
||||
form.set('data', OGGI);
|
||||
// nessun alt_core: 'ginocchia' è la prima alternativa di "Core", f_plank_sec è 'pieno'
|
||||
// (la seconda) — prima della correzione questo campo veniva scartato in silenzio.
|
||||
form.set('f_plank_sec', '90');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
const sessioneId = Number(new URL(res.headers.get('Location')!, 'http://localhost').searchParams.get('salvato'));
|
||||
const ids = (lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[])
|
||||
.map((r) => r.test_id);
|
||||
expect(ids).toContain('f_plank_sec');
|
||||
});
|
||||
|
||||
it('nota operatore non vuota: aggiunge una voce di giornale con la data del check-up e l\'operatore come autore', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0003');
|
||||
form.set('data', OGGI);
|
||||
form.set('f_peso_kg', '70');
|
||||
form.set('nota', 'Cliente motivato, riprende dopo la pausa estiva.');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
|
||||
const voci = vociDelGiornale(lg, 'ISL-0003');
|
||||
expect(voci.length).toBe(1);
|
||||
expect(voci[0].testo).toBe('Cliente motivato, riprende dopo la pausa estiva.');
|
||||
expect(voci[0].data).toBe(OGGI);
|
||||
expect(voci[0].autore).toBe('nicola');
|
||||
});
|
||||
|
||||
it('nota operatore vuota (o assente): nessuna voce di giornale', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0004');
|
||||
form.set('data', OGGI);
|
||||
form.set('f_peso_kg', '70');
|
||||
form.set('nota', ' '); // solo spazi: trim -> vuoto
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(vociDelGiornale(lg, 'ISL-0004').length).toBe(0);
|
||||
});
|
||||
|
||||
it('una correzione (sessione_id nel form) non porta il campo nota: nessuna voce anche se la sessione ha una data valida', async () => {
|
||||
const esito = salvaCheckup(lg, {
|
||||
client_code: 'ISL-0005', data: OGGI, eta: 30, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 70 }],
|
||||
});
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0005');
|
||||
form.set('sessione_id', String(esito.sessioneId));
|
||||
form.set('data', OGGI);
|
||||
form.set('f_peso_kg', '71');
|
||||
// niente campo "nota": la pagina non lo mostra in correzione
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0);
|
||||
});
|
||||
|
||||
it('nota oltre 5000 caratteri: errore PRIMA di salvare, nessuna sessione né voce di giornale (correzione passo 3, #13)', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0006');
|
||||
form.set('data', OGGI);
|
||||
form.set('f_peso_kg', '70');
|
||||
form.set('nota', 'x'.repeat(5001));
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.get('Location')).toMatch(/errore=/);
|
||||
|
||||
const sessioni = lg.prepare(`SELECT COUNT(*) AS n FROM sessioni WHERE client_code = ?`).get('ISL-0006') as { n: number };
|
||||
expect(sessioni.n).toBe(0); // niente scritto: né la sessione né la misura f_peso_kg
|
||||
expect(vociDelGiornale(lg, 'ISL-0006').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
// Issue #15: prima consulenza per cliente (solo staff, una scheda sola — correggere e
|
||||
// salvare sovrascrive, nessuno storico), e il flusso guidato per un cliente nuovo.
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLongevityDb, getLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
|
||||
import { consulenzaDi, salvaConsulenza, OBIETTIVI } from '../../src/lib/longevity/consulenza';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('salvaConsulenza e consulenzaDi', () => {
|
||||
it('inserisce e legge la scheda', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, {
|
||||
clientCode: 'ISL-0001', autore: 'nicola',
|
||||
campi: { livello: '2', obiettivoPrimario: 'dimagrimento', dataTarget: '2027-01-01', disponibilitaGiorni: '3' },
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda).not.toBeNull();
|
||||
expect(scheda?.livello).toBe(2);
|
||||
expect(scheda?.obiettivo_primario).toBe('dimagrimento');
|
||||
expect(scheda?.data_target).toBe('2027-01-01');
|
||||
expect(scheda?.disponibilita_giorni).toBe(3);
|
||||
expect(scheda?.aggiornata_da).toBe('nicola');
|
||||
});
|
||||
|
||||
it('null se non compilata', () => {
|
||||
const db = dbPronto();
|
||||
expect(consulenzaDi(db, 'ISL-0001')).toBeNull();
|
||||
});
|
||||
|
||||
it('una seconda chiamata sovrascrive la prima: una riga sola, nessuno storico', () => {
|
||||
const db = dbPronto();
|
||||
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } });
|
||||
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'donata', campi: { livello: '3' } });
|
||||
|
||||
const righe = (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
|
||||
expect(righe).toBe(1);
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda?.livello).toBe(3);
|
||||
expect(scheda?.aggiornata_da).toBe('donata');
|
||||
});
|
||||
|
||||
it('stringhe vuote diventano NULL', () => {
|
||||
const db = dbPronto();
|
||||
salvaConsulenza(db, {
|
||||
clientCode: 'ISL-0001', autore: 'nicola',
|
||||
campi: { obiettivoSpecifico: ' ', noteMediche: '', fasciaOraria: undefined },
|
||||
});
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda?.obiettivo_specifico).toBeNull();
|
||||
expect(scheda?.note_mediche).toBeNull();
|
||||
expect(scheda?.fascia_oraria).toBeNull();
|
||||
});
|
||||
|
||||
it('tutti i campi sono facoltativi: una scheda vuota è valida', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: {} });
|
||||
expect(esito.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('salvaConsulenza: rifiuti, nessuna scrittura', () => {
|
||||
const righe = (db: ReturnType<typeof dbPronto>) =>
|
||||
(db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
|
||||
|
||||
it('rifiuta un cliente inesistente', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-9999', autore: 'nicola', campi: {} });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un livello fuori 1-3', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '4' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un livello non intero', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2.5' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un obiettivo primario fuori elenco', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: 'volare' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('accetta ogni chiave dell elenco OBIETTIVI', () => {
|
||||
const db = dbPronto();
|
||||
for (const o of OBIETTIVI) {
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: o.chiave } });
|
||||
expect(esito.ok, o.chiave).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rifiuta una data target malformata', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { dataTarget: '01-01-2027' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una disponibilita fuori 0-7', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '8' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una disponibilita non intera', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '2,5' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un testo libero troppo lungo', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { noteMediche: 'x'.repeat(5001) } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema: la tabella consulenza si crea su un longevity.db preesistente senza di essa', () => {
|
||||
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
expect(salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }).ok).toBe(true);
|
||||
expect(() => createLongevityDb(':memory:')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Le API sotto (consulenza.ts e checkup.ts) chiamano SEMPRE getLongevityDb(), il singleton
|
||||
// di db.ts: aprirlo e chiuderlo dentro ogni singolo test (come fa correzione-checkup-api.test.ts
|
||||
// da solo nel suo file) romperebbe i test successivi dello STESSO file, perché il singleton
|
||||
// resta quello chiuso finché il modulo non si ricarica. Qui si apre una volta sola per
|
||||
// tutte le API, con beforeAll/afterAll, e ogni test usa un client_code diverso per non
|
||||
// pestarsi i piedi — stesso principio, applicato a un file con più test sullo stesso
|
||||
// singleton invece di uno solo.
|
||||
describe('API del flusso guidato (consulenza.ts, checkup.ts)', () => {
|
||||
let dir: string;
|
||||
let db: ReturnType<typeof getLongevityDb>;
|
||||
let primaLg: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'lg-api-flusso-test-'));
|
||||
primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
|
||||
db = getLongevityDb();
|
||||
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004']) {
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
db.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('consulenza.ts: salva con autore da locals.user (mai dal form) e torna alla scheda cliente, fuori dal flusso', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0001');
|
||||
form.set('livello', '2');
|
||||
form.set('autore', 'un-impostore'); // ignorato: deve vincere locals.user
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0001?consulenza=salvata#consulenza');
|
||||
|
||||
const riga = db.prepare(`SELECT aggiornata_da FROM consulenza WHERE client_code = 'ISL-0001'`).get() as
|
||||
{ aggiornata_da: string };
|
||||
expect(riga.aggiornata_da).toBe('nicola');
|
||||
});
|
||||
|
||||
it('consulenza.ts: con flusso=nuovo prosegue verso il check-up', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0002');
|
||||
form.set('flusso', 'nuovo');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0002/checkup?flusso=nuovo');
|
||||
});
|
||||
|
||||
it('consulenza.ts: client_code inesistente, errore, torna al modulo conservando il flusso', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-9999');
|
||||
form.set('flusso', 'nuovo');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-9999\/consulenza\?/);
|
||||
expect(loc).toMatch(/flusso=nuovo/);
|
||||
expect(loc).toMatch(/errore=/);
|
||||
});
|
||||
|
||||
it('checkup.ts: un check-up NUOVO con flusso=nuovo redirige con flusso=questionario', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0003');
|
||||
form.set('data', '2026-09-26');
|
||||
form.set('flusso', 'nuovo');
|
||||
form.set('f_peso_kg', '80'); // salvaCheckup rifiuta un check-up senza nessuna misura
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0003\?/);
|
||||
expect(loc).toMatch(/salvato=/);
|
||||
expect(loc).toMatch(/flusso=questionario/);
|
||||
});
|
||||
|
||||
it('checkup.ts: una correzione (sessione_id) ignora flusso, nessun flusso nel redirect', async () => {
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0004', data: '2026-09-20', eta: 40, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0004');
|
||||
form.set('data', '2026-09-20');
|
||||
form.set('sessione_id', String(esito.sessioneId));
|
||||
form.set('f_peso_kg', '82');
|
||||
form.set('flusso', 'nuovo'); // ignorato: una correzione non è mai nel flusso guidato
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0004\?/);
|
||||
expect(loc).toMatch(/corretto=/);
|
||||
expect(loc).not.toMatch(/flusso=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clienti.ts: dopo la creazione il flusso guidato comincia dalla consulenza', () => {
|
||||
it('redirige a .../consulenza?flusso=nuovo, non piu direttamente alla scheda', () => {
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/api/longevity/gestionale/clienti.ts'), 'utf8');
|
||||
expect(src).toMatch(/\/consulenza\?flusso=nuovo/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nessuna pagina cliente importa la consulenza', () => {
|
||||
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a consulenza.ts', () => {
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
|
||||
for (const p of pagine) {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
|
||||
expect(src, `${p} non deve importare consulenza.ts`).not.toMatch(/consulenza/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Issue #12: la correzione di un check-up passa dalla stessa API di salvaCheckup, distinta
|
||||
// da un sessione_id nel form. File separato da gestionale.test.ts apposta: l'endpoint usa il
|
||||
// singleton getLongevityDb() (db.ts), e gestionale.test.ts ha già un test (apriFascicolo)
|
||||
// che lo inizializza — nello stesso file la variabile d'ambiente impostata qui arriverebbe
|
||||
// troppo tardi. Stesso trucco di questionario-pagina.test.ts: LONGEVITY_DB_PATH va impostata
|
||||
// PRIMA che il singleton apra il suo longevity.db. Il setup usa lo STESSO getLongevityDb()
|
||||
// che poi chiamerà POST (una sola connessione, non due sullo stesso file): così si può
|
||||
// chiuderla nel finally, altrimenti rmSync fallisce con EPERM su Windows a handle aperto.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
|
||||
|
||||
describe('API checkup.ts, correzione (#12): il client_code del redirect e delle validazioni viene sempre dalla sessione', () => {
|
||||
it('un sessione_id di un altro cliente rispetto al client_code del form: errore, niente scritto', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lg-api-checkup-test-'));
|
||||
const lgPath = join(dir, 'longevity.db');
|
||||
const primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
let lg: ReturnType<typeof getLongevityDb> | undefined;
|
||||
try {
|
||||
process.env.LONGEVITY_DB_PATH = lgPath;
|
||||
|
||||
lg = getLongevityDb();
|
||||
seedRegistro(lg);
|
||||
seedPesi(lg, MODEL_VERSION);
|
||||
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
|
||||
|
||||
const esito = salvaCheckup(lg, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('sessione_id', String(esito.sessioneId));
|
||||
form.set('client_code', 'ISL-0002'); // la sessione è di ISL-0001: incoerente
|
||||
form.set('data', '2026-09-26');
|
||||
form.set('f_peso_kg', '999'); // se scrivesse, sarebbe questo il valore
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'donata' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.get('Location')).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0001\?/); // redirect sul cliente VERO, non su quello del form
|
||||
expect(res.headers.get('Location')).toMatch(/errore=/);
|
||||
|
||||
const riga = lg.prepare(
|
||||
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
|
||||
).get(esito.sessioneId) as { valore_num: number };
|
||||
expect(riga.valore_num).toBe(80); // invariato: nessuna scrittura
|
||||
} finally {
|
||||
lg?.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// Test B (#13): la struttura del modulo di check-up (strutturaCheckup) e la preselezione
|
||||
// delle alternative (alternativaSelezionata) — tutto in memoria, nessun singleton. I test
|
||||
// dell'API (campi scartati, nota->giornale) sono in checkup-alternative-api.test.ts: stesso
|
||||
// motivo di correzione-checkup-api.test.ts, l'endpoint usa il singleton getLongevityDb().
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import {
|
||||
strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup, salvaCheckup,
|
||||
type SezioneCheckup,
|
||||
} from '../../src/lib/longevity/gestionale';
|
||||
|
||||
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('strutturaCheckup: ordine e contenuto delle sezioni', () => {
|
||||
it('le sezioni compaiono nell\'ordine del manuale: Pre-test, Mobilità, Equilibrio, Presa, Core, Forza spinta, Forza trazione, Arti inferiori, Aerobico, Strumenti', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
expect(modulo.sezioni.map((s) => s.titolo)).toEqual([
|
||||
'Pre-test', 'Mobilità', 'Equilibrio', 'Presa', 'Core', 'Forza spinta',
|
||||
'Forza trazione', 'Arti inferiori', 'Aerobico', 'Strumenti (Wellness Tower, Stress Index)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pressione e SpO2 non compaiono in nessuna sezione né in Altre misure', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
const tuttiGliId = modulo.sezioni.flatMap((s) =>
|
||||
s.tipo === 'campi' ? s.campi.map((c) => c.id)
|
||||
: s.alternative.flatMap((a) => [...a.campi.map((c) => c.id), ...(a.dettagli?.map((c) => c.id) ?? [])])
|
||||
);
|
||||
for (const id of ['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']) {
|
||||
expect(tuttiGliId).not.toContain(id);
|
||||
expect(modulo.altreMisure.map((c) => c.id)).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('su una data vecchia (prima del 28/09) un test dismesso finisce in Altre misure, mai perso', () => {
|
||||
const db = dbPronto();
|
||||
// 2026-09-27: i vecchi test_id storici sono ancora attivi (attivo_a: 2026-09-28),
|
||||
// e non compaiono in nessuna sezione di STRUTTURA_RAW (che usa solo i nuovi id).
|
||||
const modulo = strutturaCheckup(db, '2026-09-27');
|
||||
const altreIds = modulo.altreMisure.map((c) => c.id);
|
||||
expect(altreIds).toContain('f_handgrip_kg');
|
||||
expect(altreIds).toContain('f_back_scratch_cm');
|
||||
expect(altreIds).toContain('f_flamingo_cadute');
|
||||
expect(altreIds).toContain('f_hrr_1min_bpm');
|
||||
});
|
||||
|
||||
it('su una data recente (dopo il 28/09) i test dismessi non sono più attivi: spariscono anche da Altre misure', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
const altreIds = modulo.altreMisure.map((c) => c.id);
|
||||
expect(altreIds).not.toContain('f_handgrip_kg');
|
||||
});
|
||||
|
||||
it('il MUTT porta i suoi otto step FC come dettagli, non come campi normali', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
const aerobico = modulo.sezioni.find((s) => s.titolo === 'Aerobico');
|
||||
expect(aerobico?.tipo).toBe('alternative');
|
||||
if (aerobico?.tipo !== 'alternative') throw new Error('atteso alternative');
|
||||
const mutt = aerobico.alternative.find((a) => a.chiave === 'mutt')!;
|
||||
expect(mutt.dettagli?.length).toBe(8);
|
||||
expect(mutt.dettagli?.map((c) => c.id)).toContain('f_mutt_fc_7_5');
|
||||
expect(mutt.campi.map((c) => c.id)).not.toContain('f_mutt_fc_7_5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alternativaSelezionata: quale alternativa preselezionare', () => {
|
||||
it('senza corrispondenza in valori, vince la prima alternativa elencata (neofita)', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
|
||||
expect(alternativaSelezionata(spinta, new Map())).toBe('manubri');
|
||||
});
|
||||
|
||||
it('con un valore di un\'alternativa più avanti nell\'elenco, quella vince', () => {
|
||||
const db = dbPronto();
|
||||
const modulo = strutturaCheckup(db, '2026-09-28');
|
||||
const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract<SezioneCheckup, { tipo: 'alternative' }>;
|
||||
const valori = new Map([['f_panca_bil_carico_kg', 60]]);
|
||||
expect(alternativaSelezionata(spinta, valori)).toBe('bilanciere');
|
||||
});
|
||||
|
||||
it('valoriUltimoCheckup: vuota se il cliente non ha ancora un check-up, altrimenti le misure dell\'ultimo', () => {
|
||||
const db = dbPronto();
|
||||
expect(valoriUltimoCheckup(db, 'ISL-0001').size).toBe(0);
|
||||
|
||||
salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-01-10', eta: 40, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_panca_bil_carico_kg', valore_num: 50 }, { test_id: 'f_panca_bil_rip', valore_num: 5 }],
|
||||
});
|
||||
const ultima = valoriUltimoCheckup(db, 'ISL-0001');
|
||||
expect(ultima.get('f_panca_bil_carico_kg')).toBe(50);
|
||||
|
||||
// un secondo check-up più recente sovrascrive quale sia "l'ultimo"
|
||||
salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-03-10', eta: 40, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_pushup_rip', valore_num: 12 }],
|
||||
});
|
||||
const piuRecente = valoriUltimoCheckup(db, 'ISL-0001');
|
||||
expect(piuRecente.get('f_pushup_rip')).toBe(12);
|
||||
expect(piuRecente.has('f_panca_bil_carico_kg')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,9 @@ 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 { salvaCheckup, sessioniDi, correggiCheckup, misureDellaSessione } from '../../src/lib/longevity/gestionale';
|
||||
import { misureDaVerificare, verificaMisura } from '../../src/lib/longevity/misure';
|
||||
import { leggiScore } from '../../src/lib/longevity/motore';
|
||||
import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista';
|
||||
import {
|
||||
creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo,
|
||||
@@ -170,6 +171,204 @@ describe('sessioniDi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('misureDellaSessione', () => {
|
||||
it('null se la sessione non esiste; altrimenti le misure ordinate per asse come campiDelCheckup', () => {
|
||||
const db = dbPronto();
|
||||
expect(misureDellaSessione(db, 999999)).toBeNull();
|
||||
|
||||
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 dettaglio = misureDellaSessione(db, esito.sessioneId);
|
||||
expect(dettaglio?.tipo).toBe('checkup');
|
||||
expect(dettaglio?.operatore).toBe('nicola');
|
||||
// 'Forza & Struttura' precede 'Composizione Corporea' nei pesi (PESI_ASSE, registro.ts):
|
||||
// stesso ordine che userebbe campiDelCheckup per raggruppare il modulo.
|
||||
expect(dettaglio?.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg', 'f_peso_kg']);
|
||||
expect(dettaglio?.misure[1]).toMatchObject({ etichetta: 'Peso corporeo', valore_num: 80, verifica: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('correggiCheckup: sovrascrittura, nessuno storico delle misure (#12)', () => {
|
||||
it('corregge un valore: misura aggiornata, score della sessione ricalcolato; la successiva viene ricalcolata, la precedente resta intatta', () => {
|
||||
const db = dbPronto();
|
||||
const precedente = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
const target = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-10', eta: 38, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 82 }],
|
||||
});
|
||||
const successiva = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-20', eta: 38, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 83 }],
|
||||
});
|
||||
expect(precedente.ok && target.ok && successiva.ok).toBe(true);
|
||||
if (!precedente.ok || !target.ok || !successiva.ok) throw new Error('setup fallito');
|
||||
|
||||
const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n;
|
||||
const primaPrecedente = contaScore(precedente.sessioneId);
|
||||
const primaTarget = contaScore(target.sessioneId);
|
||||
const primaSuccessiva = contaScore(successiva.sessioneId);
|
||||
|
||||
const esito = correggiCheckup(db, {
|
||||
sessioneId: target.sessioneId, data: '2026-09-10', eta: 38, operatore: 'donata',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 90 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
expect(esito.sessioneId).toBe(target.sessioneId);
|
||||
|
||||
const riga = db.prepare(
|
||||
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
|
||||
).get(target.sessioneId) as { valore_num: number };
|
||||
expect(riga.valore_num).toBe(90);
|
||||
|
||||
const sessioneAggiornata = db.prepare(`SELECT operatore FROM sessioni WHERE id = ?`).get(target.sessioneId) as { operatore: string };
|
||||
expect(sessioneAggiornata.operatore).toBe('donata'); // l'operatore diventa chi corregge
|
||||
|
||||
// score ricalcolato (righe nuove in append): target e successiva sì, precedente no.
|
||||
expect(contaScore(target.sessioneId)).toBeGreaterThan(primaTarget);
|
||||
expect(contaScore(successiva.sessioneId)).toBeGreaterThan(primaSuccessiva);
|
||||
expect(contaScore(precedente.sessioneId)).toBe(primaPrecedente);
|
||||
});
|
||||
|
||||
it('campo svuotato cancella la misura; campo nuovo la inserisce con fonte manuale', () => {
|
||||
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 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const corretto = correggiCheckup(db, {
|
||||
sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
// f_peso_kg non c'è più (campo svuotato nel form), f_handgrip_kg è nuovo.
|
||||
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
|
||||
});
|
||||
expect(corretto.ok).toBe(true);
|
||||
|
||||
const righe = db.prepare(`SELECT test_id, fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as
|
||||
{ test_id: string; fonte: string }[];
|
||||
expect(righe).toEqual([{ test_id: 'f_handgrip_kg', fonte: 'manuale' }]);
|
||||
});
|
||||
|
||||
it('valore invariato conserva la verifica; valore cambiato azzera la verifica e ricalcola fuori_range', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
// 500 kg è oltre il range di plausibilità (max 250): fuori range da subito.
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 45 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const misuraPeso = db.prepare(
|
||||
`SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
|
||||
).get(esito.sessioneId) as { id: number };
|
||||
const conferma = verificaMisura(db, { misuraId: misuraPeso.id, esito: 'confermata', operatore: 'nicola' });
|
||||
expect(conferma.ok).toBe(true);
|
||||
|
||||
// Correzione: f_peso_kg invariato (500), f_handgrip_kg cambiato (45 -> 50).
|
||||
const corretto = correggiCheckup(db, {
|
||||
sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 50 }],
|
||||
});
|
||||
expect(corretto.ok).toBe(true);
|
||||
|
||||
const pesoDopo = db.prepare(
|
||||
`SELECT verifica, fuori_range FROM misure WHERE id = ?`
|
||||
).get(misuraPeso.id) as { verifica: string | null; fuori_range: number };
|
||||
expect(pesoDopo.verifica).toBe('confermata'); // invariato: la verifica resta
|
||||
expect(pesoDopo.fuori_range).toBe(1);
|
||||
|
||||
const handgripDopo = db.prepare(
|
||||
`SELECT valore_num, fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_handgrip_kg'`
|
||||
).get(esito.sessioneId) as { valore_num: number; fuori_range: number };
|
||||
expect(handgripDopo.valore_num).toBe(50);
|
||||
expect(handgripDopo.fuori_range).toBe(0); // 50 è nel range (0-100): ricalcolato, non ereditato
|
||||
});
|
||||
|
||||
it('cambio data che sposta la sessione dopo un\'altra: entrambe ricalcolate', () => {
|
||||
const db = dbPronto();
|
||||
const a = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
const b = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-15', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
|
||||
});
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
if (!a.ok || !b.ok) throw new Error('setup fallito');
|
||||
|
||||
const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n;
|
||||
const primaA = contaScore(a.sessioneId);
|
||||
const primaB = contaScore(b.sessioneId);
|
||||
|
||||
// Sposta A dopo B: la posizione minima (min fra vecchia e nuova data di A) resta
|
||||
// 2026-09-01, quindi la ricalcolata copre sia A sia B.
|
||||
const esito = correggiCheckup(db, {
|
||||
sessioneId: a.sessioneId, data: '2026-09-20', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
|
||||
expect(contaScore(a.sessioneId)).toBeGreaterThan(primaA);
|
||||
expect(contaScore(b.sessioneId)).toBeGreaterThan(primaB);
|
||||
});
|
||||
|
||||
it('rifiuti: sessione inesistente, sessione questionario, nessuna misura, test non f_* — nessuna scrittura', () => {
|
||||
const db = dbPronto();
|
||||
|
||||
const inesistente = correggiCheckup(db, {
|
||||
sessioneId: 999999, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(inesistente.ok).toBe(false);
|
||||
|
||||
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);
|
||||
if (!q.ok) throw new Error('setup questionario fallito');
|
||||
const suQuestionario = correggiCheckup(db, {
|
||||
sessioneId: q.sessioneId, data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(suQuestionario.ok).toBe(false);
|
||||
|
||||
const checkup = salvaCheckup(db, {
|
||||
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(checkup.ok).toBe(true);
|
||||
if (!checkup.ok) throw new Error('setup checkup fallito');
|
||||
|
||||
const nessunaMisura = correggiCheckup(db, {
|
||||
sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [],
|
||||
});
|
||||
expect(nessunaMisura.ok).toBe(false);
|
||||
|
||||
const nonFisico = correggiCheckup(db, {
|
||||
sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }],
|
||||
});
|
||||
expect(nonFisico.ok).toBe(false);
|
||||
|
||||
// Nessuna delle chiamate rifiutate ha scritto: la misura del checkup è ancora quella originale.
|
||||
const rigaFinale = db.prepare(
|
||||
`SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
|
||||
).get(checkup.sessioneId) as { valore_num: number };
|
||||
expect(rigaFinale.valore_num).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
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:');
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Issue #16: giornale di bordo per cliente (solo staff, nessuna voce automatica, si
|
||||
// aggiunge e si cancella, non si modifica).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { vociDelGiornale, aggiungiVoce, cancellaVoce } from '../../src/lib/longevity/giornale';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('aggiungiVoce e vociDelGiornale', () => {
|
||||
it('aggiunge e legge le voci, più recente prima (data DESC, a parità id DESC), a capo conservati', () => {
|
||||
const db = dbPronto();
|
||||
|
||||
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Prima nota' });
|
||||
const b = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'donata', testo: 'Riga uno\nRiga due' });
|
||||
const c = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'nicola', testo: 'Stessa data, dopo' });
|
||||
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
expect(c.ok).toBe(true);
|
||||
|
||||
const voci = vociDelGiornale(db, 'ISL-0001');
|
||||
expect(voci.map((v) => v.testo)).toEqual(['Stessa data, dopo', 'Riga uno\nRiga due', 'Prima nota']);
|
||||
expect(voci[1].testo).toBe('Riga uno\nRiga due'); // a capo preservati
|
||||
expect(voci[0].autore).toBe('nicola');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggiungiVoce: rifiuti, nessuna riga scritta', () => {
|
||||
const contaRighe = (db: ReturnType<typeof dbPronto>) =>
|
||||
(db.prepare(`SELECT COUNT(*) n FROM giornale`).get() as { n: number }).n;
|
||||
|
||||
it('rifiuta testo vuoto', () => {
|
||||
const db = dbPronto();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: '' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta testo di soli spazi', () => {
|
||||
const db = dbPronto();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: ' \n ' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta testo troppo lungo', () => {
|
||||
const db = dbPronto();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'x'.repeat(5001) });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una data nel futuro', () => {
|
||||
const db = dbPronto();
|
||||
const domani = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: domani, autore: 'nicola', testo: 'Nota' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una data malformata', () => {
|
||||
const db = dbPronto();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '01-09-2026', autore: 'nicola', testo: 'Nota' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un cliente inesistente', () => {
|
||||
const db = dbPronto();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-9999', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(contaRighe(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancellaVoce', () => {
|
||||
it('l\'autore può cancellare la propria voce', () => {
|
||||
const db = dbPronto();
|
||||
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
|
||||
if (!a.ok) throw new Error('setup fallito');
|
||||
|
||||
const esito = cancellaVoce(db, { id: a.id, utente: 'nicola', ruolo: 'trainer' });
|
||||
expect(esito.ok).toBe(true);
|
||||
if (esito.ok) expect(esito.clientCode).toBe('ISL-0001');
|
||||
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
|
||||
});
|
||||
|
||||
it('un admin può cancellare la voce di un altro', () => {
|
||||
const db = dbPronto();
|
||||
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
|
||||
if (!a.ok) throw new Error('setup fallito');
|
||||
|
||||
const esito = cancellaVoce(db, { id: a.id, utente: 'adriano', ruolo: 'admin' });
|
||||
expect(esito.ok).toBe(true);
|
||||
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
|
||||
});
|
||||
|
||||
it('un altro trainer non può cancellare la voce di un collega', () => {
|
||||
const db = dbPronto();
|
||||
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
|
||||
if (!a.ok) throw new Error('setup fallito');
|
||||
|
||||
const esito = cancellaVoce(db, { id: a.id, utente: 'donata', ruolo: 'trainer' });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(vociDelGiornale(db, 'ISL-0001').length).toBe(1); // nessuna scrittura
|
||||
});
|
||||
|
||||
it('rifiuta un id inesistente', () => {
|
||||
const db = dbPronto();
|
||||
const esito = cancellaVoce(db, { id: 999999, utente: 'nicola', ruolo: 'trainer' });
|
||||
expect(esito.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema: la tabella giornale si crea su un longevity.db preesistente senza di essa', () => {
|
||||
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
|
||||
// db.ts crea la tabella con CREATE TABLE IF NOT EXISTS nello stesso blocco SCHEMA_LONGEVITY
|
||||
// delle altre tabelle: su un file che le ha già (come questo, aperto due volte) la seconda
|
||||
// apertura è un'operazione a vuoto, esattamente come per soggetti o sessioni — non serve
|
||||
// una migra() dedicata perché non c'è una tabella preesistente da alterare, solo una nuova
|
||||
// da creare la prima volta che manca.
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
|
||||
expect(esito.ok).toBe(true);
|
||||
|
||||
// Riaprire lo stesso schema (qui, un nuovo :memory:, ma stesso SCHEMA_LONGEVITY) non deve
|
||||
// fallire: la tabella è già presente, IF NOT EXISTS salta la creazione.
|
||||
expect(() => createLongevityDb(':memory:')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nessuna pagina cliente importa il giornale', () => {
|
||||
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a giornale.ts', () => {
|
||||
// Stile di anagrafica.ts: controllo testuale sui file, non sull'albero delle import —
|
||||
// basta a catturare un'importazione diretta, che è l'unico modo in cui il cliente
|
||||
// potrebbe finire per vedere il giornale di bordo (solo staff, #16).
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
|
||||
for (const p of pagine) {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
|
||||
expect(src, `${p} non deve importare giornale.ts`).not.toMatch(/giornale/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// La regola dei gruppi (issue #13, manuale batteria test di Nicola del 27/09/2026): per
|
||||
// alcune sotto-metriche (handgrip, spinta, arti_inferiori, vo2max, hrr, flamingo,
|
||||
// back_scratch) sottoDominiFisici sceglie fra piu' alternative COMPLETE quella la cui
|
||||
// misura piu' recente vince (`recenza`, opzionale). Questo file copre la regola da sola,
|
||||
// con `sottoDominiFisici` chiamata direttamente (mappe di misure a mano, non un DB): i
|
||||
// valori attesi si calcolano con le STESSE funzioni di test-fisici.ts (gia' verificate
|
||||
// contro l'oracolo in motore-fisici.test.ts), mai a mano.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
|
||||
import * as F from '../../src/lib/longevity/motore/test-fisici';
|
||||
|
||||
describe('un caso per ogni alternativa che da punteggio', () => {
|
||||
it('handgrip: media dx/sx (manuale)', () => {
|
||||
const m = new Map([['f_handgrip_dx_kg', 45], ['f_handgrip_sx_kg', 51]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.handgrip).toBe(F.scoreHandgrip(48, 38, 'M')); // media (45+51)/2 = 48
|
||||
});
|
||||
|
||||
it('handgrip: storico f_handgrip_kg (ancora letto)', () => {
|
||||
const m = new Map([['f_handgrip_kg', 44]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.handgrip).toBe(F.scoreHandgrip(44, 38, 'M'));
|
||||
});
|
||||
|
||||
it('spinta: panca con bilanciere (carico, ripetizioni, peso)', () => {
|
||||
const m = new Map([['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
|
||||
});
|
||||
|
||||
it('spinta: push-up (invariato)', () => {
|
||||
const m = new Map([['f_pushup_rip', 24]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
|
||||
});
|
||||
|
||||
it('arti_inferiori: sit-to-stand (ripetizioni, eta, sesso, bmi da peso/altezza)', () => {
|
||||
const m = new Map([['f_sit_to_stand_rip', 14], ['f_peso_kg', 65], ['f_altezza_cm', 160]]);
|
||||
const r = sottoDominiFisici(m, 65, 'F');
|
||||
const bmi = 65 / (160 / 100) ** 2;
|
||||
expect(r.arti_inferiori).toBe(F.scoreSitToStand(14, 65, 'F', bmi));
|
||||
});
|
||||
|
||||
it('vo2max: MILFIT (potenza, peso)', () => {
|
||||
const m = new Map([['f_milfit_watt', 150], ['f_peso_kg', 80]]);
|
||||
const r = sottoDominiFisici(m, 40, 'M');
|
||||
const vo2 = F.vo2maxDaMilfit(150, 80);
|
||||
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
|
||||
});
|
||||
|
||||
it('vo2max: camminata 2 km UKK (tempo, fc, eta, bmi)', () => {
|
||||
const m = new Map([
|
||||
['f_walk2km_min', 18], ['f_walk2km_fc_bpm', 130], ['f_peso_kg', 80], ['f_altezza_cm', 180],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, 40, 'M');
|
||||
const bmi = 80 / (180 / 100) ** 2;
|
||||
const vo2 = F.vo2maxDa2kmWalk(18, 130, 40, bmi);
|
||||
expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M'));
|
||||
});
|
||||
|
||||
it('hrr: MUTT (FC max - FC dopo 1 minuto, manuale)', () => {
|
||||
const m = new Map([['f_mutt_fc_max_bpm', 180], ['f_mutt_fc_1min_bpm', 140]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(40)); // calo = 180 - 140
|
||||
});
|
||||
|
||||
it('hrr: storico f_hrr_1min_bpm (gia un calo, ancora letto)', () => {
|
||||
const m = new Map([['f_hrr_1min_bpm', 18]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.hrr).toBe(F.scoreRecuperoCardiaco(18));
|
||||
});
|
||||
|
||||
it('flamingo: media dx/sx occhi aperti (manuale)', () => {
|
||||
const m = new Map([['f_flamingo_oa_dx', 3], ['f_flamingo_oa_sx', 5]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.flamingo).toBe(F.scoreFlamingo(4)); // media (3+5)/2 = 4
|
||||
});
|
||||
|
||||
it('flamingo: storico f_flamingo_cadute (ancora letto)', () => {
|
||||
const m = new Map([['f_flamingo_cadute', 6]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.flamingo).toBe(F.scoreFlamingo(6));
|
||||
});
|
||||
|
||||
it('back_scratch: media dx/sx (manuale)', () => {
|
||||
const m = new Map([['f_back_scratch_dx_cm', -8], ['f_back_scratch_sx_cm', -4]]);
|
||||
const r = sottoDominiFisici(m, 45, 'F');
|
||||
expect(r.back_scratch).toBe(F.scoreBackScratch(-6, 45, 'F')); // media (-8-4)/2 = -6
|
||||
});
|
||||
|
||||
it('back_scratch: storico f_back_scratch_cm (ancora letto)', () => {
|
||||
const m = new Map([['f_back_scratch_cm', -10]]);
|
||||
const r = sottoDominiFisici(m, 45, 'F');
|
||||
expect(r.back_scratch).toBe(F.scoreBackScratch(-10, 45, 'F'));
|
||||
});
|
||||
|
||||
it('la cascata del vo2max per una sessione con solo f_vo2max resta uguale', () => {
|
||||
const m = new Map([['f_vo2max', 45]]);
|
||||
const r = sottoDominiFisici(m, 40, 'M');
|
||||
expect(r.vo2max).toBe(F.scoreVo2max(45, 40, 'M'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('un lato solo, senza storico, non da punteggio', () => {
|
||||
it('handgrip solo destro: nessun punteggio (nessuna media possibile, nessuno storico)', () => {
|
||||
const m = new Map([['f_handgrip_dx_kg', 45]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.handgrip).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flamingo occhi aperti solo sinistro: nessun punteggio', () => {
|
||||
const m = new Map([['f_flamingo_oa_sx', 4]]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
expect(r.flamingo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('back_scratch solo sinistro: nessun punteggio', () => {
|
||||
const m = new Map([['f_back_scratch_sx_cm', -6]]);
|
||||
const r = sottoDominiFisici(m, 45, 'F');
|
||||
expect(r.back_scratch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('la regola dei gruppi sceglie per recenza, non solo per ordine di priorita', () => {
|
||||
it('pushup a gennaio + panca a marzo: vale panca (piu recente)', () => {
|
||||
const m = new Map([
|
||||
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
|
||||
]);
|
||||
// recenza: posizione 0 = piu recente. Panca (marzo) piu recente del pushup (gennaio).
|
||||
const recenza = new Map([
|
||||
['f_panca_bil_carico_kg', 0], ['f_panca_bil_rip', 0], ['f_pushup_rip', 1],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, 38, 'M', recenza);
|
||||
expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'));
|
||||
});
|
||||
|
||||
it('panca a gennaio + pushup a marzo: vale pushup (piu recente) — il contrario del caso sopra', () => {
|
||||
const m = new Map([
|
||||
['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80],
|
||||
]);
|
||||
const recenza = new Map([
|
||||
['f_pushup_rip', 0], ['f_panca_bil_carico_kg', 1], ['f_panca_bil_rip', 1],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, 38, 'M', recenza);
|
||||
expect(r.spinta).toBe(F.scorePushup(24, 38, 'M'));
|
||||
});
|
||||
|
||||
it('alternativa incompleta ma piu recente (sit-to-stand senza altezza) non entra: vale lo squat precedente', () => {
|
||||
const m = new Map([
|
||||
['f_sit_to_stand_rip', 14], // manca f_altezza_cm: bmi non calcolabile, alternativa incompleta
|
||||
['f_squat_carico_kg', 100], ['f_squat_rip', 5], ['f_peso_kg', 80],
|
||||
]);
|
||||
// sit-to-stand risulterebbe il piu recente SE fosse completo — ma non lo e', quindi
|
||||
// resta escluso dal gruppo a prescindere dalla recenza (regola: solo le alternative
|
||||
// complete entrano).
|
||||
const recenza = new Map([
|
||||
['f_sit_to_stand_rip', 0], ['f_squat_carico_kg', 1], ['f_squat_rip', 1],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, 65, 'M', recenza);
|
||||
expect(r.arti_inferiori).toBe(F.scoreSollevamentoSuPeso(100, 80, 5, F.TIERS_SQUAT_M, F.TIERS_SQUAT_F, 'M'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('i test "solo registrati" (nessuna tabella nel manuale) non producono chiavi', () => {
|
||||
it('panca manubri, trazioni, plank ginocchia, 6MWT, flamingo occhi chiusi: nessuna chiave nel risultato', () => {
|
||||
const m = new Map([
|
||||
['f_panca_man_carico_kg', 12], ['f_panca_man_rip', 10],
|
||||
['f_trazioni_rip', 8],
|
||||
['f_plank_ginocchia_sec', 90],
|
||||
['f_6mwt_m', 550],
|
||||
['f_flamingo_oc_dx', 10], ['f_flamingo_oc_sx', 12],
|
||||
['f_mutt_fc_2min_bpm', 110],
|
||||
['f_fc_riposo_bpm', 60],
|
||||
]);
|
||||
const r = sottoDominiFisici(m, 38, 'M');
|
||||
// nessuna di queste misure alimenta una funzione di scoring: il risultato resta {}
|
||||
expect(r).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
import * as F from '../../src/lib/longevity/motore/test-fisici'; // per i valori attesi della regola dei gruppi (#13)
|
||||
|
||||
function dbPronto(clientCode: string, sesso: 'M' | 'F' = 'M') {
|
||||
const db = createLongevityDb(':memory:');
|
||||
@@ -232,6 +233,40 @@ describe('un check-up e un questionario sono sessioni separate, ma un cliente e
|
||||
});
|
||||
});
|
||||
|
||||
describe('la regola dei gruppi (#13) su un DB vero: due sessioni, lo score usa il test piu recente', () => {
|
||||
it('pushup a gennaio, panca a marzo: il check-up di marzo usa la panca', () => {
|
||||
const db = dbPronto('ISL-0030', 'M');
|
||||
const sGennaio = apriSessione(db, { client_code: 'ISL-0030', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, sGennaio, 'manuale', [
|
||||
{ test_id: 'f_peso_kg', valore_num: 80 },
|
||||
{ test_id: 'f_pushup_rip', valore_num: 24 },
|
||||
]);
|
||||
const sMarzo = apriSessione(db, { client_code: 'ISL-0030', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, sMarzo, 'manuale', [
|
||||
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
|
||||
]);
|
||||
|
||||
// misureValideAlla porta avanti ANCHE il pushup di gennaio (mai sovrascritto): al check-up
|
||||
// di marzo il gruppo "spinta" vede entrambe le alternative complete, e vince la piu recente.
|
||||
const sd = sottoDominiDellaSessione(db, sMarzo);
|
||||
expect(sd.spinta).toBeCloseTo(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'), 5);
|
||||
});
|
||||
|
||||
it('panca a gennaio, pushup a marzo: il check-up di marzo usa il pushup (il contrario del caso sopra)', () => {
|
||||
const db = dbPronto('ISL-0031', 'M');
|
||||
const sGennaio = apriSessione(db, { client_code: 'ISL-0031', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, sGennaio, 'manuale', [
|
||||
{ test_id: 'f_peso_kg', valore_num: 80 },
|
||||
{ test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 },
|
||||
]);
|
||||
const sMarzo = apriSessione(db, { client_code: 'ISL-0031', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, sMarzo, 'manuale', [{ test_id: 'f_pushup_rip', valore_num: 24 }]);
|
||||
|
||||
const sd = sottoDominiDellaSessione(db, sMarzo);
|
||||
expect(sd.spinta).toBeCloseTo(F.scorePushup(24, 38, 'M'), 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campiDelQuestionario resta un modulo di sole domande', () => {
|
||||
it('nessun campo f_* (test fisico) compare fra i campi del questionario', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('il gestionale del trainer (Unità 2b)', () => {
|
||||
'src/pages/longevity/gestionale/nuovo.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code].astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
|
||||
];
|
||||
|
||||
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {
|
||||
|
||||
Reference in New Issue
Block a user