longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo
Una sessione per punto (solo quelle con righe in score), i punteggi congelati da leggiScore, e cambioVersione quando model_version o quest_version (non nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con quest_version NULL, non innesca né nasconde un cambio. Refs #5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// L'andamento nel tempo di un cliente: una riga per sessione, i punteggi congelati che
|
||||
// quella sessione ha davvero, e dove segnare che qualcosa nel modo di calcolare è cambiato.
|
||||
//
|
||||
// Stessa regola non negoziabile di `refertoDi` (vista.ts): i valori vengono da `leggiScore`
|
||||
// (punteggi congelati in `score`), MAI da un ricalcolo — un peso cambiato dopo il fatto non
|
||||
// deve alterare un punto già emesso, tanto quanto non altera un referto già emesso. Qui in
|
||||
// più c'è la STORIA di più sessioni, quindi la domanda "quale sessione entra nel grafico" ha
|
||||
// una risposta precisa: solo quelle che hanno almeno una riga in `score`. Una sessione aperta
|
||||
// (`apriSessione`) ma mai calcolata — capita quando il consenso non è dato, o quando qualcosa
|
||||
// si è fermato a metà prima del congelamento — non ha punteggi da mostrare, e mostrarla come
|
||||
// un punto vuoto sarebbe un buco silenzioso nel grafico, non un dato mancante dichiarato.
|
||||
import type Database from 'better-sqlite3';
|
||||
import { leggiScore } from './motore';
|
||||
|
||||
/**
|
||||
* Un punto della progressione: una sessione con i suoi punteggi congelati.
|
||||
* `questVersion` è `null` per un check-up (le sessioni `checkup` non passano dal
|
||||
* questionario, quindi `sessioni.quest_version` resta NULL — vedi db.ts).
|
||||
* `assi`/`macro` sono `Record<nome, number|null>`: la chiave è il nome del contenitore
|
||||
* (es. 'Forza & Struttura', 'PERFORMANCE') così com'è scritto in `score.elemento`, il
|
||||
* valore è `null` quando lo stato congelato era 'insufficiente' — mai un numero inventato.
|
||||
* `cambioVersione` è il testo del cambio rispetto al PUNTO PRECEDENTE (non rispetto
|
||||
* all'ultimo `quest_version` in assoluto, tranne per il caso descritto sotto), `null` sul
|
||||
* primo punto per costruzione (non c'è un "prima").
|
||||
*/
|
||||
export type PuntoProgressione = {
|
||||
sessioneId: number;
|
||||
data: string;
|
||||
tipo: 'checkup' | 'questionario';
|
||||
questVersion: string | null;
|
||||
modelVersion: string;
|
||||
assi: Record<string, number | null>;
|
||||
macro: Record<string, number | null>;
|
||||
fitnessAge: number | null;
|
||||
cambioVersione: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Le sessioni di un cliente, in ordine cronologico, che hanno almeno una riga in `score`
|
||||
* — le uniche che `progressioneDi` può mostrare. Stesso criterio d'ordine di
|
||||
* `misureValideAlla`/`leggiScore` (data ASC, poi id ASC: qui l'ordine è cronologico in
|
||||
* avanti, l'opposto di quelle due funzioni che cercano "il più recente", ma il criterio
|
||||
* di spareggio a parità di data è lo stesso — l'id di sessione, non l'ordine con cui
|
||||
* SQLite restituisce le righe).
|
||||
*/
|
||||
function sessioniConScore(db: Database.Database, clientCode: string) {
|
||||
return db.prepare(
|
||||
`SELECT id AS sessioneId, data, tipo
|
||||
FROM sessioni
|
||||
WHERE client_code = ?
|
||||
AND EXISTS (SELECT 1 FROM score WHERE score.sessione_id = sessioni.id)
|
||||
ORDER BY data ASC, id ASC`
|
||||
).all(clientCode) as { sessioneId: number; data: string; tipo: 'checkup' | 'questionario' }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `quest_version`/`model_version` della sessione, letti da `score` (non da `sessioni`:
|
||||
* `sessioni.quest_version` è quello che il cliente ha compilato, `score.quest_version` è
|
||||
* quello con cui l'ultimo calcolo congelato ha davvero girato — nel funzionamento normale
|
||||
* coincidono sempre, ma è `score` la fonte che questo modulo legge per coerenza con
|
||||
* `leggiScore`, non un'altra tabella). Una sessione può avere più calcoli nel tempo (vedi
|
||||
* il commento di `leggiScore` su `salvaScore` senza vincolo di unicità): si prende la riga
|
||||
* più recente per id, lo stesso criterio con cui `leggiScore` sceglie il valore di ciascun
|
||||
* elemento.
|
||||
*/
|
||||
function versioniDi(db: Database.Database, sessioneId: number): { questVersion: string | null; modelVersion: string } {
|
||||
const riga = db.prepare(
|
||||
`SELECT quest_version, model_version FROM score WHERE sessione_id = ? ORDER BY id DESC LIMIT 1`
|
||||
).get(sessioneId) as { quest_version: string | null; model_version: string } | undefined;
|
||||
// Non dovrebbe mai mancare: sessioniConScore filtra con lo stesso EXISTS. Se manca è un
|
||||
// bug di questa funzione (query diversa da quella del filtro), non un caso di prodotto —
|
||||
// meglio un errore esplicito che un punto silenziosamente senza versione.
|
||||
if (!riga) throw new Error(`sessione ${sessioneId} senza righe in score, nonostante il filtro`);
|
||||
return { questVersion: riga.quest_version, modelVersion: riga.model_version };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'andamento nel tempo di un cliente: un punto per sessione calcolata, in ordine
|
||||
* cronologico. `cambioVersione` marca dove il modo di calcolare è cambiato:
|
||||
* - il `modelVersion` è diverso da quello del punto precedente (un cambio di curve o pesi
|
||||
* cambia il significato del numero, non solo il suo valore);
|
||||
* - oppure il `questVersion` (quando non è `null`) è diverso dall'ultimo `questVersion`
|
||||
* NON NULLO visto prima — non dal punto immediatamente precedente: un check-up in mezzo
|
||||
* a due questionari ha `questVersion` null e non deve né innescare un cambio (non ha una
|
||||
* versione da confrontare) né "nascondere" un cambio vero fra i due questionari che lo
|
||||
* circondano (il confronto salta il check-up, non si ferma su di lui).
|
||||
*/
|
||||
export function progressioneDi(db: Database.Database, clientCode: string): PuntoProgressione[] {
|
||||
const sessioni = sessioniConScore(db, clientCode);
|
||||
|
||||
const punti: PuntoProgressione[] = [];
|
||||
let modelVersionPrecedente: string | null = null;
|
||||
let questVersionNonNullaPrecedente: string | null = null;
|
||||
|
||||
for (const s of sessioni) {
|
||||
const punteggi = leggiScore(db, s.sessioneId);
|
||||
const { questVersion, modelVersion } = versioniDi(db, s.sessioneId);
|
||||
|
||||
const assi: Record<string, number | null> = {};
|
||||
for (const [nome, p] of Object.entries(punteggi.assi)) assi[nome] = p.stato === 'ok' ? p.valore : null;
|
||||
|
||||
const macro: Record<string, number | null> = {};
|
||||
for (const [nome, p] of Object.entries(punteggi.macro)) macro[nome] = p.stato === 'ok' ? p.valore : null;
|
||||
|
||||
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
|
||||
|
||||
const cambi: string[] = [];
|
||||
if (punti.length > 0) {
|
||||
if (modelVersionPrecedente !== null && modelVersion !== modelVersionPrecedente) {
|
||||
cambi.push(`modello ${modelVersionPrecedente} → ${modelVersion}`);
|
||||
}
|
||||
if (questVersion !== null && questVersionNonNullaPrecedente !== null && questVersion !== questVersionNonNullaPrecedente) {
|
||||
cambi.push(`questionario ${questVersionNonNullaPrecedente} → ${questVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
punti.push({
|
||||
sessioneId: s.sessioneId, data: s.data, tipo: s.tipo,
|
||||
questVersion, modelVersion, assi, macro, fitnessAge,
|
||||
cambioVersione: cambi.length > 0 ? cambi.join('; ') : null,
|
||||
});
|
||||
|
||||
modelVersionPrecedente = modelVersion;
|
||||
if (questVersion !== null) questVersionNonNullaPrecedente = questVersion;
|
||||
}
|
||||
|
||||
return punti;
|
||||
}
|
||||
Reference in New Issue
Block a user