# Review package: 779aef3..HEAD ## Commits a56398e longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score 6630be7 longevity: la prova sul congelamento ora discrimina davvero il ricalcolo 7f01ee0 longevity: il referto del cliente, e il radar che dice cosa non sa 8c98075 longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts f0629db longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale f5ca841 longevity: il questionario in pagina, e il consenso che si puo negare davvero 2ea822a longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto 34c66ca longevity: il guscio della piattaforma, senza semaforo sui punteggi ## Files changed src/components/longevity/MacroScore.astro | 37 +++++ src/components/longevity/Questionario.tsx | 192 +++++++++++++++++++++++ src/components/longevity/Radar.tsx | 86 +++++++++++ src/layouts/Longevity.astro | 47 ++++++ src/lib/longevity/anagrafica.ts | 23 +++ src/lib/longevity/db.ts | 14 ++ src/lib/longevity/vista.ts | 227 ++++++++++++++++++++++++++++ src/pages/api/longevity/questionario.ts | 66 ++++++++ src/pages/longevity/index.astro | 7 + src/pages/longevity/io.astro | 57 +++++++ src/pages/longevity/questionario.astro | 22 +++ src/styles/longevity.css | 141 +++++++++++++++++ tests/longevity/anagrafica.test.ts | 20 ++- tests/longevity/pagine.test.ts | 96 ++++++++++++ tests/longevity/questionario-pagina.test.ts | 87 +++++++++++ tests/longevity/referto.test.ts | 103 +++++++++++++ 16 files changed, 1223 insertions(+), 2 deletions(-) ## Diff diff --git a/src/components/longevity/MacroScore.astro b/src/components/longevity/MacroScore.astro new file mode 100644 index 0000000..2b220e3 --- /dev/null +++ b/src/components/longevity/MacroScore.astro @@ -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. +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; +--- +
+

{etichetta}

+
+ {valore !== null ? valore : '—'} + / 100 +
+
+ +
+ {valore === null && ( +

+ Copertura dati: {Math.round(copertura * 100)}% — non ancora sufficiente per un numero. +

+ )} +
diff --git a/src/components/longevity/Questionario.tsx b/src/components/longevity/Questionario.tsx new file mode 100644 index 0000000..5f8ae1e --- /dev/null +++ b/src/components/longevity/Questionario.tsx @@ -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>({}); + const [liberi, setLiberi] = useState>({}); + const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>(''); + const [eta, setEta] = useState(''); + const [inviando, setInviando] = useState(false); + const [esito, setEsito] = useState(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 = {}; + 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 ( +
+

Questionario salvato, grazie.

+
+ ); + } + + return ( +
+

Passo {passo + 1} di {totalePassi}

+ + {passo < gruppi.length && ( +
+

{gruppi[passo].titolo}

+ {gruppi[passo].campi.map((c) => ( + + ))} +
+ )} + + {ultimo && ( +
+

Note e consenso

+ + {CAMPI_LIBERI.map((c) => ( +