Merge branch 'lavoro/goldrake-i5' into lavoro/goldrake
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
// L'andamento nel tempo: una linea per macro-score, isola React come Radar (recharts ha
|
||||||
|
// bisogno del DOM). Stesso linguaggio visivo del radar, non uno nuovo:
|
||||||
|
// - `connectNulls={false}` su ogni linea: un valore mancante (sessione insufficiente per
|
||||||
|
// quel macro-score) è un BUCO nella linea, non un segmento che la attraversa come se il
|
||||||
|
// dato ci fosse. Un buco onesto vale più di una linea continua che mente.
|
||||||
|
// - le tre serie (PERFORMANCE/ENERGY/RECOVERY) si distinguono per tratteggio e spessore,
|
||||||
|
// MAI per colore: qui non c'è semaforo, come nel radar (vedi longevity.css). Un colore
|
||||||
|
// diverso per macro-score si leggerebbe come un giudizio ("questo è il buono, quello è il
|
||||||
|
// cattivo"), che il servizio non fa.
|
||||||
|
// - la riga verticale tratteggiata su un cambio di versione usa lo stesso colore del
|
||||||
|
// perimetro "non lo sappiamo ancora" del radar (TRATTEGGIO): un confine dichiarato, non
|
||||||
|
// un errore.
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { PuntoProgressione } from '../../lib/longevity/progressione';
|
||||||
|
import { formattaNumero } from '../../lib/longevity/formato';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
punti: PuntoProgressione[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stessi colori di Radar.tsx (esadecimali, non var(--...): recharts vuole attributi SVG,
|
||||||
|
// non proprietà CSS). Non sono colori nuovi, sono una copia — vedi il commento in Radar.tsx.
|
||||||
|
const ACCENTO = '#9c8b70';
|
||||||
|
const TRATTEGGIO = '#d8d2c8';
|
||||||
|
const LINEA = '#e4dfd7';
|
||||||
|
const TESTO_CHIARO = '#8a8a8a';
|
||||||
|
|
||||||
|
/** 'PERFORMANCE' -> 'Performance': stessa trasformazione di MacroScore.astro, non un
|
||||||
|
* dizionario duplicato da tenere allineato — il NOME resta quello di prodotto (formato.ts),
|
||||||
|
* solo la resa grafica cambia maiuscola/minuscola. */
|
||||||
|
const etichetta = (nome: string) => nome.charAt(0) + nome.slice(1).toLowerCase();
|
||||||
|
|
||||||
|
// Tratteggio e spessore che distinguono le tre serie senza un colore nuovo per ciascuna:
|
||||||
|
// PERFORMANCE piena e più spessa (il macro-score principale), le altre due via via più
|
||||||
|
// leggere. L'ordine qui non impone un ordine di lettura: è solo il fallback per un
|
||||||
|
// macro-score che un domani non fosse fra questi tre (mai successo finora, ma
|
||||||
|
// `progressioneDi` non hardcoda i nomi — vedi il commento lì — quindi nemmeno qui).
|
||||||
|
const STILI_SERIE: { dash?: string; width: number }[] = [
|
||||||
|
{ dash: undefined, width: 2.5 },
|
||||||
|
{ dash: '6 3', width: 2 },
|
||||||
|
{ dash: '2 2', width: 1.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Progressione({ punti }: Props) {
|
||||||
|
const nomiMacro = Array.from(new Set(punti.flatMap((p) => Object.keys(p.macro))));
|
||||||
|
const nomiAssi = Array.from(new Set(punti.flatMap((p) => Object.keys(p.assi))));
|
||||||
|
|
||||||
|
const dati = punti.map((p) => {
|
||||||
|
const riga: Record<string, string | number | null> = { data: p.data };
|
||||||
|
for (const nome of nomiMacro) riga[nome] = p.macro[nome];
|
||||||
|
return riga;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cambi = punti.filter((p) => p.cambioVersione !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg-progressione">
|
||||||
|
<p className="lg-progressione__intro">
|
||||||
|
Una linea per macro-score (Performance, Energy, Recovery): dove manca un tratto, quella
|
||||||
|
sessione non aveva dati sufficienti per quel numero — non è uno zero, è un buco. La
|
||||||
|
riga verticale tratteggiata segna dove è cambiato il questionario o il modo di
|
||||||
|
calcolare: i numeri prima e dopo non si confrontano allo stesso modo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="lg-progressione__grafico">
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={dati} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
||||||
|
<CartesianGrid stroke={LINEA} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="data" stroke={TESTO_CHIARO} fontSize={11} tickLine={false} />
|
||||||
|
<YAxis domain={[0, 100]} stroke={TESTO_CHIARO} fontSize={11} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ border: `1px solid ${LINEA}`, borderRadius: 0, fontSize: 12 }}
|
||||||
|
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
{cambi.map((p) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={p.sessioneId}
|
||||||
|
x={p.data}
|
||||||
|
stroke={TRATTEGGIO}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
label={{ value: p.cambioVersione ?? '', position: 'top', fill: TESTO_CHIARO, fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{nomiMacro.map((nome, i) => {
|
||||||
|
const stile = STILI_SERIE[i % STILI_SERIE.length];
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={nome}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={nome}
|
||||||
|
name={etichetta(nome)}
|
||||||
|
stroke={ACCENTO}
|
||||||
|
strokeWidth={stile.width}
|
||||||
|
strokeDasharray={stile.dash}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* La tabella per asse: la stessa granularità del radar (sette assi), qui nel tempo
|
||||||
|
invece che in un solo istante. «—» dove il dato non basta, mai uno zero. */}
|
||||||
|
<div className="lg-progressione__tabella">
|
||||||
|
<table className="lg-progressione__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
{punti.map((p) => (
|
||||||
|
<th key={p.sessioneId} className="lg-num">{p.data}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nomiAssi.map((nome) => (
|
||||||
|
<tr key={nome}>
|
||||||
|
<td>{nome}</td>
|
||||||
|
{punti.map((p) => {
|
||||||
|
const v = p.assi[nome];
|
||||||
|
return (
|
||||||
|
<td key={p.sessioneId} className="lg-num">
|
||||||
|
{v !== null && v !== undefined ? formattaNumero(v) : '—'}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
|||||||
<p>
|
<p>
|
||||||
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
||||||
raccontano come cambi, non solo come stai oggi.
|
raccontano come cambi, non solo come stai oggi.
|
||||||
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
|
<a href="/longevity/progressione">Guarda il tuo andamento nel tempo</a> o
|
||||||
|
<a href="/longevity/questionario">compila di nuovo il questionario</a>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
// L'andamento nel tempo del cliente: una sessione per punto, i punteggi congelati che
|
||||||
|
// quella sessione ha davvero. Stesso modello di io.astro: la pagina non interroga il
|
||||||
|
// database da sola (c'è un test che lo verifica, referto.test.ts, esteso a tutte le pagine
|
||||||
|
// sotto src/pages/longevity/), il codice cliente arriva da codicePerUtenteLoggato
|
||||||
|
// (anagrafica.ts, l'unico modulo autorizzato a toccare identity), i punti da progressioneDi
|
||||||
|
// (progressione.ts, che legge SOLO score — mai un ricalcolo).
|
||||||
|
import Longevity from '../../layouts/Longevity.astro';
|
||||||
|
import Progressione from '../../components/longevity/Progressione';
|
||||||
|
import { getLongevityDb } from '../../lib/longevity/db';
|
||||||
|
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
|
||||||
|
import { progressioneDi } from '../../lib/longevity/progressione';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const user = Astro.locals.user!;
|
||||||
|
const codice = codicePerUtenteLoggato(user.id);
|
||||||
|
const db = getLongevityDb();
|
||||||
|
const punti = codice ? progressioneDi(db, codice) : [];
|
||||||
|
---
|
||||||
|
<Longevity title="Andamento nel tempo" crumbs={['Andamento nel tempo']}>
|
||||||
|
{!codice && (
|
||||||
|
<div class="lg-card">
|
||||||
|
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codice && punti.length < 2 && (
|
||||||
|
<div class="lg-card lg-card--incompleto">
|
||||||
|
<p>Serve almeno un secondo check-up o questionario per vedere un andamento.</p>
|
||||||
|
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codice && punti.length >= 2 && (
|
||||||
|
<div class="lg-referto">
|
||||||
|
<section class="lg-card">
|
||||||
|
<h2>Il tuo andamento</h2>
|
||||||
|
<Progressione client:load punti={punti} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lg-card">
|
||||||
|
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Longevity>
|
||||||
@@ -100,6 +100,22 @@
|
|||||||
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
|
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
|
||||||
.lg-radar__lista li { margin-bottom: 2px; }
|
.lg-radar__lista li { margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* --- l'andamento nel tempo: il grafico vive nell'isola React (Progressione.tsx),
|
||||||
|
qui solo il testo introduttivo e la tabella per asse sotto al grafico. --- */
|
||||||
|
.lg-progressione { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.lg-progressione__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
|
||||||
|
.lg-progressione__grafico { width: 100%; }
|
||||||
|
.lg-progressione__tabella { overflow-x: auto; }
|
||||||
|
.lg-progressione__table { width: 100%; border-collapse: collapse; font-size: .82rem; }
|
||||||
|
.lg-progressione__table th, .lg-progressione__table td {
|
||||||
|
padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--lg-line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lg-progressione__table th:first-child, .lg-progressione__table td:first-child {
|
||||||
|
text-align: left; color: var(--c-heading); font-weight: 500;
|
||||||
|
}
|
||||||
|
.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; }
|
||||||
|
|
||||||
/* --- le carte dei macro-score --- */
|
/* --- le carte dei macro-score --- */
|
||||||
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
|
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
|
||||||
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
|
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ describe('il guscio della piattaforma', () => {
|
|||||||
const src = readFileSync(p, 'utf8');
|
const src = readFileSync(p, 'utf8');
|
||||||
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
|
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('la pagina dell andamento non interroga il database e usa codicePerUtenteLoggato', () => {
|
||||||
|
// Rete secondaria alla stessa prova generica di referto.test.ts ("nessuna pagina
|
||||||
|
// interroga il database da sola", che scandisce TUTTE le .astro sotto longevity/):
|
||||||
|
// qui si controlla in piu' che il codice cliente arrivi dalla via autorizzata, non da
|
||||||
|
// un'altra query o da un parametro della richiesta.
|
||||||
|
const p = join(process.cwd(), 'src/pages/longevity/progressione.astro');
|
||||||
|
expect(existsSync(p)).toBe(true);
|
||||||
|
const src = readFileSync(p, 'utf8');
|
||||||
|
expect(src).not.toMatch(/\.prepare\(/);
|
||||||
|
expect(src).toMatch(/codicePerUtenteLoggato/);
|
||||||
|
// Stessa rete secondaria di anagrafica.test.ts: nessuna giunzione identity/longevity
|
||||||
|
// in un file fuori dai due moduli autorizzati.
|
||||||
|
expect(src).not.toContain('Identity');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('il gestionale del trainer (Unità 2b)', () => {
|
describe('il gestionale del trainer (Unità 2b)', () => {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// tests/longevity/progressione.test.ts
|
||||||
|
// L'andamento nel tempo: progressioneDi deve leggere SOLO ciò che è congelato in `score`
|
||||||
|
// (mai un ricalcolo, stessa regola di refertoDi) e marcare correttamente dove il modo di
|
||||||
|
// calcolare è cambiato (quest_version/model_version), senza farsi confondere da un
|
||||||
|
// check-up in mezzo a due questionari (quest_version NULL per un check-up).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||||
|
import { salvaDalForm } from '../../src/lib/longevity/vista';
|
||||||
|
import { apriSessione } from '../../src/lib/longevity/misure';
|
||||||
|
import { calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
|
||||||
|
import { progressioneDi } from '../../src/lib/longevity/progressione';
|
||||||
|
|
||||||
|
const RISPOSTE_COMPLETE = {
|
||||||
|
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
|
||||||
|
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
|
||||||
|
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
|
||||||
|
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dbPronto() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db);
|
||||||
|
seedPesi(db, MODEL_VERSION);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una sessione di check-up, calcolata e congelata come farebbe l'operatore in sala:
|
||||||
|
* nessuna domanda del questionario, quindi `quest_version` resta NULL (vedi db.ts). */
|
||||||
|
function aggiungiCheckup(db: ReturnType<typeof dbPronto>, data: string) {
|
||||||
|
const sessioneId = apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 35 });
|
||||||
|
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
|
||||||
|
return sessioneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un questionario compilato per intero, via salvaDalForm — la stessa funzione che
|
||||||
|
* chiama l'endpoint reale (vedi salvataggio-e2e.test.ts): calcola e congela da sola. */
|
||||||
|
function aggiungiQuestionario(db: ReturnType<typeof dbPronto>, data: string) {
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data, eta: 35,
|
||||||
|
risposte: RISPOSTE_COMPLETE, consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
if (!esito.ok) throw new Error(esito.errore);
|
||||||
|
return esito.sessioneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('progressioneDi: due questionari e un check-up', () => {
|
||||||
|
it('produce tre punti, in ordine cronologico', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
const s2 = aggiungiCheckup(db, '2026-02-10');
|
||||||
|
const s3 = aggiungiQuestionario(db, '2026-03-10');
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
expect(punti.length).toBe(3);
|
||||||
|
expect(punti.map((p) => p.sessioneId)).toEqual([s1, s2, s3]);
|
||||||
|
expect(punti.map((p) => p.data)).toEqual(['2026-01-10', '2026-02-10', '2026-03-10']);
|
||||||
|
expect(punti[0].tipo).toBe('questionario');
|
||||||
|
expect(punti[1].tipo).toBe('checkup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i valori coincidono con leggiScore, mai un ricalcolo', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const s1 = aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
const punteggi = leggiScore(db, s1);
|
||||||
|
|
||||||
|
for (const [nome, p] of Object.entries(punteggi.assi)) {
|
||||||
|
expect(punti[0].assi[nome]).toBe(p.stato === 'ok' ? p.valore : null);
|
||||||
|
}
|
||||||
|
for (const [nome, p] of Object.entries(punteggi.macro)) {
|
||||||
|
expect(punti[0].macro[nome]).toBe(p.stato === 'ok' ? p.valore : null);
|
||||||
|
}
|
||||||
|
expect(punti[0].fitnessAge).toBe(punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un asse senza dati sufficienti resta null, mai un numero inventato', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
// 'Forza & Struttura' non ha nessuna misura fisica in questa sessione (solo questionario).
|
||||||
|
expect(punti[0].assi['Forza & Struttura']).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quest_version NULL per il check-up', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
aggiungiCheckup(db, '2026-02-10');
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
expect(punti[1].questVersion).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessun cambio di versione: cambioVersione resta null su ogni punto dopo il primo', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
aggiungiCheckup(db, '2026-02-10');
|
||||||
|
aggiungiQuestionario(db, '2026-03-10');
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
expect(punti[0].cambioVersione).toBeNull(); // primo punto: mai un "prima" da confrontare
|
||||||
|
expect(punti[1].cambioVersione).toBeNull(); // il check-up in mezzo
|
||||||
|
expect(punti[2].cambioVersione).toBeNull(); // il secondo questionario, stessa versione
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un check-up fra due questionari alla stessa versione non produce un cambio', () => {
|
||||||
|
// Stesso scenario del test sopra, isolato: qui si verifica in particolare che il
|
||||||
|
// check-up (quest_version NULL) non "nasconda" né inventi un cambio fra i due
|
||||||
|
// questionari v1.0 che lo circondano.
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
aggiungiCheckup(db, '2026-02-10');
|
||||||
|
const s3 = aggiungiQuestionario(db, '2026-03-10');
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
const puntoCheckup = punti.find((p) => p.tipo === 'checkup')!;
|
||||||
|
const puntoSecondoQuest = punti.find((p) => p.sessioneId === s3)!;
|
||||||
|
expect(puntoCheckup.cambioVersione).toBeNull();
|
||||||
|
expect(puntoSecondoQuest.cambioVersione).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un cambio di quest_version produce il testo del cambio', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
const s3 = aggiungiQuestionario(db, '2026-03-10');
|
||||||
|
|
||||||
|
// Nessuna seconda versione delle domande esiste davvero oggi (QUEST_VERSION è una sola
|
||||||
|
// costante): si simula il cambio scrivendo direttamente la riga congelata, come farebbe
|
||||||
|
// un domani un vero v1.1 del questionario.
|
||||||
|
db.prepare(`UPDATE score SET quest_version = 'v1.1' WHERE sessione_id = ?`).run(s3);
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
const punto = punti.find((p) => p.sessioneId === s3)!;
|
||||||
|
expect(punto.cambioVersione).toMatch(/questionario v1\.0 → v1\.1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una sessione senza righe in score resta esclusa dalla progressione', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
aggiungiQuestionario(db, '2026-01-10');
|
||||||
|
// Sessione aperta ma mai calcolata: nessuna riga in score (consenso non dato, o un
|
||||||
|
// calcolo mai lanciato — non un caso teorico, vedi il commento in salvaDalForm).
|
||||||
|
apriSessione(db, { client_code: 'ISL-0001', data: '2026-02-01', tipo: 'checkup', eta_alla_data: 35 });
|
||||||
|
|
||||||
|
const punti = progressioneDi(db, 'ISL-0001');
|
||||||
|
expect(punti.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user