From c8187ce4dbb53b3d525fa428dab1c523814c1e00 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sun, 27 Sep 2026 22:54:26 +0200 Subject: [PATCH] longevity: modulo check-up con sezioni e alternative del manuale (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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(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(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, valori: Map +): 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 { + 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(); + 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 +): Set { + const scartati = new Set(); + 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 }; diff --git a/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts index e507cc0..330dfc9 100644 --- a/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts +++ b/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts @@ -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_` 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 { + const scelte = new Map(); + 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)); diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro index e5bef49..1b8ec39 100644 --- a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/checkup.astro @@ -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'); --- - {gruppi.map((g) => ( + {modulo.sezioni.map((s) => (
-

{g.asse}

- {g.campi.map((c) => ( +

{s.titolo}

+ {s.tipo === 'campi' ? s.campi.map((c) => ( + + )) : ( +
+ {s.alternative.map((alt) => ( +
+ + + + {alt.soglia &&

{alt.soglia}

} + {alt.campi.map((c) => ( + + ))} + {alt.dettagli && alt.dettagli.length > 0 && ( +
+ Frequenza cardiaca per step (7,5-14,5 km/h) + {alt.dettagli.map((c) => ( + + ))} +
+ )} +
+ ))} +
+ )} +
+ ))} + + {modulo.altreMisure.length > 0 && ( +
+

Altre misure

+ {modulo.altreMisure.map((c) => ( ))}
- ))} + )} + + {!sessione && ( +
+

Note operatore

+ +
+ )}
Annulla @@ -73,3 +136,19 @@ const errore = Astro.url.searchParams.get('errore');
+ + diff --git a/apps/platforms/src/styles/longevity.css b/apps/platforms/src/styles/longevity.css index 2f338b2..56ceef6 100644 --- a/apps/platforms/src/styles/longevity.css +++ b/apps/platforms/src/styles/longevity.css @@ -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; }