longevity: modulo check-up con sezioni e alternative del manuale (#13)

strutturaCheckup in gestionale.ts (ordine Pre-test..Strumenti, sezioni ad
alternative con soglia testuale, «Altre misure» per i test dismessi in
correzione, pressione/SpO2 esclusi); checkup.astro con radio button e
fieldset (niente <select>); API checkup.ts scarta i campi delle
alternative non scelte e aggiunge una voce di giornale dalla nota
operatore sul check-up nuovo.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 22:54:26 +02:00
parent cffb0a624d
commit c8187ce4db
4 changed files with 322 additions and 9 deletions
@@ -56,6 +56,197 @@ 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) — assente = vale la prima alternativa (comportamento del form senza JS/radio
* mancante). 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) ?? s.alternative[0]?.chiave;
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 };
@@ -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, correggiCheckup } from '../../../../lib/longevity/gestionale';
import {
salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
type SezioneCheckup,
} from '../../../../lib/longevity/gestionale';
import { aggiungiVoce } from '../../../../lib/longevity/giornale';
import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false;
@@ -52,6 +56,22 @@ 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 impostato nulla, o un refuso) resta assente dalla mappa — la sceglie
* `testIdScartatiDalleAlternative` per default sulla prima alternativa dell'elenco.
*/
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 clientCodeForm = String(form.get('client_code') ?? '').trim();
@@ -105,22 +125,36 @@ export const POST: APIRoute = async ({ request, locals }) => {
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();
if (sessioneId !== null) {
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure: parse.misure });
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: parse.misure });
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): se la nota fosse rifiutata
// (troppo lunga, data futura — non dovrebbe succedere, stessa data già validata sopra)
// il check-up resta comunque salvato, non si perde per un problema della nota.
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));
@@ -9,7 +9,9 @@
import Longevity from '../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import { campiDelCheckup, misureDellaSessione } from '../../../../../lib/longevity/gestionale';
import {
misureDellaSessione, strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup,
} from '../../../../../lib/longevity/gestionale';
export const prerender = false;
@@ -30,8 +32,13 @@ if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client
}
const dataForm = sessione?.data ?? oggi;
const gruppi = campiDelCheckup(db, dataForm);
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');
---
<Longevity
@@ -55,17 +62,73 @@ const errore = Astro.url.searchParams.get('errore');
<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} 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"></textarea>
</label>
</section>
)}
<div class="lg-form__nav">
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
@@ -73,3 +136,19 @@ const errore = Astro.url.searchParams.get('errore');
</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>
+9
View File
@@ -156,6 +156,15 @@
.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; }
/* 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); }
.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; }
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }