Files
InsanityLab-web/apps/platforms/src/lib/longevity/motore/index.ts
T
Adriano abd0626ab0 longevity: registro e motore per la nuova batteria test (manuale Nicola 27/09)
Nuovi test_id nel registro (altezza, FC riposo, handgrip/back scratch/flamingo
dx-sx, panca manubri/bilanciere, trazioni, sit-to-stand, plank ginocchia,
6MWT, 2km walk UKK, MILFIT, MUTT FC max/1'/2'/step) con attivo_da, e i quattro
test_id storici sostituiti (handgrip_kg, back_scratch_cm, flamingo_cadute,
hrr_1min_bpm) marcati attivo_a 2026-09-28 senza cancellarli.

motore/index.ts: nuova recenzaValideAlla (posizione di recenza per test_id,
stesso ORDER BY di misureValideAlla, fattorizzata la query condivisa).

motore/fisici.ts: regola dei gruppi in sottoDominiFisici (recenza opzionale) -
per ogni sotto-metrica con piu' test alternativi si sceglie l'alternativa
COMPLETA la cui misura piu' recente vince; senza recenza resta l'ordine di
priorita' di oggi. Handgrip/back_scratch/flamingo ora mediano dx/sx; spinta e
arti_inferiori aggiungono panca bilanciere e sit-to-stand; vo2max aggiunge
MILFIT e 2km walk alla cascata esistente.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:45:26 +02:00

458 lines
22 KiB
TypeScript

/**
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
* test, o cambiare un parametro, resta una modifica ai dati.
*
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale, o una misura fisica —
* vedi sotto) non contribuisce a QUESTO livello.
*
* Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola
* `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una
* curva generica (età, sesso, a volte un'altra misura come il peso
* corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini
* da curva (questionario) e sotto-domini fisici — in un'unica mappa.
*
* Un check-up e un questionario sono sessioni SEPARATE: senza altro, una
* sessione vedrebbe solo le proprie misure e lascerebbe vuoti gli assi
* dell'altra sessione. `misureValideAlla` risolve questo leggendo, per ogni
* test_id, la misura valida più recente DEL CLIENTE fra tutte le sue
* sessioni fino a quella data — non solo quelle della sessione corrente.
*
* Livelli 2-4 (`calcolaSessione`): usa `sottoDominiDellaSessione`, poi
* `cascata.ts` (`calcolaAsse`, `calcolaMacro`, `calcolaFitnessAge`) coi pesi
* letti da `pesi` (via `pesiDi`) per i sette assi, i tre macro-score e la
* Fitness Age.
*
* `salvaScore` congela il risultato nella tabella `score`, una riga per
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
* a mano su una colonna nullable.
*
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
* forma negativa; `applicaCurva` valida i parametri della curva prima di
* applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer
* (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`).
*/
import type Database from 'better-sqlite3';
import {
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
} from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
import { sottoDominiFisici, type Sesso } from './fisici';
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = {
low?: number; peakLow?: number; peakHigh?: number; high?: number;
best?: number; worst?: number; plateauStart?: number;
steps?: [number, number][]; zeroVal?: number;
};
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
function numeroValido(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x);
}
/**
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
* punteggio pieno, la peggior direzione di guasto possibile per un motore
* clinico.
*/
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
switch (curva) {
case 'bell':
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
case 'lin_dec':
return numeroValido(p.best) && numeroValido(p.worst);
case 'inc_plateau':
return numeroValido(p.worst) && numeroValido(p.plateauStart);
case 'x10':
case 'x10_inv':
return true; // nessun parametro richiesto
case 'decstep':
case 'incstep':
return Array.isArray(p.steps) && p.steps.length > 0 &&
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
default:
return false;
}
}
/**
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
*/
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
if (!voce.curva) return null;
const p = (voce.params ?? {}) as ParamsCurva;
if (!paramsValidi(voce.curva, p)) return null;
switch (voce.curva) {
case 'bell':
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
case 'lin_dec':
return curvaDecrescente(valore, p.best as number, p.worst as number);
case 'inc_plateau':
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
case 'x10':
return curvaDirettaX10(valore);
case 'x10_inv':
return curvaDirettaX10Invertita(valore);
case 'decstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
case 'incstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
default:
return null;
}
}
/**
* Sotto questa età (in giorni) una misura smette di contare per una sessione successiva.
* `null` = nessun limite: oggi una misura resta valida per sempre, finché non ne arriva
* una più recente dello stesso test_id. Decisione aperta per Nicola — quanto "invecchia"
* un dato fisico prima di non poter più coprire il questionario di un mese dopo è una
* scelta di prodotto, non tecnica; finché non arriva si tiene la più permissiva delle due,
* perché la direzione di guasto opposta (scartare una misura buona) è quella che di nuovo
* produce assi vuoti che potrebbero non esserlo.
*/
export const FINESTRA_GIORNI: number | null = null;
/**
* Per ogni test_id, il valore valido più recente DI QUESTO CLIENTE fra tutte le sue
* sessioni fino a quella data compresa — non solo le misure della sessione `sessioneId`.
* Risolve il problema del check-up e del questionario come sessioni separate (vedi
* l'intestazione del file): un check-up del giorno D+10 vede ancora le risposte del
* questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò
* che accadrà dopo di lei.
*
* "Più recente" = data della sessione DESC, poi id di sessione DESC (a parità di data,
* l'inserimento più tardo vince) — stesso criterio di `leggiScore`. A parità di SESSIONE
* (due misure dello stesso test_id nella stessa sessione: `registraMisure` non lo vieta,
* es. una correzione manuale dopo un refuso) l'ORDER BY aggiunge un terzo criterio
* esplicito, `m.id` di riga in `misure` DESC: vince quella scritta dopo. Senza questo
* terzo criterio le due righe uscirebbero in un ordine NON definito da SQL (nessuna delle
* due chiavi precedenti le distingue), non "implicito" come diceva una versione precedente
* di questo commento — un ordine che sembra stabile per come SQLite le restituisce oggi,
* ma su cui non si può contare.
*
* Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della
* spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da
* una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`,
* colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel
* momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione
* successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta
* esclusa come prima.
*/
/** Una riga grezza di `misure`, con la sessione di provenienza: usata sia da
* `misureValideAlla` che da `recenzaValideAlla` (stessa query, stesso ORDER BY). */
type RigaMisuraGrezza = { test_id: string; valore_num: number; data: string; sessione_id: number };
/** Fattorizza la query condivisa fra `misureValideAlla` e `recenzaValideAlla`: stessa
* sessione, stesse righe, stesso ordine. Aggiunto `s2.id AS sessione_id` (assente
* nella query originale) perche' `recenzaValideAlla` deve sapere DA QUALE sessione viene
* ogni riga, non solo la data — due sessioni nello stesso giorno sono comunque due
* posizioni di recenza diverse (lo stesso criterio di `ORDER BY s2.id DESC`). */
function righeMisureGrezze(
db: Database.Database, sessioneId: number
): { sessione: { client_code: string; data: string; id: number }; righe: RigaMisuraGrezza[] } {
const sessione = db.prepare(
`SELECT client_code, data, id FROM sessioni WHERE id = ?`
).get(sessioneId) as { client_code: string; data: string; id: number } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const righe = db.prepare(
`SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data, s2.id AS sessione_id
FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id
WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL
AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?))
ORDER BY s2.data DESC, s2.id DESC, m.id DESC`
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as RigaMisuraGrezza[];
return { sessione, righe };
}
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
const mappa = new Map<string, number>();
for (const r of righe) {
// gia' trovato un valore per questo test_id: e' il piu' recente, per data di sessione,
// poi id di sessione, poi (spareggio) id di riga in misure — tutti e tre espliciti
// nell'ORDER BY qui sopra, la prima riga per test_id in questo ciclo e' sempre quella giusta.
if (mappa.has(r.test_id)) continue;
if (FINESTRA_GIORNI !== null) {
const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000;
if (giorni > FINESTRA_GIORNI) continue;
}
mappa.set(r.test_id, r.valore_num);
}
return mappa;
}
/**
* La posizione di recenza di ogni test_id (0 = il piu' recente), sullo STESSO ordine di
* `misureValideAlla` (`righeMisureGrezze`: data di sessione DESC, poi id di sessione DESC).
* Non e' una data: e' un rango relativo fra le SESSIONI (data, sessione_id) di questo
* cliente fino a questa sessione compresa — due test_id misurati nella stessa sessione
* hanno la STESSA posizione (sono ugualmente "recenti": la regola dei gruppi di
* `sottoDominiFisici`, issue #13, rompe quel pareggio con l'ordine di priorita' del
* gruppo, non con l'id di riga in `misure`). Serve a scegliere, fra piu' alternative
* complete per una stessa sotto-metrica (es. panca vs push-up), quella la cui misura piu'
* recente vince.
*/
export function recenzaValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const { sessione, righe } = righeMisureGrezze(db, sessioneId);
const mappa = new Map<string, number>();
let posizione = -1;
let chiaveSessionePrecedente: string | null = null;
for (const r of righe) {
if (FINESTRA_GIORNI !== null) {
const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000;
if (giorni > FINESTRA_GIORNI) continue;
}
if (mappa.has(r.test_id)) continue; // gia' assegnata la sua posizione (la piu' recente)
const chiaveSessione = `${r.data}|${r.sessione_id}`;
if (chiaveSessione !== chiaveSessionePrecedente) {
posizione++;
chiaveSessionePrecedente = chiaveSessione;
}
mappa.set(r.test_id, posizione);
}
return mappa;
}
/**
* I sotto-domini di una sessione, dalle due fonti che oggi esistono: il questionario
* (livello 1, `applicaCurva` sulle misure di `misureValideAlla`) e i test fisici
* (`sottoDominiFisici`, sull'età alla data della sessione e il sesso del cliente). In
* caso di chiave in conflitto vince il fisico — oggi non capita mai (i sotto-domini da
* curva sono tutti `questionario_*`), ma se domani un test_id q_* condividesse la chiave
* di un sotto-dominio fisico, un dato misurato in un check-up deve prevalere su
* un'autovalutazione.
*/
export function sottoDominiDellaSessione(db: Database.Database, sessioneId: number): Record<string, number> {
const sessione = db.prepare(
`SELECT data, eta_alla_data, client_code FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const misurate = misureValideAlla(db, sessioneId);
// Recenza per test_id (issue #13): serve alla regola dei gruppi di sottoDominiFisici,
// per scegliere fra alternative COMPLETE quella con la misura piu' recente.
const recenza = recenzaValideAlla(db, sessioneId);
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
const attivi = testAttivi(db, sessione.data);
const perSottoDominio = new Map<string, number[]>();
for (const voce of attivi) {
if (!voce.sotto_dominio) continue;
const grezzo = misurate.get(voce.test_id);
if (grezzo === undefined) continue;
const punteggio = applicaCurva(voce, grezzo);
if (punteggio === null) continue;
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
lista.push(punteggio);
perSottoDominio.set(voce.sotto_dominio, lista);
}
const daCurva: Record<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
daCurva[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
const soggetto = db.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(sessione.client_code) as
{ sesso: Sesso } | undefined;
// soggetto dovrebbe sempre esistere (sessioni.client_code referenzia soggetti, FK attive
// in db.ts): il controllo resta comunque, perche' l'assenza di dato fisico e' un {}
// sicuro, mai un sesso indovinato.
const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso, recenza) : {};
return { ...daCurva, ...daFisici };
}
/**
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age.
* `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
* e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la
* tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il
* composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che
* altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'.
*/
export type RisultatoSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
fitnessAge: number | null;
fitnessAgeCopertura: number;
modelVersion: string;
questVersion: string | null;
};
/**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
* `misure` (tramite `sottoDominiDellaSessione`), i pesi da `pesi` (tramite `pesiDi`).
*/
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare(
`SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
// Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione.
const sottoDominio = sottoDominiDellaSessione(db, sessioneId);
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
const nomiAssi = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const assi: Record<string, Punteggio> = {};
for (const nomeAsse of nomiAssi) {
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
}
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
const nomiMacro = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const macro: Record<string, Punteggio> = {};
for (const nomeMacro of nomiMacro) {
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
}
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
// come nell'oracolo, che guarda quei due sotto-domini da soli.
const valoreAsse = (nome: string): number | null => {
const a = assi[nome];
return a && a.stato === 'ok' ? a.valore : null;
};
const vociFitnessAge: Record<string, number | null> = {
cardio: valoreAsse('Cardio-Respiratorio'),
handgrip_isolato: sottoDominio.handgrip ?? null,
hrv_isolato: sottoDominio.hrv ?? null,
forza_resto: valoreAsse('Forza & Struttura'),
composizione: valoreAsse('Composizione Corporea'),
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
};
const { fitnessAge, composito } = calcolaFitnessAge(
sessione.eta_alla_data,
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
vociFitnessAge
);
return {
assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura,
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
};
}
/**
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
*/
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const ins = db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
ins.run(
sessione.client_code, sessioneId, tipo, elemento,
p.stato === 'ok' ? p.valore : null,
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
);
};
const tx = db.transaction(() => {
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null
? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura }
: { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura };
riga('fitness_age', 'fitness_age', fitnessAgePunteggio);
});
tx();
}
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
export type PunteggiSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
fitnessAge: Punteggio | null;
};
/**
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
*
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
* che non è un criterio).
*/
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
const righe = db.prepare(
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
FROM score s
JOIN (
SELECT tipo, elemento, MAX(id) AS ultimo_id
FROM score
WHERE sessione_id = ?
GROUP BY tipo, elemento
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
WHERE s.sessione_id = ?`
).all(sessioneId, sessioneId) as
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
const assi: Record<string, Punteggio> = {};
const macro: Record<string, Punteggio> = {};
let fitnessAge: Punteggio | null = null;
for (const r of righe) {
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
else fitnessAge = aPunteggio(r);
}
return { assi, macro, fitnessAge };
}