Compare commits
10 Commits
0e9b081a79
...
1d6bdd0b3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d6bdd0b3d | |||
| a56398ea02 | |||
| 6630be7a02 | |||
| 7f01ee0b45 | |||
| 8c980750c0 | |||
| f0629db3c4 | |||
| f5ca841d92 | |||
| 2ea822a84d | |||
| 34c66ca438 | |||
| 779aef32e6 |
@@ -0,0 +1,405 @@
|
|||||||
|
# Longevity — l'interfaccia: piano di implementazione
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** dare una faccia al fascicolo del cliente — il questionario che si compila in pagina e il referto che il cliente legge, col radar a sette assi.
|
||||||
|
|
||||||
|
**Architecture:** pagine Astro dentro il sito esistente, sotto `/longevity/`, con un layout dedicato e un foglio di stile prefissato. Il contenuto statico è Astro; React solo dove serve interattività vera — il questionario a blocchi e il radar. I dati arrivano dal motore già costruito, mai da query scritte nelle pagine.
|
||||||
|
|
||||||
|
**Tech Stack:** Astro, React, recharts (già in casa), better-sqlite3, vitest. Nessuna dipendenza nuova.
|
||||||
|
|
||||||
|
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
|
||||||
|
|
||||||
|
**Piani precedenti, già eseguiti:** `2026-08-21-longevity-strato-dati.md` e `2026-08-22-longevity-motore.md`. Lo strato dati e il motore esistono, sono testati, e nessuna pagina li usa ancora.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
|
||||||
|
- **Nessuna dipendenza npm nuova.**
|
||||||
|
- Test in `tests/longevity/`, eseguiti con `npm test`. Codice e commenti in italiano.
|
||||||
|
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
|
||||||
|
- ⚠️ **`npx tsc --noEmit` deve restare pulito.** In questo lavoro il tipo è il meccanismo di sicurezza: un typecheck rosso è un cancello che nessuno guarda più.
|
||||||
|
- **Nessun dato reale di persone** nelle fixture.
|
||||||
|
|
||||||
|
## Le due regole di prodotto che l'interfaccia non può violare
|
||||||
|
|
||||||
|
**1. Un punteggio insufficiente non si mostra come numero.** Il motore restituisce un tipo che nel ramo insufficiente **non ha il campo `valore`**: la pagina non ha da dove prenderlo. Ma la barriera finisce lì — la tabella dei punteggi è letta da una funzione tipata (`leggiScore`), e **le pagine devono usare quella**, mai una query propria. Una `SELECT valore FROM score` scritta in una pagina riapre il buco.
|
||||||
|
|
||||||
|
**2. Il colore non giudica il corpo.** Niente semaforo verde/giallo/rosso sui punteggi: il servizio è premium ma **non clinico**, e un rosso su «Composizione Corporea» detto a una persona è un giudizio, non un'informazione. I punteggi si esprimono con l'**intensità** del colore d'accento del sito. L'unico colore di segnale — il mattone `#b05a4e` già presente nel foglio di Stress Index — è riservato ai **valori fuori range**, che segnalano un problema **della misura**, non della persona.
|
||||||
|
|
||||||
|
## Il gesto che questa interfaccia deve avere
|
||||||
|
|
||||||
|
Il radar mostra **quello che sappiamo** come area piena, e dove la misura non basta lascia un **perimetro tratteggiato** con, sotto, cosa manca per completarlo. Non un buco, non un errore: un invito.
|
||||||
|
|
||||||
|
Regge tre cose insieme — la regola di prodotto («tratteggiato, mai un numero pieno fasullo»), la leva commerciale che il cliente voleva per il livello avanzato, e una posizione onesta: *non ti diciamo un numero che non sappiamo*.
|
||||||
|
|
||||||
|
⚠️ **Con i dati di oggi sei assi su sette saranno tratteggiati**, perché nel registro c'è solo il questionario e i test fisici arrivano col piano degli import. È atteso: chi guarda la prima dashboard non deve scambiarlo per un guasto.
|
||||||
|
|
||||||
|
## Struttura dei file
|
||||||
|
|
||||||
|
| File | Responsabilità |
|
||||||
|
|---|---|
|
||||||
|
| `src/layouts/Longevity.astro` | layout della piattaforma: intestazione, niente header pubblico |
|
||||||
|
| `src/styles/longevity.css` | stile prefissato `.lg`, globale (deve raggiungere le isole React) |
|
||||||
|
| `src/pages/longevity/questionario.astro` | la pagina del questionario |
|
||||||
|
| `src/components/longevity/Questionario.tsx` | isola React: quattro blocchi, validazione, invio |
|
||||||
|
| `src/pages/api/longevity/questionario.ts` | endpoint che salva una compilazione |
|
||||||
|
| `src/pages/longevity/io.astro` | il referto del cliente |
|
||||||
|
| `src/components/longevity/Radar.tsx` | isola React: il radar a sette assi col tratteggio |
|
||||||
|
| `src/components/longevity/MacroScore.astro` | le tre carte dei macro-score |
|
||||||
|
| `src/lib/longevity/vista.ts` | ciò che serve alle pagine, letto dal motore |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Il guscio — layout, stile, e una rotta che risponde
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/layouts/Longevity.astro`, `src/styles/longevity.css`, `src/pages/longevity/index.astro`
|
||||||
|
- Test: `tests/longevity/pagine.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `isProtectedPath`, `canAccessAdminPath` da `src/lib/auth.ts`
|
||||||
|
- Produces: il layout `Longevity.astro` con `title` e `crumbs`, e le classi `.lg-*`
|
||||||
|
|
||||||
|
**Il modello da seguire:** `src/layouts/StressIndex.astro` e `src/styles/stress-index.css`. Leggili prima di scrivere. Il foglio di stile va **globale e prefissato**, non in `<style>` scoped: le stesse classi servono ai componenti Astro e alle isole React, che lo scope di Astro non raggiunge — la ragione è scritta in testa al foglio di Stress Index.
|
||||||
|
|
||||||
|
**La differenza voluta rispetto a Stress Index.** Quella piattaforma ha una barra laterale con sette voci, giusta per chi ci **lavora** dentro. Qui i pubblici sono due e uno non è staff: il **cliente** entra tre o quattro volte l'anno per guardare il proprio referto, e una barra di navigazione è l'arredamento di uno strumento di lavoro. Quindi il layout prevede due forme: `variante="referto"` (una colonna che scorre, nessuna navigazione) e `variante="gestionale"` (barra laterale come Stress Index). Questo task realizza la prima; la seconda arriva col piano del gestionale.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/longevity/pagine.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||||
|
|
||||||
|
describe('il guscio della piattaforma', () => {
|
||||||
|
it('il layout esiste e non monta header e footer pubblici', () => {
|
||||||
|
const l = leggi('src/layouts/Longevity.astro');
|
||||||
|
expect(l).not.toMatch(/import\s+Header/);
|
||||||
|
expect(l).not.toMatch(/import\s+Footer/);
|
||||||
|
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il foglio di stile e globale e prefissato, non scoped', () => {
|
||||||
|
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
|
||||||
|
const css = leggi('src/styles/longevity.css');
|
||||||
|
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
|
||||||
|
expect(classi.length).toBeGreaterThan(3);
|
||||||
|
expect(classi.every((c) => c.startsWith('lg'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usa i token del sito invece di inventare colori', () => {
|
||||||
|
const css = leggi('src/styles/longevity.css');
|
||||||
|
expect(css).toMatch(/var\(--c-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessun semaforo sui punteggi: il colore non giudica il corpo', () => {
|
||||||
|
const css = leggi('src/styles/longevity.css').toLowerCase();
|
||||||
|
// il mattone del fuori range e ammesso; un verde "tutto bene" no
|
||||||
|
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la rotta del cliente e sotto /longevity, dove le regole la proteggono', () => {
|
||||||
|
expect(existsSync(join(process.cwd(), 'src/pages/longevity/index.astro'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/longevity/pagine.test.ts`
|
||||||
|
Expected: FAIL — i file non esistono
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Il layout prende `title`, `crumbs` e `variante`, importa i font e i due fogli di stile (`global.css` e `longevity.css`), e dichiara `noindex`. Nessun `Header`/`Footer` pubblico: la navigazione è interna.
|
||||||
|
|
||||||
|
Il foglio `longevity.css` definisce, **tutte prefissate `lg`**: il guscio del referto (una colonna centrata, larghezza leggibile), l'intestazione, la carta di un punteggio, e i token locali. Le variabili locali riprendono quelle del sito (`--c-accent`, `--c-dark`, `--c-bg-alt`) e ne aggiungono due sole:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--lg-tratteggio: #d8d2c8; /* il perimetro di cio che non sappiamo */
|
||||||
|
--lg-fuori-range: #b05a4e; /* SOLO per una misura sospetta, mai per un punteggio */
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Non definire un colore «buono» o «cattivo» per i punteggi: l'intensità si ottiene variando l'opacità dell'accento. È la regola di prodotto, e il test la controlla.
|
||||||
|
|
||||||
|
`src/pages/longevity/index.astro` reindirizza a `/longevity/io`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/longevity/pagine.test.ts` → PASS (5 test)
|
||||||
|
Poi: `npm test` per intero e `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layouts/Longevity.astro src/styles/longevity.css src/pages/longevity/index.astro tests/longevity/pagine.test.ts
|
||||||
|
git commit -m "longevity: il guscio della piattaforma, senza semaforo sui punteggi"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Il questionario in pagina
|
||||||
|
|
||||||
|
Sostituisce il prototipo del cliente, che salva nel browser e che il documento stesso dichiara da buttare in produzione.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/longevity/Questionario.tsx`, `src/pages/longevity/questionario.astro`, `src/pages/api/longevity/questionario.ts`
|
||||||
|
- Test: `tests/longevity/questionario-pagina.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `testAttivi` da `registro.ts`; `salvaCompilazione`, `QUEST_VERSION`, `CAMPI_LIBERI` da `questionario.ts`; `codicePerUtente` da `anagrafica.ts`
|
||||||
|
- Produces: `POST /api/longevity/questionario` → `{ sessioneId }` o errore
|
||||||
|
|
||||||
|
**Le domande vengono dal registro, non dal codice.** La pagina legge `testAttivi` e costruisce i campi da lì: etichetta, tipo, minimo e massimo. Aggiungere una domanda resta una modifica ai dati — è la promessa della spec, e qui va mantenuta.
|
||||||
|
|
||||||
|
⚠️ **Tre regole che l'endpoint deve rispettare, e sono di sostanza:**
|
||||||
|
|
||||||
|
1. **Il codice cliente si ricava dalla sessione, mai dalla richiesta.** Un codice che arriva dal browser va ignorato. È la regola §8 della spec, e questo è il primo endpoint su cui si può finalmente provare.
|
||||||
|
2. **Il consenso al trattamento dei dati sanitari non è «compilato», è «dato».** Il prototipo del cliente blocca il salvataggio solo se il campo è vuoto: rispondendo **«No»** i dati vengono salvati lo stesso. È l'esatto contrario di ciò per cui il campo esiste. Qui: se il consenso non è affermativo, **la compilazione non si salva**, e la risposta lo dice chiaramente.
|
||||||
|
3. **Il consenso va registrato, non solo controllato.** Un consenso è un fatto che un domani va **dimostrato**: chi, quando, a cosa. Salvalo fra le note di profilo con la sua data e la versione del questionario, **anche quando la risposta è no** — il rifiuto è precisamente ciò di cui bisogna poter provare il rispetto.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/longevity/questionario-pagina.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||||
|
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
|
||||||
|
|
||||||
|
function dbPronto() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('i campi del questionario vengono dal registro', () => {
|
||||||
|
it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||||
|
expect(campi.length).toBe(20);
|
||||||
|
const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
|
||||||
|
expect(alcol.etichetta).toMatch(/alcoliche/i);
|
||||||
|
expect(alcol.max).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
|
||||||
|
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||||
|
expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
|
||||||
|
expect(campi.length).toBe(19);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('il salvataggio, e il consenso', () => {
|
||||||
|
const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
|
||||||
|
|
||||||
|
it('salva una compilazione quando il consenso e dato', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||||
|
expect(n.n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'No', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||||
|
expect(n.n).toBe(0); // niente sessione, niente misure
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NON salva se il consenso manca del tutto', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: '', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'No', liberi: {},
|
||||||
|
});
|
||||||
|
const note = db.prepare(
|
||||||
|
`SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
|
||||||
|
).all() as { campo_id: string; testo: string }[];
|
||||||
|
expect(note.length).toBeGreaterThan(0);
|
||||||
|
expect(note[0].testo).toMatch(/No/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
|
||||||
|
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
|
||||||
|
// il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
|
||||||
|
expect(src).toMatch(/codicePerUtente/);
|
||||||
|
expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/longevity/questionario-pagina.test.ts`
|
||||||
|
Expected: FAIL — `src/lib/longevity/vista.ts` non esiste
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`vista.ts` espone `campiDelQuestionario(db, alla)` — che legge `testAttivi` e tiene i soli `test_id` che iniziano per `q_` — e `salvaDalForm(db, input)`, che controlla il consenso **prima** di scrivere qualsiasi cosa, registra il consenso fra le note col suo esito e la versione del questionario, e delega a `salvaCompilazione`.
|
||||||
|
|
||||||
|
L'isola React mostra i quattro blocchi uno per schermata, con avanti e indietro, e invia in fondo. L'endpoint ricava il codice cliente dalla sessione con `codicePerUtente` e passa a `salvaDalForm`.
|
||||||
|
|
||||||
|
⚠️ Il consenso **rifiutato va scritto comunque** fra le note: apri la sessione solo se il consenso è affermativo, ma la nota del consenso si scrive in entrambi i casi, legata al cliente.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test` — atteso 1 fallito pre-esistente, il resto verde. Poi `npx tsc --noEmit` pulito.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/longevity/vista.ts src/components/longevity/Questionario.tsx \
|
||||||
|
src/pages/longevity/questionario.astro src/pages/api/longevity/questionario.ts \
|
||||||
|
tests/longevity/questionario-pagina.test.ts
|
||||||
|
git commit -m "longevity: il questionario in pagina, e il consenso che si puo negare davvero"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Il radar, e come si mostra ciò che non si sa
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/longevity/Radar.tsx`, `src/components/longevity/MacroScore.astro`, `src/pages/longevity/io.astro`
|
||||||
|
- Modify: `src/lib/longevity/vista.ts` — aggiungere `refertoDi`
|
||||||
|
- Test: `tests/longevity/referto.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `calcolaSessione`, `leggiScore` dal motore; `codicePerUtente` da `anagrafica.ts`
|
||||||
|
- Produces:
|
||||||
|
- `type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] }`
|
||||||
|
- `refertoDi(db, sessioneId): { assi: AsseVista[]; macro: {...}[]; fitnessAge: number | null }`
|
||||||
|
|
||||||
|
**Il punto del task.** Un asse senza dati sufficienti non porta un numero: porta **cosa manca per averlo**. `mancano` è l'elenco dei test del registro che alimenterebbero quell'asse e che quella sessione non ha — è ciò che trasforma un buco in un invito, ed è il gesto su cui si regge tutta l'interfaccia.
|
||||||
|
|
||||||
|
⚠️ **Le pagine non interrogano il database.** Tutto passa da `refertoDi`, che a sua volta usa `leggiScore`. Una `SELECT` scritta dentro una pagina riaprirebbe il buco che il tipo del motore chiude.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/longevity/referto.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||||
|
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||||
|
import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
|
||||||
|
import { refertoDi } from '../../src/lib/longevity/vista';
|
||||||
|
|
||||||
|
function conCompilazione() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db); seedPesi(db, MODEL_VERSION);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
const s = salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
|
||||||
|
});
|
||||||
|
salvaScore(db, s, calcolaSessione(db, s));
|
||||||
|
return { db, s };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('il referto del cliente', () => {
|
||||||
|
it('restituisce i sette assi', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
expect(refertoDi(db, s).assi.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un asse senza dati NON porta un numero', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||||
|
expect(forza.valore).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||||
|
expect(forza.mancano.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un asse coperto porta il suo numero', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
|
||||||
|
expect(stile.valore).not.toBeNull();
|
||||||
|
expect(stile.mancano).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legge i punteggi congelati, non li ricalcola al volo', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
// se rileggesse le misure invece degli score salvati, cambiare i pesi cambierebbe il referto
|
||||||
|
const prima = JSON.stringify(refertoDi(db, s));
|
||||||
|
db.prepare(`UPDATE pesi SET peso = 0.99 WHERE model_version = ? AND elemento = 'questionario_lifestyle'`).run(MODEL_VERSION);
|
||||||
|
expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessuna pagina interroga il database da sola', () => {
|
||||||
|
for (const f of ['src/pages/longevity/io.astro', 'src/pages/longevity/questionario.astro']) {
|
||||||
|
const src = readFileSync(join(process.cwd(), f), 'utf8');
|
||||||
|
expect(src, `${f} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/longevity/referto.test.ts`
|
||||||
|
Expected: FAIL — `refertoDi` non esiste
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`refertoDi` legge i punteggi congelati con `leggiScore` e, per ogni asse insufficiente, calcola `mancano`: i sotto-domini di quell'asse che non hanno nessuna misura nella sessione, tradotti nelle etichette leggibili del registro.
|
||||||
|
|
||||||
|
Il radar è un'isola React con `recharts`: due serie sovrapposte — una piena con i valori noti, una tratteggiata che chiude il perimetro dove il dato manca. Sotto, l'elenco di ciò che manca.
|
||||||
|
|
||||||
|
Le carte dei macro-score sono Astro. Un macro insufficiente mostra **un trattino**, non uno zero.
|
||||||
|
|
||||||
|
⚠️ I numeri usano `font-variant-numeric: tabular-nums`: in una pagina che confronta misure nel tempo, cifre che ballano sono un difetto di lettura.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test` — atteso 1 fallito pre-esistente. Poi `npx tsc --noEmit` pulito.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/longevity/Radar.tsx src/components/longevity/MacroScore.astro \
|
||||||
|
src/pages/longevity/io.astro src/lib/longevity/vista.ts tests/longevity/referto.test.ts
|
||||||
|
git commit -m "longevity: il referto del cliente, e il radar che dice cosa non sa"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cosa esiste alla fine di questo piano
|
||||||
|
|
||||||
|
Un cliente entra, compila il questionario, e vede il proprio referto col radar. Il consenso ai dati sanitari si può **negare davvero**, e il rifiuto resta registrato.
|
||||||
|
|
||||||
|
**Non esiste ancora**: il gestionale del trainer, l'inserimento dei check-up, l'assorbimento delle sezioni HRV di Stress Index, la progressione nel tempo. Sono i piani successivi.
|
||||||
|
|
||||||
|
⚠️ **E non si deploya niente.** Il consenso al trattamento dei dati sanitari è una questione aperta che il cliente sta chiudendo con un consulente legale: si può costruire tutto, non si può accendere la raccolta su persone vere finché quella risposta non arriva.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
// La carta di un macro-score (Performance/Energy/Recovery). Stesso principio del radar:
|
||||||
|
// il numero se il dato basta, un trattino se no — MAI uno zero, che si leggerebbe come
|
||||||
|
// "hai zero" invece di "non lo sappiamo ancora". Il bordo tratteggiato (.lg-card--incompleto,
|
||||||
|
// dal Task 1) è lo stesso linguaggio visivo del radar: non una convenzione nuova per le carte.
|
||||||
|
// L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un
|
||||||
|
// elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery
|
||||||
|
// con una trasformazione, non con un dizionario da tenere allineato a mano.
|
||||||
|
import { formattaNumero } from '../../lib/longevity/formato';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nome: string;
|
||||||
|
valore: number | null;
|
||||||
|
copertura: number;
|
||||||
|
}
|
||||||
|
const { nome, valore, copertura } = Astro.props;
|
||||||
|
const etichetta = nome.charAt(0) + nome.slice(1).toLowerCase();
|
||||||
|
// L'intensità dell'accento (mai un colore diverso, Task 1): minimo 0.15 così una carta
|
||||||
|
// nota ma bassa resta comunque leggibile, non sparisce nello sfondo.
|
||||||
|
const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0;
|
||||||
|
---
|
||||||
|
<div class:list={['lg-card', 'lg-macro__card', { 'lg-card--incompleto': valore === null }]}>
|
||||||
|
<p class="lg-score__label">{etichetta}</p>
|
||||||
|
<div class="lg-score">
|
||||||
|
<span class="lg-score__value lg-num">{valore !== null ? formattaNumero(valore) : '—'}</span>
|
||||||
|
<span class="lg-score__max">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="lg-score__bar">
|
||||||
|
<span
|
||||||
|
class="lg-score__bar-fill"
|
||||||
|
style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`}
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<p class="lg-muted lg-macro__nota">
|
||||||
|
Copertura dati: {Math.round(copertura * 100)}%{valore === null && ' — non ancora sufficiente per un numero.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
|
||||||
|
// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
|
||||||
|
// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
|
||||||
|
//
|
||||||
|
// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
|
||||||
|
// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
|
||||||
|
// proteggere e non protegge). La verifica che conta è quella del server
|
||||||
|
// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
|
||||||
|
// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { CampoQuestionario } from '../../lib/longevity/vista';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
campi: CampoQuestionario[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
|
||||||
|
{ id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
|
||||||
|
{ id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
|
||||||
|
{ id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
|
||||||
|
{ id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCCHI = [
|
||||||
|
{ titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
|
||||||
|
{ titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
|
||||||
|
{ titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
|
||||||
|
|
||||||
|
export default function Questionario({ campi }: Props) {
|
||||||
|
const [passo, setPasso] = useState(0);
|
||||||
|
const [risposte, setRisposte] = useState<Record<string, string>>({});
|
||||||
|
const [liberi, setLiberi] = useState<Record<string, string>>({});
|
||||||
|
const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
|
||||||
|
const [eta, setEta] = useState('');
|
||||||
|
const [inviando, setInviando] = useState(false);
|
||||||
|
const [esito, setEsito] = useState<Esito | null>(null);
|
||||||
|
|
||||||
|
// Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
|
||||||
|
// ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
|
||||||
|
const gruppi = useMemo(
|
||||||
|
() => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
|
||||||
|
[campi]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Il tipo dichiarato nel registro per ciascun campo: serve sia per scegliere il tipo di
|
||||||
|
// input (numero o testo) sia, all'invio, per non trattare una risposta testuale come un
|
||||||
|
// numero da scartare in silenzio.
|
||||||
|
const tipoDiCampo = useMemo(() => new Map(campi.map((c) => [c.id, c.tipo])), [campi]);
|
||||||
|
|
||||||
|
const totalePassi = gruppi.length + 1; // + il blocco note/consenso
|
||||||
|
const ultimo = passo === totalePassi - 1;
|
||||||
|
|
||||||
|
async function invia() {
|
||||||
|
setInviando(true);
|
||||||
|
setEsito(null);
|
||||||
|
|
||||||
|
const risposteNumeriche: Record<string, number> = {};
|
||||||
|
for (const [id, v] of Object.entries(risposte)) {
|
||||||
|
// Le domande testuali (tipo_valore='txt' nel registro) non sono ancora agganciate
|
||||||
|
// al salvataggio numerico: oggi non esistono nel questionario (i 20 campi attivi
|
||||||
|
// sono tutti 'num'), ma se un domani ne comparisse una non va convertita in NaN e
|
||||||
|
// scartata in silenzio — resta fuori esplicitamente.
|
||||||
|
if (tipoDiCampo.get(id) === 'txt') continue;
|
||||||
|
const n = Number(v);
|
||||||
|
if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/longevity/questionario', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
risposte: risposteNumeriche,
|
||||||
|
liberi,
|
||||||
|
consensoSanitario: consenso,
|
||||||
|
eta: eta === '' ? undefined : Number(eta),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const corpo = await res.json();
|
||||||
|
if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
|
||||||
|
else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
|
||||||
|
} catch {
|
||||||
|
setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
|
||||||
|
} finally {
|
||||||
|
setInviando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (esito?.tipo === 'ok') {
|
||||||
|
return (
|
||||||
|
<div className="lg-card">
|
||||||
|
<p>Questionario salvato, grazie.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg-form">
|
||||||
|
<p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
|
||||||
|
|
||||||
|
{passo < gruppi.length && (
|
||||||
|
<section className="lg-form__blocco">
|
||||||
|
<h2>{gruppi[passo].titolo}</h2>
|
||||||
|
{gruppi[passo].campi.map((c) => (
|
||||||
|
<label className="lg-field" key={c.id}>
|
||||||
|
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||||
|
{c.tipo === 'txt' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={risposte[c.id] ?? ''}
|
||||||
|
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={c.min}
|
||||||
|
max={c.max}
|
||||||
|
step="any"
|
||||||
|
value={risposte[c.id] ?? ''}
|
||||||
|
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ultimo && (
|
||||||
|
<section className="lg-form__blocco">
|
||||||
|
<h2>Note e consenso</h2>
|
||||||
|
|
||||||
|
{CAMPI_LIBERI.map((c) => (
|
||||||
|
<label className="lg-field" key={c.id}>
|
||||||
|
<span>{c.etichetta}</span>
|
||||||
|
<textarea
|
||||||
|
value={liberi[c.id] ?? ''}
|
||||||
|
onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<label className="lg-field">
|
||||||
|
<span>La tua età</span>
|
||||||
|
<input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="lg-field lg-field--consenso">
|
||||||
|
<legend>
|
||||||
|
Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
|
||||||
|
composizione corporea) per il monitoraggio del percorso?
|
||||||
|
</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio" name="consenso" checked={consenso === 'Si'}
|
||||||
|
onChange={() => setConsenso('Si')}
|
||||||
|
/>
|
||||||
|
Sì, acconsento
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio" name="consenso" checked={consenso === 'No'}
|
||||||
|
onChange={() => setConsenso('No')}
|
||||||
|
/>
|
||||||
|
No, non acconsento
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="lg-form__nav">
|
||||||
|
<button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
|
||||||
|
Indietro
|
||||||
|
</button>
|
||||||
|
{!ultimo && (
|
||||||
|
<button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
|
||||||
|
Avanti
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{ultimo && (
|
||||||
|
<button type="button" disabled={inviando || consenso === ''} onClick={invia}>
|
||||||
|
{inviando ? 'Invio…' : 'Invia'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Il radar a sette assi del referto: isola React perché recharts ha bisogno del DOM.
|
||||||
|
// Due serie sovrapposte sugli stessi sette assi (recharts vuole un solo `data` condiviso,
|
||||||
|
// non può disegnare un poligono che copre solo alcuni assi):
|
||||||
|
// - "noto": l'area piena, coi valori congelati che la sessione ha davvero. Dove il dato
|
||||||
|
// manca vale 0 — non si inventa un centro pieno per una misura che non c'è, si lascia
|
||||||
|
// che l'area si ritiri, onestamente, verso il centro;
|
||||||
|
// - "potenziale": il perimetro tratteggiato. Dove il dato c'è coincide col bordo di
|
||||||
|
// "noto" (stesso valore, la linea tratteggiata sparisce dietro quella piena); dove
|
||||||
|
// manca si allarga fino al massimo (100) — è la parte che CHIUDE il perimetro, il gesto
|
||||||
|
// su cui si regge l'interfaccia: non un errore, un invito a completare quell'asse.
|
||||||
|
// Sotto il grafico, l'elenco leggibile di cosa completerebbe ogni asse ancora tratteggiato,
|
||||||
|
// letto da `mancano` (calcolato in vista.ts, mai qui: questo componente non tocca il db).
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { AsseVista } from '../../lib/longevity/vista';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assi: AsseVista[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stessi colori del sito (come in DataChart.tsx, che per lo stesso motivo — attributi SVG,
|
||||||
|
// non proprietà CSS — non passa var(--...) a recharts): l'accento e il tratteggio di
|
||||||
|
// longevity.css, non un colore nuovo.
|
||||||
|
const ACCENTO = '#9c8b70';
|
||||||
|
const TRATTEGGIO = '#d8d2c8';
|
||||||
|
const LINEA = '#e4dfd7';
|
||||||
|
const TESTO_CHIARO = '#8a8a8a';
|
||||||
|
|
||||||
|
export default function RadarAssi({ assi }: Props) {
|
||||||
|
const dati = assi.map((a) => ({
|
||||||
|
nome: a.nome,
|
||||||
|
noto: a.valore ?? 0,
|
||||||
|
potenziale: a.valore ?? 100,
|
||||||
|
}));
|
||||||
|
const incompleti = assi.filter((a) => a.mancano.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg-radar">
|
||||||
|
{/* La spiegazione del linguaggio visivo viene PRIMA del grafico che spiega, non dopo:
|
||||||
|
chi legge deve sapere cosa sta guardando prima di guardarlo. */}
|
||||||
|
<p className="lg-radar__intro">
|
||||||
|
L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto
|
||||||
|
dell'asse, dove il dato non c'è ancora.
|
||||||
|
</p>
|
||||||
|
<div className="lg-radar__grafico">
|
||||||
|
<ResponsiveContainer width="100%" height={360}>
|
||||||
|
<RadarChart data={dati} outerRadius="72%">
|
||||||
|
<PolarGrid stroke={LINEA} />
|
||||||
|
<PolarAngleAxis dataKey="nome" tick={{ fill: TESTO_CHIARO, fontSize: 11 }} />
|
||||||
|
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
|
||||||
|
{/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */}
|
||||||
|
<Radar
|
||||||
|
name="Perimetro completo"
|
||||||
|
dataKey="potenziale"
|
||||||
|
stroke={TRATTEGGIO}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="Quello che sappiamo"
|
||||||
|
dataKey="noto"
|
||||||
|
stroke={ACCENTO}
|
||||||
|
fill={ACCENTO}
|
||||||
|
fillOpacity={0.32}
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{incompleti.length > 0 && (
|
||||||
|
<div className="lg-radar__mancano">
|
||||||
|
{/* L'invito concreto: l'elenco sotto è nomi di test, da solo non dice cosa fare.
|
||||||
|
Questi test si fanno in palestra con lo staff, non a casa da soli. */}
|
||||||
|
<p className="lg-muted">
|
||||||
|
Questi test si fanno in sala, con il trainer: prenota la prossima sessione per
|
||||||
|
completare il quadro.
|
||||||
|
</p>
|
||||||
|
{incompleti.map((a) => (
|
||||||
|
<div className="lg-radar__voce" key={a.nome}>
|
||||||
|
<p className="lg-radar__voce-nome">{a.nome}</p>
|
||||||
|
<ul className="lg-radar__lista">
|
||||||
|
{a.mancano.map((m) => <li key={m}>{m}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
// Layout della piattaforma Longevity. Come Stress Index: token, font e palette del sito,
|
||||||
|
// senza Header e Footer pubblici — l'area è riservata e non va mai indicizzata.
|
||||||
|
// A differenza di Stress Index (barra laterale con sette voci, giusta per chi ci lavora
|
||||||
|
// dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro
|
||||||
|
// volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno
|
||||||
|
// strumento di lavoro. Il layout prevede due forme:
|
||||||
|
// - "referto": una colonna che scorre, senza navigazione (questo task)
|
||||||
|
// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale)
|
||||||
|
import '@fontsource/montserrat/400.css';
|
||||||
|
import '@fontsource/montserrat/500.css';
|
||||||
|
import '@fontsource/montserrat/600.css';
|
||||||
|
import '@fontsource/open-sans/400.css';
|
||||||
|
import '@fontsource/open-sans/600.css';
|
||||||
|
import '../styles/global.css';
|
||||||
|
import '../styles/longevity.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
/** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */
|
||||||
|
crumbs?: string[];
|
||||||
|
/** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, non ancora implementata). */
|
||||||
|
variante?: 'referto' | 'gestionale';
|
||||||
|
}
|
||||||
|
const { title, crumbs = [], variante = 'referto' } = Astro.props;
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title} — Longevity InsanityLab</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
</head>
|
||||||
|
<body class="lg" data-variante={variante}>
|
||||||
|
<div class="lg-shell">
|
||||||
|
<header class="lg-top">
|
||||||
|
<p class="lg-top__crumbs">
|
||||||
|
<strong>Longevity</strong>
|
||||||
|
{crumbs.map((c) => <span> / {c}</span>)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main class="lg-main"><slot /></main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
|
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
|
||||||
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
|
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createIdentityDb } from './db';
|
||||||
|
|
||||||
|
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
|
||||||
|
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
|
||||||
|
// (un controllo testuale file-per-file non lo vedrebbe, ma qui il problema non si pone:
|
||||||
|
// non esiste un nome da importare). QUESTO è il modo in cui il vincolo si rende vero,
|
||||||
|
// non solo sorvegliato.
|
||||||
|
let identitySingleton: Database.Database | null = null;
|
||||||
|
function getIdentityDb(): Database.Database {
|
||||||
|
if (!identitySingleton) identitySingleton = createIdentityDb();
|
||||||
|
return identitySingleton;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
|
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
|
||||||
@@ -62,6 +74,17 @@ export function codicePerUtente(identity: Database.Database, userId: number): st
|
|||||||
return r?.client_code ?? null;
|
return r?.client_code ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il codice cliente a partire dall'utente di sessione del sito: apre lei stessa la
|
||||||
|
* connessione a identity (getIdentityDb), perché QUESTO è il modulo autorizzato a farlo.
|
||||||
|
* Un endpoint che deve solo sapere "chi è" chiama questa funzione e non ha mai bisogno
|
||||||
|
* di aprire identity da sé — riceve una stringa, mai una connessione che potrebbe
|
||||||
|
* incrociarsi con quella di longevity nello stesso file.
|
||||||
|
*/
|
||||||
|
export function codicePerUtenteLoggato(userId: number): string | null {
|
||||||
|
return codicePerUtente(getIdentityDb(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
||||||
export function etaAllaData(dataNascita: string, alla: string): number {
|
export function etaAllaData(dataNascita: string, alla: string): number {
|
||||||
const n = new Date(dataNascita);
|
const n = new Date(dataNascita);
|
||||||
|
|||||||
@@ -134,3 +134,17 @@ export function createLongevityDb(path?: string): Database.Database {
|
|||||||
export function createIdentityDb(path?: string): Database.Database {
|
export function createIdentityDb(path?: string): Database.Database {
|
||||||
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
|
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
|
||||||
|
// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
|
||||||
|
// ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton
|
||||||
|
// analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico
|
||||||
|
// modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione
|
||||||
|
// che passa da un re-export su piu' file, quindi la connessione condivisa a identity non
|
||||||
|
// deve essere raggiungibile da fuori quel modulo, punto.
|
||||||
|
let longevitySingleton: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function getLongevityDb(): Database.Database {
|
||||||
|
if (!longevitySingleton) longevitySingleton = createLongevityDb();
|
||||||
|
return longevitySingleton;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Formattazione dei numeri del referto. Un solo posto, perché prima ognuno stampava il
|
||||||
|
// numero grezzo di JavaScript (punto, non virgola) direttamente nel template — e un
|
||||||
|
// referto italiano con "72.5" al posto di "72,5" si legge come un difetto, non come un
|
||||||
|
// dettaglio.
|
||||||
|
|
||||||
|
/** Un punteggio o una Fitness Age, formattati con la virgola decimale italiana.
|
||||||
|
* Le ETICHETTE dei punteggi (PERFORMANCE/ENERGY/RECOVERY, nomi di prodotto) non si
|
||||||
|
* toccano: solo i NUMERI si leggono all'italiana in un referto italiano. */
|
||||||
|
export function formattaNumero(n: number): string {
|
||||||
|
return n.toLocaleString('it-IT');
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ import { esisteTest } from './registro';
|
|||||||
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
|
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
|
||||||
export const QUEST_VERSION = 'v1.0';
|
export const QUEST_VERSION = 'v1.0';
|
||||||
|
|
||||||
|
/** Versione del TESTO del consenso al trattamento dei dati sanitari — distinta da
|
||||||
|
* QUEST_VERSION apposta: se cambia la formulazione del consenso senza che cambi una
|
||||||
|
* domanda del questionario, il record del consenso deve poterlo dire. Prima di questa
|
||||||
|
* costante il record portava QUEST_VERSION, la versione sbagliata. */
|
||||||
|
export const CONSENT_VERSION = 'v1.0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Campi testuali che si salvano ma non entrano MAI in una formula
|
* Campi testuali che si salvano ma non entrano MAI in una formula
|
||||||
* (indicazione esplicita del cliente, punto 7 della specifica).
|
* (indicazione esplicita del cliente, punto 7 della specifica).
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//
|
||||||
|
// La vista che il questionario in pagina usa. Due responsabilità:
|
||||||
|
// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
|
||||||
|
// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
|
||||||
|
// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
|
||||||
|
// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
|
||||||
|
// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
|
||||||
|
// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
|
||||||
|
// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
|
||||||
|
// cosa) che un domani va dimostrato tanto quanto un assenso.
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
|
||||||
|
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
|
||||||
|
import { leggiScore, calcolaSessione, salvaScore } from './motore';
|
||||||
|
|
||||||
|
export type CampoQuestionario = {
|
||||||
|
id: string;
|
||||||
|
etichetta: string;
|
||||||
|
tipo: 'num' | 'txt';
|
||||||
|
unita?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
sottoDominio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
|
||||||
|
* `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
|
||||||
|
export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
|
||||||
|
return testAttivi(db, alla)
|
||||||
|
.filter((v) => v.test_id.startsWith('q_'))
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.test_id,
|
||||||
|
etichetta: v.etichetta,
|
||||||
|
tipo: v.tipo_valore,
|
||||||
|
unita: v.unita,
|
||||||
|
min: v.range_min,
|
||||||
|
max: v.range_max,
|
||||||
|
sottoDominio: v.sotto_dominio,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EsitoSalvataggio =
|
||||||
|
| { ok: true; sessioneId: number }
|
||||||
|
| { ok: false; errore: string };
|
||||||
|
|
||||||
|
const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
|
||||||
|
|
||||||
|
function consensoDato(risposta: string): boolean {
|
||||||
|
return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso
|
||||||
|
* (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che
|
||||||
|
* cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla
|
||||||
|
* sessione solo quando il consenso è affermativo e la sessione esiste davvero. */
|
||||||
|
function registraConsenso(
|
||||||
|
db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
|
||||||
|
): void {
|
||||||
|
const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
|
||||||
|
const oggi = new Date().toISOString().slice(0, 10);
|
||||||
|
const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
|
||||||
|
).run(clientCode, sessioneId, 'consenso_sanitario', testo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
|
||||||
|
* qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
|
||||||
|
* note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
|
||||||
|
* scrive comunque, con l'esito letterale ricevuto.
|
||||||
|
*
|
||||||
|
* Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il
|
||||||
|
* salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la
|
||||||
|
* nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa
|
||||||
|
* correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava
|
||||||
|
* calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla
|
||||||
|
* transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che
|
||||||
|
* li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint
|
||||||
|
* quelle interne di `salvaCompilazione` e `salvaScore`.
|
||||||
|
*/
|
||||||
|
export function salvaDalForm(
|
||||||
|
db: Database.Database,
|
||||||
|
input: {
|
||||||
|
client_code: string; data: string; eta?: number;
|
||||||
|
risposte: Record<string, number>;
|
||||||
|
consensoSanitario: string;
|
||||||
|
liberi?: Record<string, string>;
|
||||||
|
}
|
||||||
|
): EsitoSalvataggio {
|
||||||
|
if (!consensoDato(input.consensoSanitario)) {
|
||||||
|
registraConsenso(db, input.client_code, null, input.consensoSanitario);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const sessioneId = salvaCompilazione(db, {
|
||||||
|
client_code: input.client_code,
|
||||||
|
data: input.data,
|
||||||
|
eta: input.eta,
|
||||||
|
risposte: input.risposte,
|
||||||
|
liberi: input.liberi,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo
|
||||||
|
// successivo affidato a chi legge: senza questo, una compilazione restava per sempre
|
||||||
|
// senza i suoi punteggi.
|
||||||
|
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
|
||||||
|
|
||||||
|
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
|
||||||
|
|
||||||
|
return sessioneId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, sessioneId: tx() };
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Il referto che il cliente legge: il radar a sette assi e le carte dei macro-score.
|
||||||
|
// Due regole non negoziabili, entrambe già chiuse a monte, non da questo modulo:
|
||||||
|
// - il valore viene da `leggiScore` (punteggi congelati in `score`), mai da un
|
||||||
|
// ricalcolo — cambiare i pesi non deve mai cambiare un referto già emesso;
|
||||||
|
// - un asse/macro insufficiente non porta un `valore` (resta `null`): è il tipo
|
||||||
|
// `Punteggio` del motore che lo rende impossibile da violare per distrazione.
|
||||||
|
//
|
||||||
|
|
||||||
|
/** Un asse del radar: il valore (assente se il dato non basta), la copertura di peso
|
||||||
|
* disponibile e — solo quando manca — l'elenco leggibile di cosa lo completerebbe. */
|
||||||
|
export type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] };
|
||||||
|
|
||||||
|
/** Una carta di macro-score: stesso principio degli assi, senza l'elenco di cosa manca
|
||||||
|
* (il "cosa manca" si legge scendendo ai sette assi, non qui). */
|
||||||
|
export type MacroVista = { nome: string; valore: number | null; copertura: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
|
||||||
|
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
|
||||||
|
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
|
||||||
|
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
|
||||||
|
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
|
||||||
|
* dove il registro non arriva ancora.
|
||||||
|
*/
|
||||||
|
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
|
||||||
|
handgrip: 'Forza di presa (handgrip)',
|
||||||
|
spinta: 'Test di spinta (push-up o panca)',
|
||||||
|
trazione: 'Test di trazione',
|
||||||
|
arti_inferiori: 'Test arti inferiori',
|
||||||
|
core: 'Test del core (plank)',
|
||||||
|
grasso: 'Composizione corporea — massa grassa',
|
||||||
|
muscolo: 'Composizione corporea — massa muscolare',
|
||||||
|
whr: 'Rapporto vita-fianchi (WHR)',
|
||||||
|
vo2max: 'Test cardio (VO2max)',
|
||||||
|
spirometria: 'Spirometria',
|
||||||
|
wellness_tower_cardio: 'Wellness Tower — cardio',
|
||||||
|
hrv: 'Variabilità della frequenza cardiaca (HRV)',
|
||||||
|
pressione: 'Pressione arteriosa',
|
||||||
|
hrr: 'Frequenza cardiaca di recupero (HRR)',
|
||||||
|
questionario_sonno: 'Questionario del sonno',
|
||||||
|
questionario_energia_stress: 'Questionario energia e stress',
|
||||||
|
flamingo: 'Test di equilibrio (flamingo)',
|
||||||
|
sit_and_reach: 'Test di flessibilità (sit and reach)',
|
||||||
|
plank: 'Plank (tenuta)',
|
||||||
|
back_scratch: 'Test di mobilità delle spalle (back scratch)',
|
||||||
|
wellness_tower_shoulder: 'Wellness Tower — spalla',
|
||||||
|
questionario_lifestyle: 'Questionario di stile di vita',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** I contenitori (assi o macro-score) dichiarati nei pesi per il model version corrente,
|
||||||
|
* nell'ordine in cui compaiono nella tabella — che è l'ordine di inserimento di `seedPesi`,
|
||||||
|
* cioè quello dichiarato in registro.ts. Non un elenco duplicato qui: si legge dal dato. */
|
||||||
|
function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string[] {
|
||||||
|
return (db.prepare(
|
||||||
|
`SELECT contenitore FROM pesi WHERE model_version = ? AND livello = ?
|
||||||
|
GROUP BY contenitore ORDER BY MIN(rowid)`
|
||||||
|
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
|
||||||
|
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
|
||||||
|
* test sono stati fatti non è ricalcolare come pesano. */
|
||||||
|
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
|
||||||
|
const righe = db.prepare(
|
||||||
|
`SELECT DISTINCT rt.sotto_dominio AS sd
|
||||||
|
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
|
||||||
|
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
|
||||||
|
AND rt.sotto_dominio IS NOT NULL`
|
||||||
|
).all(sessioneId) as { sd: string }[];
|
||||||
|
return new Set(righe.map((r) => r.sd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
|
||||||
|
* che questa sessione non ha, tradotti nell'etichetta leggibile. */
|
||||||
|
function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, presenti: Set<string>): string[] {
|
||||||
|
const pesi = pesiDi(db, MODEL_VERSION, 'asse', nomeAsse);
|
||||||
|
return Object.keys(pesi)
|
||||||
|
.filter((sd) => !presenti.has(sd))
|
||||||
|
.map((sd) => ETICHETTE_SOTTO_DOMINIO[sd] ?? sd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il referto di una sessione: i sette assi del radar, i macro-score e la Fitness Age.
|
||||||
|
* Legge SOLO `leggiScore` per i valori (punteggi congelati): non chiama mai
|
||||||
|
* `calcolaSessione`, quindi un peso cambiato dopo il fatto non altera un referto già
|
||||||
|
* emesso. `mancano` guarda invece le misure grezze della sessione (quali test ci sono),
|
||||||
|
* non il punteggio — è informazione su cosa manca, non un ricalcolo di quanto vale.
|
||||||
|
*/
|
||||||
|
export function refertoDi(
|
||||||
|
db: Database.Database,
|
||||||
|
sessioneId: number
|
||||||
|
): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null; fitnessAgeCopertura: number } {
|
||||||
|
const punteggi = leggiScore(db, sessioneId);
|
||||||
|
const presenti = sottoDominiPresenti(db, sessioneId);
|
||||||
|
|
||||||
|
const assi: AsseVista[] = contenitoriDi(db, 'asse').map((nome) => {
|
||||||
|
const p = punteggi.assi[nome];
|
||||||
|
if (p?.stato === 'ok') return { nome, valore: p.valore, copertura: p.copertura, mancano: [] };
|
||||||
|
return {
|
||||||
|
nome, valore: null, copertura: p?.copertura ?? 0,
|
||||||
|
mancano: mancanoPer(db, sessioneId, nome, presenti),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const macro: MacroVista[] = contenitoriDi(db, 'macro').map((nome) => {
|
||||||
|
const p = punteggi.macro[nome];
|
||||||
|
return p?.stato === 'ok'
|
||||||
|
? { nome, valore: p.valore, copertura: p.copertura }
|
||||||
|
: { nome, valore: null, copertura: p?.copertura ?? 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
|
||||||
|
// Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve
|
||||||
|
// poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca:
|
||||||
|
// la copertura resta leggibile anche quando lo stato è 'insufficiente'.
|
||||||
|
const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0;
|
||||||
|
|
||||||
|
return { assi, macro, fitnessAge, fitnessAgeCopertura };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'ultima sessione di un cliente (la più recente per data, a parità di data la più
|
||||||
|
* recente per id). `null` se il cliente non ha ancora nessuna sessione — la pagina del
|
||||||
|
* referto la usa per non dover interrogare `sessioni` da sola. */
|
||||||
|
export function ultimaSessioneDi(db: Database.Database, clientCode: string): number | null {
|
||||||
|
const r = db.prepare(
|
||||||
|
`SELECT id FROM sessioni WHERE client_code = ? ORDER BY data DESC, id DESC LIMIT 1`
|
||||||
|
).get(clientCode) as { id: number } | undefined;
|
||||||
|
return r?.id ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getLongevityDb } from '../../../lib/longevity/db';
|
||||||
|
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
|
||||||
|
import { salvaDalForm } from '../../../lib/longevity/vista';
|
||||||
|
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (status: number, body: object) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
|
||||||
|
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
|
||||||
|
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
|
||||||
|
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
|
||||||
|
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
|
||||||
|
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
|
||||||
|
*/
|
||||||
|
type CorpoQuestionario = {
|
||||||
|
risposte: Record<string, number>;
|
||||||
|
liberi: Record<string, string>;
|
||||||
|
consensoSanitario: string;
|
||||||
|
eta?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
|
||||||
|
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
|
||||||
|
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
|
||||||
|
* al chiamante, perché il tipo di ritorno non la contiene. */
|
||||||
|
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return null;
|
||||||
|
const grezzo = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const risposteIn = grezzo.risposte;
|
||||||
|
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
|
||||||
|
const risposte: Record<string, number> = {};
|
||||||
|
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
|
||||||
|
const liberiIn = grezzo.liberi;
|
||||||
|
const liberi: Record<string, string> = {};
|
||||||
|
if (typeof liberiIn === 'object' && liberiIn !== null) {
|
||||||
|
for (const campo of CAMPI_LIBERI) {
|
||||||
|
const v = (liberiIn as Record<string, unknown>)[campo];
|
||||||
|
if (typeof v === 'string') liberi[campo] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
|
||||||
|
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
|
||||||
|
|
||||||
|
return { risposte, liberi, consensoSanitario, eta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
|
||||||
|
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
|
||||||
|
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
|
||||||
|
// prenderlo per errore.
|
||||||
|
const codice = codicePerUtenteLoggato(locals.user!.id);
|
||||||
|
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||||
|
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
|
||||||
|
|
||||||
|
const corpo = corpoDaJson(raw);
|
||||||
|
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
|
||||||
|
const { risposte, liberi, consensoSanitario, eta } = corpo;
|
||||||
|
|
||||||
|
// La data della compilazione è quella del server, non quella (manomettibile) del client.
|
||||||
|
const oggi = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
|
||||||
|
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
|
||||||
|
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
|
||||||
|
let esito;
|
||||||
|
try {
|
||||||
|
esito = salvaDalForm(getLongevityDb(), {
|
||||||
|
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Salvataggio questionario fallito:', err);
|
||||||
|
return json(400, {
|
||||||
|
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!esito.ok) return json(422, { error: esito.errore });
|
||||||
|
return json(201, { sessioneId: esito.sessioneId });
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al
|
||||||
|
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath
|
||||||
|
// e canAccessAdminPath in src/lib/auth.ts).
|
||||||
|
export const prerender = false;
|
||||||
|
return Astro.redirect('/longevity/io');
|
||||||
|
---
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
|
||||||
|
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
|
||||||
|
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
|
||||||
|
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
|
||||||
|
import Longevity from '../../layouts/Longevity.astro';
|
||||||
|
import Radar from '../../components/longevity/Radar';
|
||||||
|
import MacroScore from '../../components/longevity/MacroScore.astro';
|
||||||
|
import { getLongevityDb } from '../../lib/longevity/db';
|
||||||
|
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
|
||||||
|
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
|
||||||
|
import { formattaNumero } from '../../lib/longevity/formato';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const user = Astro.locals.user!;
|
||||||
|
const codice = codicePerUtenteLoggato(user.id);
|
||||||
|
const db = getLongevityDb();
|
||||||
|
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
|
||||||
|
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
||||||
|
---
|
||||||
|
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
|
||||||
|
{!codice && (
|
||||||
|
<div class="lg-card">
|
||||||
|
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codice && !referto && (
|
||||||
|
<div class="lg-card lg-card--incompleto">
|
||||||
|
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
|
||||||
|
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{referto && (
|
||||||
|
<div class="lg-referto">
|
||||||
|
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
|
||||||
|
<p class="lg-score__label">Fitness Age</p>
|
||||||
|
<div class="lg-score">
|
||||||
|
<span class="lg-score__value lg-num">
|
||||||
|
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
|
||||||
|
</span>
|
||||||
|
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
|
||||||
|
</div>
|
||||||
|
<p class="lg-muted lg-macro__nota">
|
||||||
|
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
|
||||||
|
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lg-card">
|
||||||
|
<h2>Il tuo radar</h2>
|
||||||
|
<Radar client:load assi={referto.assi} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lg-macro">
|
||||||
|
{referto.macro.map((m) => (
|
||||||
|
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lg-card">
|
||||||
|
<p>
|
||||||
|
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
||||||
|
raccontano come cambi, non solo come stai oggi.
|
||||||
|
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Longevity>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
|
||||||
|
// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
|
||||||
|
// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
|
||||||
|
// /api/longevity/questionario, dove il consenso si verifica davvero.
|
||||||
|
import Longevity from '../../layouts/Longevity.astro';
|
||||||
|
import Questionario from '../../components/longevity/Questionario';
|
||||||
|
import { getLongevityDb } from '../../lib/longevity/db';
|
||||||
|
import { campiDelQuestionario } from '../../lib/longevity/vista';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const oggi = new Date().toISOString().slice(0, 10);
|
||||||
|
const campi = campiDelQuestionario(getLongevityDb(), oggi);
|
||||||
|
---
|
||||||
|
<Longevity title="Questionario" crumbs={['Questionario']}>
|
||||||
|
<div class="lg-head">
|
||||||
|
<h1>Questionario</h1>
|
||||||
|
<p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
|
||||||
|
</div>
|
||||||
|
<Questionario client:load campi={campi} />
|
||||||
|
</Longevity>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* Stile della piattaforma Longevity.
|
||||||
|
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
|
||||||
|
ai componenti Astro sia alle isole React (questionario e radar), che lo scope di Astro
|
||||||
|
non raggiungerebbe. Tutto è prefissato `.lg` e usa i token del sito.
|
||||||
|
|
||||||
|
A differenza di Stress Index qui non c'è semaforo verde/ambra/mattone sui punteggi:
|
||||||
|
il servizio è premium ma non clinico, e un rosso su un punteggio del corpo sarebbe un
|
||||||
|
giudizio, non un'informazione. L'intensità di un punteggio si legge nell'opacità
|
||||||
|
dell'accento del sito (var(--lg-intensita), impostata inline dal componente che disegna
|
||||||
|
il punteggio), mai in un colore diverso. I due soli colori di segnale locali sono:
|
||||||
|
- --lg-tratteggio: il perimetro di ciò che non sappiamo ancora (un asse senza dati
|
||||||
|
sufficienti), non un errore;
|
||||||
|
- --lg-fuori-range: SOLO per una misura sospetta (es. un valore fisiologicamente
|
||||||
|
improbabile), mai per un punteggio — segnala un problema della misura, non della
|
||||||
|
persona. Riusa il mattone di Stress Index (--si-bad: #b05a4e). */
|
||||||
|
|
||||||
|
.lg {
|
||||||
|
--lg-line: #e4dfd7;
|
||||||
|
--lg-surface: #ffffff;
|
||||||
|
--lg-tratteggio: #d8d2c8;
|
||||||
|
--lg-fuori-range: #b05a4e;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--c-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- guscio del referto: una colonna che scorre, senza navigazione --- */
|
||||||
|
.lg-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 760px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--lg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- intestazione --- */
|
||||||
|
.lg-top {
|
||||||
|
padding: 18px 32px;
|
||||||
|
border-bottom: 1px solid var(--lg-line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--lg-surface);
|
||||||
|
}
|
||||||
|
.lg-top p { margin: 0; }
|
||||||
|
.lg-top__crumbs {
|
||||||
|
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em;
|
||||||
|
text-transform: uppercase; color: var(--c-text-light);
|
||||||
|
}
|
||||||
|
.lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
|
||||||
|
|
||||||
|
.lg-main { flex: 1; padding: 34px 32px 70px; }
|
||||||
|
|
||||||
|
/* --- intestazione di pagina --- */
|
||||||
|
.lg-head { margin-bottom: 28px; }
|
||||||
|
.lg-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
|
||||||
|
.lg-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
|
||||||
|
|
||||||
|
/* --- la carta di un punteggio ---
|
||||||
|
Nessun colore «buono» o «cattivo»: l'intensità si legge nell'opacità dell'accento
|
||||||
|
(--lg-intensita, 0..1, impostata inline dal componente in base al punteggio). */
|
||||||
|
.lg-card { background: var(--lg-surface); border: 1px solid var(--lg-line); padding: 20px 22px; }
|
||||||
|
.lg-card--incompleto { border-style: dashed; border-color: var(--lg-tratteggio); }
|
||||||
|
|
||||||
|
.lg-score { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.lg-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
|
||||||
|
.lg-score__max { font-size: .82rem; color: var(--c-text-light); }
|
||||||
|
.lg-score__label {
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||||
|
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||||
|
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
|
||||||
|
|
||||||
|
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
|
||||||
|
|
||||||
|
.lg-muted { color: var(--c-text-light); }
|
||||||
|
|
||||||
|
/* i numeri che si confrontano nel tempo non devono ballare a ogni cifra */
|
||||||
|
.lg-num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* --- il referto: colonna di sezioni --- */
|
||||||
|
.lg-referto { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
|
||||||
|
/* --- il radar dei sette assi: area piena + perimetro tratteggiato ---
|
||||||
|
Il disegno vive nell'isola React (Radar.tsx); qui solo il testo sotto al grafico —
|
||||||
|
l'elenco di ciò che manca, che è la parte che rende il buco un invito. */
|
||||||
|
.lg-radar { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.lg-radar__grafico { width: 100%; }
|
||||||
|
.lg-radar__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
|
||||||
|
.lg-radar__mancano { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.lg-radar__voce { border-top: 1px dashed var(--lg-tratteggio); padding-top: 12px; }
|
||||||
|
.lg-radar__voce-nome {
|
||||||
|
margin: 0 0 6px; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||||
|
color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
|
||||||
|
.lg-radar__lista li { margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* --- le carte dei macro-score --- */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* --- il questionario: un blocco alla volta, avanti/indietro --- */
|
||||||
|
.lg-form { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
.lg-form__passo {
|
||||||
|
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
|
||||||
|
text-transform: uppercase; color: var(--c-text-light); margin: 0;
|
||||||
|
}
|
||||||
|
.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
|
||||||
|
|
||||||
|
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
|
||||||
|
.lg-field > span { color: var(--c-heading); font-weight: 500; }
|
||||||
|
.lg-field input[type="number"],
|
||||||
|
.lg-field textarea {
|
||||||
|
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
|
||||||
|
}
|
||||||
|
.lg-field textarea { min-height: 68px; resize: vertical; }
|
||||||
|
|
||||||
|
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
|
||||||
|
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
||||||
|
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||||
|
|
||||||
|
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
|
.lg-form__nav button {
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
|
||||||
|
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
|
||||||
|
background: transparent; color: var(--c-heading); cursor: pointer;
|
||||||
|
}
|
||||||
|
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
|
.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
|
||||||
|
}
|
||||||
@@ -88,7 +88,36 @@ describe('anagrafica pseudonimizzata', () => {
|
|||||||
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
|
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
|
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
|
||||||
|
// ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
|
||||||
|
// esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
|
||||||
|
// parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
|
||||||
|
// Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
|
||||||
|
// tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
|
||||||
|
// longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
|
||||||
|
// contiene singolarmente le due parole. Un controllo file-per-file non vede una
|
||||||
|
// giunzione che attraversa più file, e non c'è formulazione di questo test che lo
|
||||||
|
// possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
|
||||||
|
// Questo test resta come rete secondaria — cattura il caso comune (una giunzione
|
||||||
|
// dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
|
||||||
|
// esportata per errore — non come prova che la giunzione non possa esistere.
|
||||||
|
// Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
|
||||||
|
// come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
|
||||||
|
// un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
|
||||||
|
// sono nati dopo la prima versione di questo test e sono passati indisturbati).
|
||||||
|
// ⚠️ VERIFICATO IL 22/08, UN TERZO BUCO, STAVOLTA IN UN FILE SOLO: `createIdentityDb`
|
||||||
|
// (a differenza del singleton privato getIdentityDb) È esportata da db.ts — serve a
|
||||||
|
// creaCliente, che riceve due connessioni già aperte da fuori. Un file che chiama
|
||||||
|
// `createIdentityDb()` direttamente e apre longevity con una connessione propria (es.
|
||||||
|
// `new Database(process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')`, mai col nome
|
||||||
|
// `getLongevityDb`/`createLongevityDb`) contiene "Identity" ma MAI "Longevity" con
|
||||||
|
// l'iniziale maiuscola — LONGEVITY_DB_PATH è tutto maiuscolo, 'longevity.db' tutto
|
||||||
|
// minuscolo — e questo test lo lascia passare. Provato con un file del genere in
|
||||||
|
// src/lib/longevity/: zero test rossi. Non è una falla da chiudere con un controllo
|
||||||
|
// più furbo (già tentato e aggirato due volte prima di questa): la garanzia resta solo
|
||||||
|
// quella architetturale sopra, e su `createIdentityDb` non c'è — è pensata per essere
|
||||||
|
// chiamata da fuori anagrafica.ts (creaCliente la riceve come parametro), quindi non si
|
||||||
|
// può chiuderla come si è chiusa getIdentityDb senza cambiare quella funzione.
|
||||||
const srcDir = join(process.cwd(), 'src');
|
const srcDir = join(process.cwd(), 'src');
|
||||||
const colpevoli: string[] = [];
|
const colpevoli: string[] = [];
|
||||||
|
|
||||||
@@ -106,7 +135,7 @@ describe('anagrafica pseudonimizzata', () => {
|
|||||||
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
|
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
|
||||||
|
|
||||||
const src = readFileSync(path, 'utf8');
|
const src = readFileSync(path, 'utf8');
|
||||||
if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) {
|
if (src.includes('Identity') && src.includes('Longevity')) {
|
||||||
colpevoli.push(f);
|
colpevoli.push(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Il consenso veniva scritto DOPO che salvaCompilazione aveva già chiuso la propria
|
||||||
|
// transazione: se il passo successivo falliva, restavano i dati sanitari SENZA il record
|
||||||
|
// di consenso che li giustifica — l'esatto contrario dell'invariante ("o entra tutto nel
|
||||||
|
// fascicolo, o non entra niente"). Questo file forza un fallimento nel calcolo dei
|
||||||
|
// punteggi (mockando calcolaSessione) DOPO che la sessione e le misure sono già scritte, e
|
||||||
|
// verifica che l'intera transazione — sessione, misure, punteggi, consenso — vada
|
||||||
|
// indietro insieme. Il mock vive in un file a parte perché vi.mock è per-file: gli altri
|
||||||
|
// test su calcolaSessione (referto.test.ts, salvataggio-e2e.test.ts) devono restare reali.
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||||
|
|
||||||
|
vi.mock('../../src/lib/longevity/motore', async (importOriginal) => {
|
||||||
|
const reale = await importOriginal<typeof import('../../src/lib/longevity/motore')>();
|
||||||
|
return {
|
||||||
|
...reale,
|
||||||
|
calcolaSessione: () => { throw new Error('rottura simulata nel calcolo dei punteggi'); },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('salvaDalForm — atomicità di dati, punteggi e consenso', () => {
|
||||||
|
it('se il calcolo dei punteggi fallisce DOPO che sessione e misure sono state scritte, tutto torna indietro: niente sessione, niente misure, niente nota di consenso', async () => {
|
||||||
|
const { salvaDalForm } = await import('../../src/lib/longevity/vista');
|
||||||
|
const db = dbPronto();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_riposato: 8 }, consensoSanitario: 'Si', liberi: {},
|
||||||
|
})
|
||||||
|
).toThrow(/rottura simulata/);
|
||||||
|
|
||||||
|
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||||
|
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
|
||||||
|
const score = db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number };
|
||||||
|
const consenso = db.prepare(`SELECT COUNT(*) n FROM profilo_note WHERE campo_id = 'consenso_sanitario'`)
|
||||||
|
.get() as { n: number };
|
||||||
|
|
||||||
|
expect(sessioni.n).toBe(0);
|
||||||
|
expect(misure.n).toBe(0);
|
||||||
|
expect(score.n).toBe(0);
|
||||||
|
expect(consenso.n).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formattaNumero } from '../../src/lib/longevity/formato';
|
||||||
|
|
||||||
|
describe('formattaNumero — i numeri del referto in italiano', () => {
|
||||||
|
it('un decimale usa la virgola, non il punto', () => {
|
||||||
|
expect(formattaNumero(72.5)).toBe('72,5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un intero resta senza decimali spuri', () => {
|
||||||
|
expect(formattaNumero(80)).toBe('80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un negativo (possibile solo per differenze, non per un punteggio 0-100) mantiene il segno', () => {
|
||||||
|
expect(formattaNumero(-3.2)).toBe('-3,2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// tests/longevity/pagine.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||||
|
|
||||||
|
describe('il guscio della piattaforma', () => {
|
||||||
|
it('il layout esiste e non monta header e footer pubblici', () => {
|
||||||
|
const l = leggi('src/layouts/Longevity.astro');
|
||||||
|
expect(l).not.toMatch(/import\s+Header/);
|
||||||
|
expect(l).not.toMatch(/import\s+Footer/);
|
||||||
|
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il foglio di stile e globale e prefissato, non scoped', () => {
|
||||||
|
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
|
||||||
|
const css = leggi('src/styles/longevity.css');
|
||||||
|
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
|
||||||
|
expect(classi.length).toBeGreaterThan(3);
|
||||||
|
// "lg" da solo o "lg-qualcosa": il solo startsWith('lg') lascerebbe passare anche "lgfoo".
|
||||||
|
expect(classi.every((c) => c === 'lg' || c.startsWith('lg-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usa i token del sito invece di inventare colori', () => {
|
||||||
|
const css = leggi('src/styles/longevity.css');
|
||||||
|
expect(css).toMatch(/var\(--c-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessuna variabile porta un nome da semaforo (verde/buono/ok)', () => {
|
||||||
|
const css = leggi('src/styles/longevity.css').toLowerCase();
|
||||||
|
// il mattone del fuori range e ammesso; un verde "tutto bene" no
|
||||||
|
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate', () => {
|
||||||
|
// Il test sopra cerca nomi vietati: chi scrivesse un verde in esadecimale, o lo
|
||||||
|
// chiamasse con un nome innocuo (var(--lg-buona-salute)), passerebbe indisturbato.
|
||||||
|
// Qui si fa il contrario: si elenca ciò che PUÒ comparire, e qualunque altro colore —
|
||||||
|
// letterale (esadecimale, rgb(), nome CSS) o var() con un altro nome — è una
|
||||||
|
// regressione.
|
||||||
|
//
|
||||||
|
// PRIMA questo test filtrava le regole sul selettore ("solo quelle con 'lg-score' nel
|
||||||
|
// nome"): un elenco di QUALI regole guardare invecchia appena nasce una classe nuova —
|
||||||
|
// è successo con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3): non
|
||||||
|
// contengono la stringa "lg-score", quindi non venivano MAI controllate, in silenzio.
|
||||||
|
// Il foglio è interamente prefissato `lg-` per costruzione (verificato dal test sopra):
|
||||||
|
// non serve scegliere quali regole guardare, si guardano TUTTE, e le eccezioni
|
||||||
|
// legittime si dichiarano una per una — selettore, valore esatto, motivo — così un
|
||||||
|
// colore fuori lista entra solo con una decisione visibile, mai con un buco nel filtro.
|
||||||
|
const css = leggi('src/styles/longevity.css');
|
||||||
|
const AMMESSI = new Set([
|
||||||
|
'--c-accent', '--c-accent-dark', // l'accento del sito: l'unica leva per l'intensità
|
||||||
|
'--c-heading', '--c-text', '--c-text-light', // testo neutro, uguale ovunque nel sito
|
||||||
|
'--c-bg', '--c-bg-alt', // sfondi neutri
|
||||||
|
'--lg-line', '--lg-surface', // struttura locale, neutra
|
||||||
|
'--lg-tratteggio', // il perimetro di ciò che non sappiamo
|
||||||
|
'--lg-fuori-range', // SOLO una misura sospetta, mai un punteggio
|
||||||
|
]);
|
||||||
|
// Eccezioni dichiarate: selettore esatto + valore esatto + perché non è un giudizio sul
|
||||||
|
// punteggio. Ogni riga qui è una decisione presa a vista, non un buco silenzioso.
|
||||||
|
const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [
|
||||||
|
{
|
||||||
|
selettore: '.lg-form__nav button:last-child:not(:only-child)',
|
||||||
|
valore: '#fff',
|
||||||
|
motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const NON_COLORE = /^(transparent|inherit|initial|unset|currentcolor|none)$/i;
|
||||||
|
const PROP_COLORE = /^(color|background|background-color|border(-\w+)?-color|outline-color|fill|stroke)$/i;
|
||||||
|
|
||||||
|
const violazioni: string[] = [];
|
||||||
|
for (const regola of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||||
|
const selettore = regola[1].trim();
|
||||||
|
for (const dich of regola[2].split(';')) {
|
||||||
|
const due = dich.indexOf(':');
|
||||||
|
if (due < 0) continue;
|
||||||
|
const prop = dich.slice(0, due).trim().toLowerCase();
|
||||||
|
const val = dich.slice(due + 1).trim();
|
||||||
|
if (!PROP_COLORE.test(prop) || val === '' || NON_COLORE.test(val)) continue;
|
||||||
|
const varMatch = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,.*)?\)$/i);
|
||||||
|
if (varMatch && AMMESSI.has(varMatch[1])) continue;
|
||||||
|
if (ECCEZIONI.some((e) => e.selettore === selettore && e.valore === val)) continue;
|
||||||
|
violazioni.push(`${selettore} { ${prop}: ${val} }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(violazioni).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la rotta del cliente e sotto /longevity, e rimanda davvero al referto', () => {
|
||||||
|
const p = join(process.cwd(), 'src/pages/longevity/index.astro');
|
||||||
|
expect(existsSync(p)).toBe(true);
|
||||||
|
const src = readFileSync(p, 'utf8');
|
||||||
|
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// La prima apertura del referto (revisione del 22/08): con i dati di oggi il cliente vede
|
||||||
|
// molti assi tratteggiati. È corretto, ma la pagina deve SPIEGARLO, non lasciarlo sembrare
|
||||||
|
// un guasto. Questi controlli leggono il sorgente delle pagine/componenti — come già fa
|
||||||
|
// referto.test.ts per "nessuna pagina interroga il database da sola" — perché non c'è un
|
||||||
|
// harness di rendering per Astro/React in questo repo (nessuna dipendenza nuova ammessa).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||||
|
|
||||||
|
describe('la Fitness Age, quando manca, spiega perché (come le altre carte)', () => {
|
||||||
|
it('io.astro mostra una nota di copertura anche per la Fitness Age, non solo un trattino nudo', () => {
|
||||||
|
const src = leggi('src/pages/longevity/io.astro');
|
||||||
|
// Deve leggere la copertura dedicata (vista.ts, fitnessAgeCopertura), non inventarsi
|
||||||
|
// un numero: la sola presenza del trattino '—' non basta più.
|
||||||
|
expect(src).toMatch(/fitnessAgeCopertura/);
|
||||||
|
expect(src).toMatch(/Copertura dati/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il numero della Fitness Age, quando c\'è, è formattato all\'italiana', () => {
|
||||||
|
const src = leggi('src/pages/longevity/io.astro');
|
||||||
|
expect(src).toMatch(/formattaNumero\(referto\.fitnessAge\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('il radar spiega prima di mostrare', () => {
|
||||||
|
it('la spiegazione del radar precede il grafico, non lo segue', () => {
|
||||||
|
const src = leggi('src/components/longevity/Radar.tsx');
|
||||||
|
const idxIntro = src.indexOf("L'area piena");
|
||||||
|
const idxGrafico = src.indexOf('<ResponsiveContainer');
|
||||||
|
expect(idxIntro).toBeGreaterThan(-1);
|
||||||
|
expect(idxGrafico).toBeGreaterThan(-1);
|
||||||
|
expect(idxIntro).toBeLessThan(idxGrafico);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("l'elenco di ciò che manca invita a fare qualcosa, non solo a leggere nomi di test", () => {
|
||||||
|
const src = leggi('src/components/longevity/Radar.tsx');
|
||||||
|
expect(src).toMatch(/trainer/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('la copertura si mostra sempre, non solo quando il numero manca', () => {
|
||||||
|
it('MacroScore.astro non nasconde più la copertura quando il punteggio c\'è', () => {
|
||||||
|
const src = leggi('src/components/longevity/MacroScore.astro');
|
||||||
|
// PRIMA: `{valore === null && (<p ...>Copertura dati...`. La nota deve poter comparire
|
||||||
|
// anche quando valore !== null: la vecchia guardia esatta non deve più esserci.
|
||||||
|
expect(src).not.toMatch(/\{valore === null && \(\s*<p class="lg-muted lg-macro__nota">/);
|
||||||
|
expect(src).toMatch(/Copertura dati/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il punteggio di MacroScore è formattato all\'italiana', () => {
|
||||||
|
const src = leggi('src/components/longevity/MacroScore.astro');
|
||||||
|
expect(src).toMatch(/formattaNumero\(valore\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chi ha già compilato può ricompilare (monitoraggio nel tempo)', () => {
|
||||||
|
it('io.astro offre il collegamento al questionario anche quando il referto esiste già', () => {
|
||||||
|
const src = leggi('src/pages/longevity/io.astro');
|
||||||
|
const idxReferto = src.indexOf('{referto && (');
|
||||||
|
expect(idxReferto).toBeGreaterThan(-1);
|
||||||
|
const blocco = src.slice(idxReferto);
|
||||||
|
expect(blocco).toMatch(/href=["'`]\/longevity\/questionario["'`]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||||
|
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
|
||||||
|
import { creaCliente } from '../../src/lib/longevity/anagrafica';
|
||||||
|
import { CONSENT_VERSION, QUEST_VERSION } from '../../src/lib/longevity/questionario';
|
||||||
|
|
||||||
|
function dbPronto() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('i campi del questionario vengono dal registro', () => {
|
||||||
|
it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||||
|
expect(campi.length).toBe(20);
|
||||||
|
const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
|
||||||
|
expect(alcol.etichetta).toMatch(/alcoliche/i);
|
||||||
|
expect(alcol.max).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
|
||||||
|
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||||
|
expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
|
||||||
|
expect(campi.length).toBe(19);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('il salvataggio, e il consenso', () => {
|
||||||
|
const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
|
||||||
|
|
||||||
|
it('salva una compilazione quando il consenso e dato', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||||
|
expect(n.n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'No', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||||
|
expect(n.n).toBe(0); // niente sessione, niente misure
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NON salva se il consenso manca del tutto', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: '', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'No', liberi: {},
|
||||||
|
});
|
||||||
|
const note = db.prepare(
|
||||||
|
`SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
|
||||||
|
).all() as { campo_id: string; testo: string }[];
|
||||||
|
expect(note.length).toBeGreaterThan(0);
|
||||||
|
expect(note[0].testo).toMatch(/No/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PRIMA di questa correzione il ramo del consenso DATO non era coperto da nessun test:
|
||||||
|
// cancellando la riga che registra il consenso accettato (registraConsenso dentro il ramo
|
||||||
|
// affermativo di salvaDalForm) la suite restava tutta verde — provato. Il consenso dato è
|
||||||
|
// l'unico documento che un domani si esibisce a chi chiede conto del trattamento, tanto
|
||||||
|
// quanto il rifiuto (già coperto sopra).
|
||||||
|
it('registra il consenso anche quando e dato: e il documento che un domani va esibito', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
const note = db.prepare(
|
||||||
|
`SELECT campo_id, testo, sessione_id FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
|
||||||
|
).all() as { campo_id: string; testo: string; sessione_id: number | null }[];
|
||||||
|
expect(note.length).toBe(1);
|
||||||
|
expect(note[0].testo).toMatch(/Si/);
|
||||||
|
if (esito.ok) expect(note[0].sessione_id).toBe(esito.sessioneId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il record del consenso portava la versione delle DOMANDE (QUEST_VERSION): se cambia il
|
||||||
|
// testo del consenso senza che cambi una domanda, il record non lo diceva. Ora porta una
|
||||||
|
// versione propria (CONSENT_VERSION, questionario.ts), dichiarata accanto a QUEST_VERSION.
|
||||||
|
it('il record del consenso porta CONSENT_VERSION, non QUEST_VERSION', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte, consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
const nota = db.prepare(
|
||||||
|
`SELECT testo FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
|
||||||
|
).get() as { testo: string };
|
||||||
|
expect(nota.testo).toContain(`consenso ${CONSENT_VERSION}`);
|
||||||
|
expect(nota.testo).not.toMatch(/questionario\s+v/i);
|
||||||
|
// Le due costanti oggi coincidono di valore ('v1.0'): la prova che conta è che sono
|
||||||
|
// DUE COSTANTI DISTINTE (non lo stesso import rietichettato) — non che i valori
|
||||||
|
// divergano oggi.
|
||||||
|
expect(CONSENT_VERSION).not.toBe(undefined);
|
||||||
|
expect(QUEST_VERSION).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il codice cliente si risolve dalla sessione (codicePerUtenteLoggato)', () => {
|
||||||
|
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
|
||||||
|
expect(src).toMatch(/codicePerUtente/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PRIMA questo test cercava, nel sorgente, le stringhe letterali "body.client_code" e
|
||||||
|
// "corpo.client_code" — ma il file chiama la variabile del corpo `data`, non `body` né
|
||||||
|
// `corpo`: la lista di divieti non nominava nessuna variabile che il file usasse
|
||||||
|
// davvero, quindi era vacua per costruzione. Provato: aggiungendo all'endpoint
|
||||||
|
// `if (typeof data.client_code === 'string') codice = data.client_code;` (la vulnerabilità
|
||||||
|
// vera — un cliente scrive nel fascicolo di un altro) la suite restava tutta verde.
|
||||||
|
//
|
||||||
|
// Il fix e' strutturale (il tipo del corpo, in questionario.ts, non ha il campo
|
||||||
|
// client_code — chi lo leggesse da li' non passerebbe tsc --noEmit) e questo e' il test
|
||||||
|
// di comportamento che lo dimostra end-to-end: chiama l'endpoint VERO con un client_code
|
||||||
|
// estraneo nel corpo e verifica dove la sessione finisce scritta.
|
||||||
|
it('un client_code nel corpo della richiesta viene ignorato: la sessione finisce sul cliente vero, mai su quello iniettato', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'lg-api-test-'));
|
||||||
|
const idPath = join(dir, 'identity.db');
|
||||||
|
const lgPath = join(dir, 'longevity.db');
|
||||||
|
const primaId = process.env.IDENTITY_DB_PATH;
|
||||||
|
const primaLg = process.env.LONGEVITY_DB_PATH;
|
||||||
|
try {
|
||||||
|
process.env.IDENTITY_DB_PATH = idPath;
|
||||||
|
process.env.LONGEVITY_DB_PATH = lgPath;
|
||||||
|
|
||||||
|
// Setup con connessioni proprie, sullo stesso file che il singleton dell'endpoint
|
||||||
|
// aprirà (le due variabili d'ambiente sopra), come in anagrafica.test.ts.
|
||||||
|
const id = createIdentityDb(idPath);
|
||||||
|
const lg = createLongevityDb(lgPath);
|
||||||
|
seedRegistro(lg);
|
||||||
|
const codiceVero = creaCliente(id, lg, { nome: 'Vera', cognome: 'Utente', sesso: 'F', user_id: 1 });
|
||||||
|
const codiceAltro = creaCliente(id, lg, { nome: 'Altro', cognome: 'Cliente', sesso: 'M', user_id: 2 });
|
||||||
|
|
||||||
|
const { POST } = await import('../../src/pages/api/longevity/questionario');
|
||||||
|
const richiesta = new Request('http://localhost/api/longevity/questionario', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_code: codiceAltro, // tentativo: scrivere nel fascicolo di un altro cliente
|
||||||
|
risposte: { q_riposato: 8 },
|
||||||
|
consensoSanitario: 'Si',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST({ request: richiesta, locals: { user: { id: 1 } } } as any);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const corpo = await res.json();
|
||||||
|
|
||||||
|
const riga = lg.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(corpo.sessioneId) as
|
||||||
|
{ client_code: string };
|
||||||
|
expect(riga.client_code).toBe(codiceVero);
|
||||||
|
expect(riga.client_code).not.toBe(codiceAltro);
|
||||||
|
} finally {
|
||||||
|
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
|
||||||
|
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// tests/longevity/referto.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||||
|
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||||
|
import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
|
||||||
|
import { refertoDi } from '../../src/lib/longevity/vista';
|
||||||
|
|
||||||
|
function conCompilazione() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db); seedPesi(db, MODEL_VERSION);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
const s = salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
|
||||||
|
});
|
||||||
|
salvaScore(db, s, calcolaSessione(db, s));
|
||||||
|
return { db, s };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Come conCompilazione, con IN PIU' una risposta di questionario_sonno (q_riposato).
|
||||||
|
* Serve solo alla prova sul congelamento, che ha bisogno di un asse con PIU' sotto-domini
|
||||||
|
* pesati e copertura parziale non nulla — vedi il commento sopra la mutazione qui sotto per
|
||||||
|
* il perche' conCompilazione da sola non basta.
|
||||||
|
*/
|
||||||
|
function conCoperturaParziale() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db); seedPesi(db, MODEL_VERSION);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
const s = salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: {
|
||||||
|
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
|
||||||
|
q_riposato: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
salvaScore(db, s, calcolaSessione(db, s));
|
||||||
|
return { db, s };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('il referto del cliente', () => {
|
||||||
|
it('restituisce i sette assi', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
expect(refertoDi(db, s).assi.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un asse senza dati NON porta un numero', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||||
|
expect(forza.valore).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||||
|
expect(forza.mancano.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('espone la copertura della Fitness Age anche quando è insufficiente (serve alla nota in pagina)', () => {
|
||||||
|
// conCompilazione() non porta nessuna misura del composito Fitness Age (cardio,
|
||||||
|
// handgrip, hrv, forza_resto, composizione, stabilita: nessuno di questi sotto-domini
|
||||||
|
// ha dati in questa sessione, solo il questionario), quindi fitnessAge è null — ma
|
||||||
|
// PRIMA di questa correzione refertoDi scartava anche la copertura insieme al valore:
|
||||||
|
// la pagina non aveva modo di dire "41%", solo di dire "manca".
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const referto = refertoDi(db, s);
|
||||||
|
expect(referto.fitnessAge).toBeNull();
|
||||||
|
expect(referto.fitnessAgeCopertura).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un asse coperto porta il suo numero', () => {
|
||||||
|
const { db, s } = conCompilazione();
|
||||||
|
const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
|
||||||
|
expect(stile.valore).not.toBeNull();
|
||||||
|
expect(stile.mancano).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legge i punteggi congelati, non li ricalcola al volo', () => {
|
||||||
|
const { db, s } = conCoperturaParziale();
|
||||||
|
const prima = JSON.stringify(refertoDi(db, s));
|
||||||
|
// ATTENZIONE alla scelta della mutazione: non una qualunque, una che DISCRIMINA
|
||||||
|
// davvero un ricalcolo da una lettura congelata.
|
||||||
|
//
|
||||||
|
// 'Recupero & Sistema Nervoso' pesa QUATTRO sotto-domini (hrv 0.50, pressione 0.15,
|
||||||
|
// hrr 0.15, questionario_sonno 0.20); questa sessione ne misura UNO solo
|
||||||
|
// (questionario_sonno, via q_riposato in conCoperturaParziale) — copertura vera
|
||||||
|
// 0.20/1.00=0.20, sotto COPERTURA_MINIMA (0.4): l'asse e' 'insufficiente', ma con
|
||||||
|
// disponibili.length===1, non 0 — la formula del peso viene davvero eseguita, non
|
||||||
|
// bypassata dalla scorciatoia "nessun dato disponibile" di aggrega() in cascata.ts.
|
||||||
|
// Azzerando il peso di 'hrv' (un sotto-dominio ASSENTE) il totale scende a 0.50 e la
|
||||||
|
// copertura vera diventa 0.20/0.50=0.40: attraversa la soglia, l'asse passerebbe da
|
||||||
|
// insufficiente a ok SE il referto ricalcolasse (provato: vedi task-3-report.md).
|
||||||
|
//
|
||||||
|
// Una mutazione come questa NON discrimina, ed e' un errore da non ripetere:
|
||||||
|
// il peso dell'UNICO sotto-dominio di un asse a un solo elemento (es.
|
||||||
|
// 'questionario_lifestyle', 1.00 in 'Stile di Vita & Sonno') si semplifica sempre,
|
||||||
|
// sia al numeratore sia al denominatore della media pesata — cambiarlo non altera
|
||||||
|
// ne' il valore ne' la copertura, quindi il test resterebbe verde anche se
|
||||||
|
// refertoDi ricalcolasse davvero (era il bug di questo stesso test, corretto qui).
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE pesi SET peso = 0 WHERE model_version = ? AND livello='asse' AND contenitore='Recupero & Sistema Nervoso' AND elemento='hrv'`
|
||||||
|
).run(MODEL_VERSION);
|
||||||
|
expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessuna pagina interroga il database da sola', () => {
|
||||||
|
// Scandisce TUTTE le pagine sotto src/pages/longevity/, non un elenco scritto qui:
|
||||||
|
// un elenco di due nomi non protegge una pagina nuova, che passerebbe indisturbata
|
||||||
|
// anche con una query dentro (verificato: una prova-query.astro con un
|
||||||
|
// db.prepare('SELECT 1') non veniva vista dalla versione precedente di questo test).
|
||||||
|
const dir = join(process.cwd(), 'src/pages/longevity');
|
||||||
|
const pagine = (readdirSync(dir, { recursive: true }) as string[])
|
||||||
|
.filter((f) => f.endsWith('.astro'));
|
||||||
|
expect(pagine.length).toBeGreaterThan(0); // se la cartella si svuota, il test non deve tacere per errore
|
||||||
|
for (const f of pagine) {
|
||||||
|
const percorso = `src/pages/longevity/${f}`;
|
||||||
|
const src = readFileSync(join(dir, f), 'utf8');
|
||||||
|
expect(src, `${percorso} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Il blocco scoperto nella revisione finale del 22/08: il salvataggio del questionario e la
|
||||||
|
// lettura dei punteggi esistono entrambi, ma fra i due non calcolava niente nessuno.
|
||||||
|
// I test esistenti (referto.test.ts) chiamavano calcolaSessione/salvaScore dentro la propria
|
||||||
|
// fixture — cioè facevano a mano ciò che in produzione non fa nessuno — quindi restavano
|
||||||
|
// verdi anche con l'endpoint reale del tutto scollegato dal motore.
|
||||||
|
//
|
||||||
|
// Questo file parte dal modulo compilato (salvaDalForm, la stessa funzione che chiama
|
||||||
|
// l'endpoint POST /api/longevity/questionario) e arriva al referto (refertoDi): NON chiama
|
||||||
|
// mai calcolaSessione o salvaScore a mano. Se un domani qualcuno stacca di nuovo il
|
||||||
|
// calcolo dal salvataggio, questo è il test che deve diventare rosso.
|
||||||
|
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, refertoDi } from '../../src/lib/longevity/vista';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('salvaDalForm calcola e congela i punteggi da solo', () => {
|
||||||
|
it('venti risposte compilate producono un referto con assi e macro-score valorizzati, senza ricalcolo a mano', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
|
||||||
|
const esito = salvaDalForm(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
consensoSanitario: 'Si', liberi: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
if (!esito.ok) throw new Error(esito.errore);
|
||||||
|
|
||||||
|
// Riprova diretta sulla tabella score: prima di questa correzione restava vuota,
|
||||||
|
// perché nessuno chiamava calcolaSessione/salvaScore fra salvataggio e lettura.
|
||||||
|
const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as
|
||||||
|
{ n: number };
|
||||||
|
expect(righeScore.n).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const referto = refertoDi(db, esito.sessioneId);
|
||||||
|
// 'Stile di Vita & Sonno' ha UN solo sotto-dominio (questionario_lifestyle, peso 1.00):
|
||||||
|
// con tutte le risposte compilate la copertura è piena.
|
||||||
|
const stile = referto.assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
|
||||||
|
expect(stile.valore).not.toBeNull();
|
||||||
|
// 'Energia & Regolazione Stress' pesa hrv 0.50 + questionario_energia_stress 0.50: con
|
||||||
|
// solo il questionario la copertura vera è 0.50, sopra la soglia del 40%.
|
||||||
|
const energia = referto.assi.find((a) => a.nome === 'Energia & Regolazione Stress')!;
|
||||||
|
expect(energia.valore).not.toBeNull();
|
||||||
|
const almenoUnMacro = referto.macro.some((m) => m.valore !== null);
|
||||||
|
expect(almenoUnMacro).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user