From 370dcbda56c9bf3b66245f3b96810fdcdd0935fe Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:22:32 +0200 Subject: [PATCH] longevity: registro dei test e pesi versionati come dato --- src/lib/longevity/registro.ts | 181 +++++++++++++++++++++++++++++++ tests/longevity/registro.test.ts | 74 +++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/lib/longevity/registro.ts create mode 100644 tests/longevity/registro.test.ts diff --git a/src/lib/longevity/registro.ts b/src/lib/longevity/registro.ts new file mode 100644 index 0000000..d934a5f --- /dev/null +++ b/src/lib/longevity/registro.ts @@ -0,0 +1,181 @@ +import type Database from 'better-sqlite3'; + +export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; + +export type VoceRegistro = { + test_id: string; + etichetta: string; + unita?: string; + tipo_valore: 'num' | 'txt'; + curva?: Curva; + params?: unknown; + asse?: string; + sotto_dominio?: string; + range_min?: number; + range_max?: number; + attivo_da: string; + attivo_a?: string; +}; + +const DA = '2026-01-01'; + +// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html). +// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma +// dichiarata per ciascuna curva, non nell'array posizionale del prototipo. +const QUESTIONARIO: VoceRegistro[] = [ + { test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num', + curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + + { test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio', + tipo_valore: 'num', curva: 'x10_inv', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + + { test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali', + tipo_valore: 'num', curva: 'x10', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA }, + + { test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA }, + { test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA }, + // Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO), + // confermata il 21/08. Non e' un refuso per 0=100 -> 7=0. + { test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 7, worst: 14 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA }, + { test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, +]; + +export function seedRegistro(db: Database.Database): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO registro_test + (test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio, + range_min, range_max, attivo_da, attivo_a, note) + VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio, + @range_min, @range_max, @attivo_da, @attivo_a, @note)` + ); + const tx = db.transaction((voci: VoceRegistro[]) => { + for (const v of voci) { + ins.run({ + test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null, + tipo_valore: v.tipo_valore, curva: v.curva ?? null, + params: v.params === undefined ? null : JSON.stringify(v.params), + asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null, + range_min: v.range_min ?? null, range_max: v.range_max ?? null, + attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null, + }); + } + }); + tx(QUESTIONARIO); +} + +// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, +// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono +// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge. +const PESI_ASSE: Record> = { + 'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 }, + 'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 }, + 'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 }, + 'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 }, + 'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 }, + 'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 }, + 'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 }, +}; + +const PESI_MACRO: Record> = { + PERFORMANCE: { + 'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15, + 'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10, + 'Energia & Regolazione Stress': 0.05, + }, + ENERGY: { + 'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20, + 'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10, + }, + RECOVERY: { + 'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15, + 'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05, + }, +}; + +const PESI_FITNESS_AGE: Record = { + cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20, + forza_resto: 0.15, composizione: 0.10, stabilita: 0.05, +}; + +export function seedPesi(db: Database.Database, modelVersion: string): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso) + VALUES (?, ?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [asse, sd] of Object.entries(PESI_ASSE)) + for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p); + for (const [macro, assi] of Object.entries(PESI_MACRO)) + for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p); + for (const [el, p] of Object.entries(PESI_FITNESS_AGE)) + ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p); + }); + tx(); +} + +export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] { + const righe = db.prepare( + `SELECT * FROM registro_test + WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?) + ORDER BY test_id` + ).all(alla, alla) as Record[]; + return righe.map((r) => ({ + ...r, + params: r.params ? JSON.parse(r.params as string) : undefined, + })) as VoceRegistro[]; +} + +export function esisteTest(db: Database.Database, testId: string): boolean { + const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); + return r !== undefined; +} diff --git a/tests/longevity/registro.test.ts b/tests/longevity/registro.test.ts new file mode 100644 index 0000000..4c7135c --- /dev/null +++ b/tests/longevity/registro.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro'; + +describe('registro dei test', () => { + it('carica i 20 campi punteggiati del questionario', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number }; + expect(q.n).toBe(20); + }); + + it('alcol_life porta la soglia 7-14 voluta da Nicola', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`) + .get() as { curva: string; params: string }; + expect(row.curva).toBe('lin_dec'); + expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 }); + }); + + it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`) + .get() as { curva: string }; + expect(row.curva).toBe('x10_inv'); + }); + + it('un test disattivato non e piu attivo dopo la sua data', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run(); + const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id); + expect(attivi).not.toContain('q_sigarette'); + expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette'); + }); + + it('esisteTest distingue un test vero da uno inventato', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + expect(esisteTest(db, 'q_ore_sonno')).toBe(true); + expect(esisteTest(db, 'q_inventato')).toBe(false); + }); + + it('i pesi di ogni asse sommano a 1', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi + WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore` + ).all() as { contenitore: string; tot: number }[]; + expect(righe.length).toBe(7); + for (const r of righe) expect(r.tot).toBe(1); + }); + + it('i pesi sono legati alla versione del modello', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + seedPesi(db, 'v2.0'); + const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('il plank pesa su due assi: va visto, non nascosto', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')` + ).all() as { contenitore: string }[]; + expect(righe.map((r) => r.contenitore).sort()) + .toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']); + }); +});