longevity: registro dei test e pesi versionati come dato
This commit is contained in:
@@ -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<string, Record<string, number>> = {
|
||||||
|
'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<string, Record<string, number>> = {
|
||||||
|
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<string, number> = {
|
||||||
|
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<string, unknown>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user