6301f521fd
Campus, Stress Index e Longevity escono dal sito e diventano apps.insanitylab.it, con utenti, sessioni, login e database propri. Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni autosufficienti, cosi' che ognuna resti estraibile. Misura che ha guidato il taglio: le tre aree condividevano col sito SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts. Layout, librerie e dati erano gia' disgiunti. Lato piattaforme: - ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali - canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno) - tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti - /api risponde 401/403, non un redirect verso una pagina di login - tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi Lato sito: - ROLES tornano i tre redazionali; via le regole e il login pubblico - il login del pannello RESPINGE un account emigrato invece di aprirgli una sessione: senza, landingFor lo mandava su /admin, il middleware glielo negava, e si formava un ciclo di redirect che sembra un guasto del sito - l'header non mostra piu' un menu delle piattaforme: un link che esce Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra in Campus e viene rimbalzato da Longevity, il cliente il contrario. Non ancora in produzione: manca il record DNS di apps.insanitylab.it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
200 lines
12 KiB
TypeScript
200 lines
12 KiB
TypeScript
import type Database from 'better-sqlite3';
|
|
|
|
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
|
|
|
|
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
|
|
export const MODEL_VERSION = 'v1.0';
|
|
|
|
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.
|
|
// ⚠️ Il plank compare volutamente in DUE assi: 'core' in Forza & Struttura (0.15) e
|
|
// 'plank' in Stabilità & Mobilità (0.20). Confermato da Nicola il 22/08 e motivato:
|
|
// il plank non è forza pura, richiede controllo neuromuscolare del tronco, quindi
|
|
// contribuisce davvero a entrambi i domini che misura. Non è un refuso da correggere.
|
|
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;
|
|
}
|
|
|
|
/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */
|
|
export function pesiDi(
|
|
db: Database.Database, modelVersion: string,
|
|
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
|
|
): Record<string, number> {
|
|
const righe = db.prepare(
|
|
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
|
|
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
|
|
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
|
|
}
|