Compare commits

..

73 Commits

Author SHA1 Message Date
Adriano 0e9b081a79 spec: cosa ha scoperto la costruzione del motore 2026-08-22 11:02:26 +02:00
Adriano 42a039ae6b schema: il limite del vincolo e' la migrazione che non c'e', non SQLite 2026-08-22 11:01:45 +02:00
Adriano 82a4cd2000 test: chiude l'errore di tipi pre-esistente in middleware.test.ts
Cast di FakeContext a onRequest via 'unknown' invece che diretto: i
due tipi non si sovrappongono abbastanza per tsc (FakeContext è un
doppio minimale, non un APIContext completo). Il typecheck è il
meccanismo di sicurezza di questo motore: uno rosso in permanenza è un
cancello che nessuno guarda più.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:42 +02:00
Adriano c1c868622f test: le sei tabelle dei sollevamenti sorvegliate, e il caso misto
- motore-fisici.test.ts confrontava i tiers letti dal file di
  riferimento, non le costanti TIERS_* del codice: ventiquattro numeri
  senza custode. Ora identifica la tabella dagli argomenti
  dell'oracolo ma usa le costanti vere per calcolare, quindi una
  costante alterata non combacia più con nessun caso.
- motore-caso-reale.test.ts: aggiunto il caso end-to-end mancante, un
  asse valido (Composizione Corporea, via due righe aggiunte al
  registro) e sei insufficienti — oggi la suite copriva solo "tutto
  insufficiente".
- test-fisici.ts: commento in testa che dichiara le venti curve non
  collegate al motore (vocabolario delle curve senza nomi per loro,
  applicaCurva senza modo di ricevere età/sesso) — aggancio è lavoro
  del piano degli import, non "una modifica ai dati" come la spec
  lascia intendere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:35 +02:00
Adriano 469251366e motore: chiude i tre buchi laterali della barriera dell'insufficiente
- eta_alla_data NULL non usa più il ripiego a zero: senza età la
  Fitness Age è null, non un'età di forma negativa dichiarata valida.
  Gli assi e i macro-score restano calcolabili (non dipendono dall'età).
- applicaCurva valida i parametri richiesti da ciascuna curva prima di
  applicarla: un registro con params vuoti/incompleti dà null, mai il
  punteggio pieno che dava lerp con estremi indefiniti.
- calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non
  entrano nello score finché non esiste la colonna di conferma (§12,
  punto aperto).
- leggiScore(db, sessioneId): legge la tabella score ritipata come
  Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella
  query (MAX(id)), non l'ordine naturale delle righe — salvaScore non
  sovrascrive di proposito (la storia degli score si tiene, discende
  dal congelamento della §6).
- documentata la seconda divergenza dall'oracolo, mai scritta finora:
  la Fitness Age esclude gli assi insufficienti, l'oracolo li include
  comunque. Comportamento giusto, ma cambia il numero (25,0 contro
  34,8 sullo stesso profilo parziale).
- nuovo test che prova che i pesi di due model_version diverse restano
  separati (pesiDi filtra su model_version, non li fonde).

Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a
mano per confermare che discrimina davvero (vedi report).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:25 +02:00
Adriano c4524e3a73 schema: la tabella score rifiuta un valore sotto uno stato che lo nega
CHECK ((stato='ok' AND valore NOT NULL) OR (stato='insufficiente' AND
valore NULL)): il tipo Punteggio protegge il codice, non la tabella, e
l'interfaccia leggerà 'score' direttamente. Idempotente sui database
nuovi (CREATE TABLE IF NOT EXISTS); un longevity.db già esistente NON
riceve il vincolo, SQLite non lo permette via ALTER TABLE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:12 +02:00
Adriano d22f88ad71 longevity: la prova sul profilo con una sola area misurata
Task 6 - la prova sul caso reale documentato dal cliente (dati sintetici,
nessuna persona reale). Il test smontava un difetto nel task 5: RisultatoSessione
esponeva fitnessAge come Punteggio invece che number|null (come lo produce
calcolaFitnessAge in cascata.ts), quindi un fitnessAge insufficiente non era mai
`null`. Corretto in motore/index.ts, con fitnessAgeCopertura a fianco per non
perdere l'informazione che salvaScore scrive in `score`.
2026-08-22 10:07:28 +02:00
Adriano ac8b377f84 longevity test: motivazione corretta per il caso 'test disattivato', e media verificata numericamente
Il commento del test 'un test disattivato non entra nel calcolo' diceva
che q_attivita fosse l'unica voce del sotto-dominio questionario_lifestyle:
falso, ne ha sei (q_attivita, q_alimentazione, q_sigarette, q_alcol_life,
q_luce, q_schermi). Il motivo vero per cui il test funziona e' che il
fixture ne compila solo una delle sei, quindi disattivarla svuota il
sotto-dominio. Corretto il commento a dire il motivo vero, con nota su
cosa succederebbe se il fixture cambiasse.

Aggiunto anche il test mancante sulla regola della media: q_attivita(3)=65
e q_alimentazione(6)=60, stesso sotto-dominio, verificano che il valore
dell'asse "Stile di Vita & Sonno" sia la loro media (62.5), diversa sia
dalla somma (125) sia da ciascun addendo (65, 60) — nessun test esistente
controllava il VALORE della media, solo lo stato.
2026-08-22 10:02:59 +02:00
Adriano f238984828 longevity: il motore legge curve e pesi dal registro, e congela i punteggi
Task 5: applicaCurva smista sulla curva dichiarata in registro_test (non
scritta nel codice); calcolaSessione aggrega le misure attive per
sotto-dominio (media quando piu' test contribuiscono allo stesso
sotto-dominio) e usa cascata.ts coi pesi letti da pesi (pesiDi) per i
sette assi, i tre macro-score e la Fitness Age; salvaScore congela il
risultato in score, con valore a null quando lo stato e' insufficiente.

Corretto anche un refuso nel test del brief ('un test disattivato non
entra nel calcolo'): disattivava q_ore_sonno, ma quella voce sta nel
sotto-dominio questionario_sonno (peso 0.20 su Recupero & Sistema
Nervoso, sempre sotto COPERTURA_MINIMA=0.4 finche' hrv/pressione/hrr non
hanno una voce nel registro), quindi l'asse restava insufficiente prima
e dopo e il JSON risultava identico (verificato: il test falliva con le
due stringhe uguali). Corretto usando q_attivita, l'unica voce del
sotto-dominio questionario_lifestyle che pesa 1.00 su Stile di Vita &
Sonno: disattivarla fa cadere la copertura da 1.00 a 0 e lo stato passa
da ok a insufficiente, una differenza vera.
2026-08-22 09:53:17 +02:00
Adriano 93ecc3d367 longevity: la copertura si confronta grezza, non arrotondata
Bug in aggrega(): la soglia veniva confrontata con la copertura gia'
arrotondata a due decimali, quindi 0.396 (< 0.40) passava per 'ok' perche'
arrotondava a 0.40. Confronto ora sulla copertura grezza; l'arrotondamento
resta solo sul valore esposto. Generalizzata arrotonda1 in curve.ts a un
numero qualsiasi di decimali (bit-exact, banker's rounding) e riusata qui a
due decimali, invece di tenere una seconda logica di arrotondamento nel
motore.
2026-08-22 09:39:58 +02:00
Adriano 98a633b3e1 longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente 2026-08-22 09:28:40 +02:00
Adriano f029820a14 longevity: il test delle funzioni escluse copre anche score_generic_range_local
Il test controllava solo l'assenza dell'agilita (scoreAgilita/scoreAgilityMs)
e non della seconda funzione esclusa dal brief, score_generic_range_local:
se qualcuno la reintroducesse un domani nessun test se ne accorgerebbe.
Aggiunta l'assertion con lo stesso criterio, rinominato il test perche' ora
copre entrambe le esclusioni.
2026-08-22 09:24:42 +02:00
Adriano 4d76030839 longevity: unico arrotondamento al pari nel motore, esatto sul valore binario
Sposta arrotonda1 (round-half-to-even come Python) da test-fisici.ts a
curve.ts, esportata, cosi' entrambi i moduli condividono la stessa funzione
invece di due copie che sui pareggi potrebbero divergere.

Nel farlo, la versione precedente (moltiplica per 10, tolleranza 1e-9 sul
pareggio) si e' rivelata sbagliata su un caso reale: 86.35 non e' un
pareggio nel double che lo rappresenta (vale 86.34999999999999431...,
Python arrotonda a 86.3 senza ambiguita'), ma 86.35*10 arrotonda ESATTAMENTE
a 863.5 in virgola mobile, un pareggio fasullo creato dalla moltiplicazione.
La versione naive dava 86.4. Sostituita con un confronto esatto: scompone il
double in mantissa/esponente (bit IEEE 754) e confronta con aritmetica
razionale su BigInt, senza mai passare per una moltiplicazione che possa
introdurre o cancellare un pareggio.

Aggiunti in motore-curve.test.ts i casi che dimostrano il pareggio vero
(86.25 -> 86.2, 86.75 -> 86.8) e quello fasullo (86.35 -> 86.3, non 86.4:
blocca la regressione appena descritta).
2026-08-22 09:16:31 +02:00
Adriano 6f926e3197 longevity: le curve dei test fisici, verificate contro l oracolo 2026-08-22 09:09:23 +02:00
Adriano c63bb1fe80 longevity: chiude i tre buchi nei test delle curve — ramo crescente di curvaGradini mai esercitato, test zeroVal non discriminante, cardinalita mancante su due confronti con l oracolo 2026-08-22 09:02:20 +02:00
Adriano 36a2cf3fb6 longevity: le cinque curve del questionario, verificate contro l oracolo 2026-08-22 08:51:27 +02:00
Adriano 11ecc8305b piano: il codice sta nel repo, non anche nel piano 2026-08-22 08:48:26 +02:00
Adriano 236cdce076 longevity: rigenera riferimento.json con la griglia corretta (plateau offset, 2km walk multi-punto, confini plank) 2026-08-22 08:42:29 +02:00
Adriano c5249255f0 piano: rimette i commenti della griglia al posto giusto 2026-08-22 08:39:06 +02:00
Adriano 998487ed62 piano: infittita la griglia dove non aveva capacita diagnostica 2026-08-22 08:38:54 +02:00
Adriano 7cb49c6a49 longevity: l'oracolo del motore e la griglia di riferimento 2026-08-22 08:29:53 +02:00
Adriano bc9d31e193 docs: piano del motore di calcolo, verificato contro l'oracolo del cliente 2026-08-22 08:27:51 +02:00
Adriano 59b453c67e longevity: annotate le conferme di Nicola del 22/08 (plank su due assi, soglia unica al 40%) 2026-08-22 08:02:20 +02:00
Adriano 52ff28b69d spec: i sei punti emersi costruendo lo strato dati, piu' la curva x10_inv che mancava 2026-08-21 20:34:49 +02:00
Adriano 763e5611e6 longevity: revisione finale - codice cliente non riusabile, ruoli allineati, middleware verificato
- creaCliente: il codice si deduce dal massimo fra identity E longevity (non piu' solo
  identity), cosi' un client_code cancellato dall'anagrafica non torna mai disponibile
  e non si attribuiscono le misure di un vecchio cliente a uno nuovo. Compensazione se
  la scrittura su longevity fallisce dopo quella su identity.
- ROLES unica fonte in auth.ts (Role e isRole derivati); users.astro e users/new.astro
  usano quella lista invece di array scritti a mano che dimenticavano cliente/trainer.
- Commento falso su cosa protegge il middleware, riscritto: rimanda a isProtectedPath.
- vitest.config.ts: alias per astro:middleware (stesso bersaglio della pipeline vite di
  Astro), cosi' src/middleware.ts e' finalmente importabile e testabile in isolamento -
  prima nessun test lo caricava davvero.
- export.test.ts: asserzione posizionale per colonna, non piu' solo intestazioni scritte
  a mano; intercetta un riordino della SELECT che disallinea etichette e valori.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:30:23 +02:00
Adriano e6c2ac9148 longevity: test atomicità corretto - verifica rollback anche dei campi liberi 2026-08-21 20:08:12 +02:00
Adriano 2024c32965 longevity: export pseudonimizzato per costruzione 2026-08-21 20:05:19 +02:00
Adriano 91e6487da3 longevity: atomicità compilazione questionario con transazione unica 2026-08-21 20:02:23 +02:00
Adriano f21b64fcc5 longevity: salvataggio compilazione questionario, versionata 2026-08-21 19:57:35 +02:00
Adriano d3abbf9bf7 task-5 fix: isRole, login redirect, create-user script per cliente e trainer 2026-08-21 19:53:12 +02:00
Adriano 473c0d8c36 fix: isProtectedPath nel middleware, landingFor per cliente e trainer 2026-08-21 19:44:08 +02:00
Adriano 502ccb1585 longevity: ruoli cliente e trainer con le rispettive rotte 2026-08-21 19:41:45 +02:00
Adriano b7ceece4ed test(anagrafica): allarga la scansione a tutto src/ per rilevare giunzioni non autorizzate
Rafforza il test che verifica l'isolamento tra identity e longevity.
Invece di scandire solo src/lib/longevity/, ora scandisce ricorsivamente
tutto src/ inclusi .astro e .ts, saltando node_modules e dist.

Preserva le due sole esclusioni autorizzate: anagrafica.ts (il modulo
autorizzato) e db.ts (dove le factory sono definite).

In questo modo il test rileva violazioni anche da route API e pagine
Astro, non solo da moduli della cartella longevity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:38:56 +02:00
Adriano 9943312071 longevity: anagrafica, unico punto di giunzione fra identita e clinica
Implementa il modulo che unisce i due database (identity e longevity)
rispettando la separazione richiesta dal cliente: pseudonimizzazione
dei dati clinici. Il modulo esporta tre funzioni:

- creaCliente: registra un soggetto con il suo codice progressivo ISL-NNNN
- codicePerUtente: risale dal user_id del sito al client_code
- etaAllaData: calcola l'eta' al momento della misura (non la data di nascita)

Un test verifica che nessun altro modulo apra entrambe le connessioni,
preservando il firewall fra dati clinici e identita'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:33:23 +02:00
Adriano 7779c6b830 test: verifica che fuoriRange non trunca il valore numerico 2026-08-21 19:31:15 +02:00
Adriano a18bb7c02f longevity: registraMisure, unico varco con validazione sul registro 2026-08-21 19:27:41 +02:00
Adriano 370dcbda56 longevity: registro dei test e pesi versionati come dato 2026-08-21 19:22:32 +02:00
Adriano 1821a0a8d4 spec: score.nome diventa score.elemento, coerente con la tabella pesi 2026-08-21 19:17:42 +02:00
Adriano ad2fc0d510 fix: rinomina score.nome in score.elemento per coerenza con tabella pesi 2026-08-21 19:17:03 +02:00
Adriano f16012c8bd longevity: schema dei due database, identity separato da longevity 2026-08-21 19:15:17 +02:00
Adriano bdc44dd3c5 chore: ignora gli artefatti di lavoro dei subagenti in .superpowers/ 2026-08-21 19:11:44 +02:00
Adriano 534833c767 docs: piano di implementazione dello strato dati longevity
Sette task in TDD: schema dei due database, registro test e pesi versionati,
registraMisure come unico varco, anagrafica che tiene separata identita' e
clinica, ruoli cliente/trainer, salvataggio del questionario, export.

Alla fine esiste una libreria testata e nessuna pagina: il motore di calcolo
e le schermate sono i due piani successivi.
2026-08-21 19:05:07 +02:00
Adriano 02d0224e5a docs: design della piattaforma IN-SANITY LONGEVITY PROJECT
Fascicolo unico del cliente dentro il sito esistente, ramo separato e nessun
deploy. Tre database: sito, identity (codice <-> nome) e longevity (misure,
mai un nome) - cosi' l'export per le statistiche e' gia' pseudonimizzato per
costruzione, come chiesto da Nicola il 21/08.

Le misure sono un registro (una riga per valore) e i test sono dato, non
codice: togliere l'agilita' o passare l'HRR da 2' a 1' non e' una migrazione.
I pesi stanno in tabella versionata, cosi' il congelamento degli score
storici ha davvero qualcosa dietro.

Punteggi congelati con due versioni distinte (domande e modello di calcolo).
Il tipo di ritorno del motore rende impossibile mostrare un numero pieno dove
la regola vuole il tratteggio.
2026-08-21 18:41:06 +02:00
Adriano ecab67805b Data dell'inaugurazione piu' grande 2026-08-19 10:34:38 +02:00
Adriano cdfbf194c2 Eventi: la data dell'inaugurazione si legge da lontano
Chiesto da Adriano il 19/08: era 0,82rem sotto un titolo che arriva a 2,6rem, e
sembrava una didascalia. Ora cresce con la finestra come il titolo, restando un
gradino sotto.
2026-08-19 10:34:12 +02:00
Adriano 76adef3fc7 Titolo e data dell'inaugurazione su due righe 2026-08-19 10:31:49 +02:00
Adriano d079019dcf Eventi: titolo e data dell'inaugurazione su due righe
Chiesto da Adriano il 19/08. Sono due tag e non un testo con un a capo dentro:
la data si cambia da sola se l'evento si sposta, e un ritorno a capo dentro un
valore di testo non sopravvive all'editor del pannello.

Il titolo vive anche nel database: in produzione va aggiornato
eventi.inaugurazione.title a "Inaugurazione", altrimenti resta la riga unica.
Il tag nuovo (.date) non e' in tabella e viene dal seed.
2026-08-19 10:30:07 +02:00
Adriano 48909e8c8b Foto del team senza logo, e la foto di Eva torna nel repo 2026-08-19 10:25:18 +02:00
Adriano cc6945c3d1 Eva: la foto della pagina About torna nel repo, senza logo
La foto di Eva in pagina non veniva dal repo ma da un file caricato dal
pannello il 12/07 (tag team.eva.image-about): lo scatto giusto, ritagliato piu'
largo, ma con il logo ancora nell'angolo. Il file del repo era l'altro ritaglio,
piu' stretto, e non lo vedeva nessuno.

Ora team-eva-dark.jpg E' quella foto, col logo rimosso: maschera dei pixel del
logo allargata di 4 px per prendere i bordi sfumati, ricostruzione biharmonic
(scikit-image) e grana del tessuto ripresa dalla stoffa 80 px piu' in alto —
una toppa liscia si sarebbe notata.

Cosi' tutte le foto stanno in un posto solo e passano dall'ottimizzazione della
build (54 KB webp invece di 177 KB jpeg servito com'e'). In produzione va
svuotato il tag team.eva.image-about, altrimenti continua a vincere il file
caricato. Originale col logo su
wasabi://.../foto-team/team-eva-dark-ORIGINALE-col-logo.jpeg; il file su
uploads/ resta dov'e'.

Il ritaglio e' 864x1240 (0.697) invece di 1547x1920 (0.806): le card ritagliano
a 3:4, quindi qui si perde qualcosa sopra e sotto invece che ai lati. Verificato
sulla card renderizzata.
2026-08-19 10:24:13 +02:00
Adriano f7a9ae47d1 Foto del team senza il logo nell'angolo
Adriano ha tolto un piccolo logo dall'angolo in basso a destra delle sette foto
scure della pagina About: alessandro, alice, donata, eleonora, eva, giulia,
nicola. Originali in wasabi://.../Sito_Web/foto-team-originali-20260819.zip.

Stesso formato di prima (JPEG 1547x1920, RGB): l'unica differenza e' un riquadro
di ~80x80 px nell'angolo, verificato confrontando le immagini. I file pesano il
doppio perche' risalvati a qualita' piu' alta, ma in pagina non cambia nulla: la
build li ricomprime in webp attorno agli 85 KB.

Nota: la foto di Eva in pagina NON e' questa, e' un ritaglio piu' largo dello
stesso scatto caricato dal pannello il 12/07 (tag team.eva.image-about), che il
logo ce l'ha ancora.
2026-08-19 10:16:29 +02:00
Adriano 9772b3a3f6 Il titolo della pagina About us 2026-08-19 09:43:49 +02:00
Adriano 32c8a510bb About us: il titolo in cima alla pagina dice About us, non Insanitylab
Segnalato da Adriano il 19/08. Il valore vive anche nel database dei contenuti,
dove viene letto per primo: il seed da solo non avrebbe cambiato la pagina, e
l'aggiornamento del tag about.hero.title va fatto in produzione.
2026-08-19 08:59:44 +02:00
Adriano 20f0c2120b Piu' aria fra l'immagine e il titolo Check-up Avanzato 2026-08-19 08:54:46 +02:00
Adriano 6e002d4dc1 Check-up: il titolo della sezione Avanzato si stacca dall'immagine
Segnalato da Adriano il 19/08: "Check-up Avanzato" era appiccicato alla foto
sopra e si leggeva come una didascalia invece che come l'inizio di una parte
nuova.

Lo stacco e' un padding e non un margine: sopra c'e' gia' il margine
dell'immagine, e due margini verticali adiacenti collassano fra loro — con
margin-top lo spazio sarebbe rimasto quello di prima.
2026-08-19 08:49:55 +02:00
Adriano 3790d6d506 Check-up senza la domanda "Che cosa comprende?" 2026-08-19 08:46:19 +02:00
Adriano e315887964 Check-up: via la domanda "Che cosa comprende?" in fondo alla scheda
Chiesta da Adriano il 19/08. Restava in coda alle FAQ e ripeteva, in versione
piu' povera, cio' che la sezione Check-up Avanzato racconta per esteso.

In pagina resta una sola domanda. I due tag del pannello
(service.profilazione.faq.2.q e .a) non sono piu' letti da nessuno.
2026-08-19 08:45:35 +02:00
Adriano 0cbaf66a5b Pubblica le modifiche di agosto: calendario delle class, promo a edizioni e le richieste del 17/08 2026-08-18 17:06:53 +02:00
Adriano 3741ce34ac Modifiche chieste dal cliente il 17 agosto
Il documento MODIFICHE_PER_SITO.pdf porta cinque cose.

1. "Small Group" si chiama "Personalized Training Group". Il nome vive anche
   nel database dei contenuti, dove `resolveContent` legge prima della copia
   nel codice: `scripts/testi-17-agosto.mjs` aggiorna le 19 righe rimaste,
   una volta sola, correggendo anche gli articoli ("Lo Small Group" -> "Il
   Personalized Training Group"). Lo slug della scheda resta `small-group`:
   cambiarlo romperebbe i link gia' girati su WhatsApp.

2. Dalla pagina Servizi le card instradano dove serve. "One to One" apre la
   vista Training con One to One, Personalized Training Group e Rehab;
   "Class" quella con Holistic e Reformer. Le viste sono `/training/<vista>`;
   una vista inventata e' 404, non l'elenco intero, altrimenti un indirizzo
   rotto continuerebbe a sembrare buono.

3. La Performance Class non si eroga piu': la scheda che la spiegava
   risponde 404, la card "Class" resta in griglia. Con la scheda e' caduta la
   tabella prezzi, che era solo sua.

4. Il Check-up racconta Base e Avanzato: trafiletto nuovo nella card e
   sezione "Check-up Avanzato" nella scheda, con la Wellness Tower e i cinque
   punti. Il calendario delle class si apre ora anche dalla card Class in
   griglia e dalla scheda Reformer, oltre che da Holistic.

5. "Promo ed eventi" diventa una pagina di scelta: le promozioni restano su
   /promo-eventi/promo, l'inaugurazione del 19 settembre sta su
   /promo-eventi/eventi. Il testo dice che il programma della giornata manca
   ancora, ed e' editabile dal pannello.

Corretti due link del footer che portavano a schede inesistenti
(`/services/personal-training` non e' mai stato uno slug).

181 test verdi (157 + 24 nuovi), build ok, `astro check` invariato.
2026-08-18 17:06:05 +02:00
Adriano 69bd17a296 Calendario: si vedono tutti gli orari validi, e il PDF ne fa una pagina ciascuno
Il popup mostrava solo l'orario del momento: chi guarda ad agosto vuole sapere
com'e' settembre per intero, e una griglia sola gli fa credere che valga per
sempre. Ora ci sono tutti quelli non ancora scaduti, ognuno con la sua etichetta
- In vigore, oppure la data da cui parte - e quelli passati spariscono da soli.

Il bottone scarica /calendario/tutti.pdf: un orario per pagina, con le giornate
fuori griglia ripetute su ognuna, perche' un foglio stampato da solo deve bastare
a se stesso. Gli id singoli restano validi per mandarne uno solo.

calendarioDaMostrare (uno) diventa calendariDaMostrare (tutti): una funzione
sola, non due che dicono cose simili - e' lo stesso difetto dei due link della
pagina servizi, che erano due espressioni per lo stesso indirizzo e sono
divergute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:22:22 +02:00
Adriano d03c62641e Calendario: il 19 e il 20 settembre non sono un buco, sono un evento e una chiusura
Quei due giorni non stanno in nessuna delle due griglie perche' non hanno class:
il 19 c'e' la presentazione della nuova apertura, il 20 si sta chiusi. Scritto
come avviso con la sua finestra, il vuoto smette di sembrare una trascrizione
incompleta e diventa un'informazione.

Gli avvisi compaiono in cima al popup e in fondo al PDF - e' il file che gira su
WhatsApp, e chi lo riceve non ha la pagina davanti. Spariscono da soli quando
sono passati: un avviso scaduto dice che il centro e' chiuso in un giorno gia'
trascorso, e chi legge non sa piu' se valga per quest'anno o per il prossimo.

Lo spazio riservato in fondo alla pagina cresce con gli avvisi, altrimenti la
griglia si allunga fino a spingerli su una seconda pagina.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:07:24 +02:00
Adriano 928de34c55 Calendario: mercoledi 17.30 e 18.15 sono vuote per davvero
Le due celle coperte dal riquadro di Excel nel foglio dal 21 settembre: Adriano
conferma che non c'e' nulla. Il dato non cambia, cambia il commento - quel vuoto
adesso e' una risposta, non una lettura mancata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:44:19 +02:00
Adriano 5be0fa787c Calendario: il PDF e' un file vero, generato dal server
"Scarica in PDF" non apre piu' la stampa del browser: e' un link a
/calendario/<id>.pdf che restituisce un documento disegnato dal dato con PDFKit,
con Content-Disposition attachment. Serve perche' il calendario si gira su
WhatsApp, ed e' cosi' che questo centro parla coi clienti: chi lo riceve non ha
il sito aperto davanti.

Niente browser headless: Chromium in un container da 2 vCPU costa centinaia di
megabyte e un processo per richiesta, per una griglia di sette colonne.

Le linee della griglia si tracciano in una passata a parte, dopo i riempimenti:
disegnandole riga per riga, il riempimento della riga seguente copriva quella
appena fatta e due lezioni uguali in fasce consecutive si leggevano come un
blocco unico lungo il doppio.

Quattro test: intestazione %PDF- e coda %%EOF (un documento non chiuso arriva
monco e il browser mostra una pagina bianca invece di un errore), una pagina
sola, nomi di file distinti. Un id sconosciuto e' 404, non un PDF vuoto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:14:02 +02:00
Adriano 01f49ab6d2 Calendario delle class: popup a pieno schermo dalle pagine Performance e Holistic
Le due griglie fornite dal cliente (1-18 settembre, dal 21 in poi) sono
trascritte come dato e ridisegnate nello stile del sito: si leggono da telefono,
si stampano in PDF e per correggere una cella non serve rifare uno screenshot.

Quale griglia compare lo decide la data, come per le edizioni della promo: quella
in vigore, oppure - se nessuna lo e' ancora - la prossima, dichiarando da quando
entra. Serve perche' il link esiste da subito e ad agosto un "nessun calendario"
non direbbe niente a chi vuole gli orari di settembre.

Il popup e' un <dialog> nativo: fondale, Esc e trappola del focus senza
librerie. "Scarica in PDF" stampa la sola griglia, gia' impaginata in orizzontale
dal CSS di stampa. Le discipline hanno una barra colorata a sinistra oltre al
fondo, cosi' restano distinguibili anche quando il browser stampa senza sfondi.

Quattordici test sul dato: confini al secondo in ora italiana, fasce orarie
uniche e in ordine, nessuna lezione in un giorno di chiusura, e il buco vero fra
il 19 e il 20 settembre resta dichiarato invece che nascosto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:58:47 +02:00
Adriano a18a505254 Servizi: foto e "Per saperne di piu'" della stessa card portano allo stesso posto
Erano due espressioni diverse per lo stesso link e sono divergute: su One to One e
Performance Class la foto apriva la scheda del servizio mentre il testo mandava a
/training, quindi la destinazione dipendeva da dove capitava il dito. Adesso
l'indirizzo lo calcola una funzione sola, usata da entrambi. Verificato sulla
pagina renderizzata: sei card, zero link discordanti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:38:03 +02:00
Adriano cbcb533e57 Promo: un'anteprima non applicata lo dice, invece di mostrare oggi in silenzio
Senza login (o con una data illeggibile) il parametro veniva ignorato e la pagina
rispondeva con l'edizione corrente: due date diverse davano lo stesso risultato,
e si legge come una funzione rotta invece che come un accesso mancante. Ora una
barra rossa dichiara il motivo e come rimediare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:32:57 +02:00
Adriano 0b5d9f4106 Promo: le edizioni si scambiano da sole alla data stabilita
La pagina non mostra piu' una promozione sola scritta nel codice: sceglie
l'edizione la cui finestra contiene l'istante della richiesta. Il 20 agosto alle
00:00 la promo agosto lascia il posto agli Opening Bundles senza deploy, e dopo
il 4 settembre la pagina dichiara che non c'e' nessuna promozione invece di
lasciare online prezzi scaduti.

Le edizioni sono dato (data/promo-edizioni.ts): finestra, quante card, quali
campi ha ciascuna. I testi restano tag modificabili dal pannello, col prefisso
dell'edizione. I bundle aggiungono domanda, descrizione e riquadro del periodo
di utilizzo, presi dal volantino del cliente.

Le date portano l'offset esplicito: il server gira in UTC e senza offset lo
scambio avverrebbe due ore prima di quanto scritto sul sito. La pagina esce con
Cache-Control: no-store, perche' una copia in cache la congelerebbe all'edizione
vecchia.

Dieci test: le finestre non si sovrappongono e non lasciano buchi, il countdown
scade quando finisce l'edizione, e ogni tag richiesto esiste nel seed - un tag
mancante non romperebbe la pagina, lascerebbe una card senza prezzo.

Anteprima con ?anteprima=<data>, solo per chi e' loggato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:27:31 +02:00
Adriano cd73906c08 Migliora il layout mobile delle sezioni home
MethodSteps passa a una sola colonna con step in orizzontale, PartnersStrip
riduce loghi e spaziature, TrainingCards ridimensiona testi e padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:17:15 +02:00
Adriano Dal Pastro d6c6597d0a Ignora i backup .env.bak*
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:26:43 +00:00
Adriano 97bf270906 Lista articoli del blog per revisione cliente (md + pdf)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:58:13 +02:00
Adriano 63417fb8ea FAQ promo eventi: rimando WhatsApp e riscrittura FAQ 1 e 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:51:30 +02:00
Adriano 9000c78a22 Aggiunge FAQ acquisto pacchetti in cima alla promo eventi
Nuovo tag promo.faq.0 (q/a) numerato 0 per evitare di rinumerare le FAQ
esistenti: il seed è INSERT OR IGNORE e non riscriverebbe i tag già
presenti nei DB, causando disallineamento tra domanda e risposta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:24:32 +02:00
Adriano 12f6b088de README aggiornato: piattaforme, Stress Index, cestino del blog e note di deploy
Documenta il ruolo piattaforme al posto di campus, la piattaforma Stress
Index con le scelte fatte sullo stile e sulle isole React, il cestino
degli articoli e il caricamento degli articoli via script, oltre alle
trappole incontrate: il percorso nuovo del progetto Campus, la cache
dell'indice in sviluppo e la lettura del database in WAL dopo il deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:03:52 +02:00
Adriano ab2797e487 Merge: piattaforme riservate, Stress Index, pannello rinnovato e promo agosto
Porta in main il lavoro di questa sessione: il ruolo piattaforme al posto
di campus con il menu dedicato, la dashboard Stress Index tradotta nello
stile del sito, l'area riservata ridisegnata con cestino degli articoli e
gestione utenti rivista, il Campus riallineato alle 167 guide del corso,
venti articoli divulgativi nel blog e la promo di agosto con countdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:21:39 +02:00
74 changed files with 47802 additions and 348 deletions
+7
View File
@@ -2,7 +2,14 @@ node_modules/
dist/ dist/
.astro/ .astro/
.env .env
.env.bak*
data/*.db* data/*.db*
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
assets-src/ assets-src/
# Artefatti di lavoro dei subagenti (ledger, brief, review package)
.superpowers/
# Cache di Python generata rieseguendo l'oracolo
__pycache__/
+61 -20
View File
@@ -1,11 +1,13 @@
# InsanityLab Website # InsanityLab Website
Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**. Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti, piattaforme riservate ai clienti abilitati e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
## Descrizione ## Descrizione
Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor richtext, e il form di contatti invia email tramite SMTP. Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor richtext, e il form di contatti invia email tramite SMTP.
Accanto alla vetrina vivono due sezioni riservate, raccolte sotto `/piattaforme` e visibili solo agli utenti abilitati: il **Biohacking Campus** (guide di studio generate da un progetto esterno) e **Stress Index** (piattaforma di monitoraggio HRV). Quest'ultima è l'unica parte del sito che usa React, sotto forma di isole limitate ai grafici e alle viste interattive.
## Stack tecnologico ## Stack tecnologico
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche) - **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
@@ -14,6 +16,8 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
- **Autenticazione**: sessioni con password hashate (`bcryptjs`) - **Autenticazione**: sessioni con password hashate (`bcryptjs`)
- **Email**: Nodemailer (SMTP) - **Email**: Nodemailer (SMTP)
- **Editor articoli**: TipTap (rich text) - **Editor articoli**: TipTap (rich text)
- **Isole interattive**: React 19 via `@astrojs/react`, solo nella piattaforma Stress Index
- **Grafici**: Recharts (caricati unicamente sulle pagine della piattaforma)
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno - **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati) - **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
- **Test**: Vitest - **Test**: Vitest
@@ -25,9 +29,10 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi". - **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina. - **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito. - **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo. - **Blog** con editor TipTap, immagini caricate, firma autore, proprietà degli articoli per ruolo, filtro per tematica senza ricaricare la pagina e conteggio degli articoli per categoria.
- **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `campus`). - **Cestino degli articoli**: l'eliminazione sposta in `/admin/cestino`, da dove si ripristina o si cancella in via definitiva.
- **Campus**: sezione riservata con le guide di studio del corso Biohacking Campus, accessibile solo dopo il login e con navigazione autonoma. - **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `piattaforme`), creazione su pagina dedicata e password impostabile a mano o generata.
- **Piattaforme riservate** (`/piattaforme`): indice delle sezioni accessibili ai soli utenti abilitati — Biohacking Campus e Stress Index.
- **Form contatti** con validazione, honeypot antispam, ratelimit e invio email via SMTP. - **Form contatti** con validazione, honeypot antispam, ratelimit e invio email via SMTP.
## Installazione (sviluppo) ## Installazione (sviluppo)
@@ -54,8 +59,9 @@ Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato aut
| `npm run preview` | Anteprima locale della build | | `npm run preview` | Anteprima locale della build |
| `npm run start` | Server di produzione (`node --env-file=.env`) | | `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Test con Vitest | | `npm run test` | Test con Vitest |
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|campus]` | | `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|piattaforme]` |
| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` | | `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` |
| `npm run seed-blog` | Carica i venti articoli divulgativi tratti dal corso base del Campus (salta quelli già presenti) |
## Gestione contenuti ## Gestione contenuti
@@ -69,43 +75,65 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten
> Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`). > Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
## Blog
Gli articoli stanno nella tabella `posts` e si gestiscono da `/admin`. La pagina pubblica `/blog` filtra per tematica e pagina senza ricaricare — si scarica la stessa pagina e si sostituiscono soltanto griglia e filtri — mantenendo indirizzi veri e cronologia del browser: senza JavaScript la navigazione funziona come prima.
L'eliminazione di un articolo non cancella nulla: valorizza `deleted_at` e lo sposta nel **cestino** (`/admin/cestino`), da dove si ripristina o si elimina davvero. Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del singolo articolo — esclude il cestino, e chi non ha ruolo di gestione vede nel cestino soltanto i propri articoli.
Lo script `npm run seed-blog` carica venti articoli divulgativi ricavati dal corso base del Campus, dieci tecnici e dieci educativi. È versionato apposta: il database di produzione è un volume del VPS e il deploy non lo tocca, quindi articoli creati a mano in locale resterebbero in locale. Lo script salta gli slug già presenti e crea, se mancano, i due utenti che firmano gli articoli — la firma pubblica è lo `username` dell'autore.
## Ruoli e accessi ## Ruoli e accessi
Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`: Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
| Ruolo | Può fare | | Ruolo | Può fare |
|-------|----------| |-------|----------|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), Campus | | `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), piattaforme |
| `superuser` | Pannello contenuti + blog | | `superuser` | Pannello contenuti + blog |
| `user` | Solo i **propri** articoli del blog | | `user` | Solo i **propri** articoli del blog |
| `campus` | Solo la sezione riservata `/campus`; nessun accesso al pannello | | `piattaforme` | Solo le sezioni riservate (`/piattaforme`, `/campus`); nessun accesso al pannello |
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, il Campus per il ruolo omonimo. La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin. Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, l'indice delle piattaforme per il ruolo `piattaforme`. La gestione utenti (`/admin/users`, solo admin) consente creazione su pagina dedicata (`/admin/users/new`), cambio password — scelta a mano o generata, mostrata una sola volta perché nel database resta il solo hash — cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin.
## Campus (sezione riservata) > Il ruolo `campus` è stato rinominato in `piattaforme` quando le sezioni riservate sono diventate due. Una migrazione in `src/lib/db.ts` aggiorna da sé gli utenti esistenti.
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare alla home — ma usano i font, i colori e il CSS globale di InsanityLab. ## Piattaforme riservate
Le sezioni riservate sono raccolte sotto `/piattaforme`, che è anche la pagina di atterraggio del ruolo omonimo. L'elenco vive in `src/data/piattaforme.ts`: aggiungerne una è una riga, e il menu dell'header la mostra da sé a chi ha i permessi.
### Biohacking Campus (`/campus`)
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare all'indice delle piattaforme — ma usano i font, i colori e il CSS globale di InsanityLab.
I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`. I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`.
Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida. Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida.
> Il progetto sorgente sta in `/home/adriano/Wasabi-Adp-Work/AI-OS/projects/personale/biohacking-campus` (percorso predefinito dello script). In sviluppo, dopo un sync, il server va riavviato: `src/lib/campus.ts` tiene l'indice in cache in memoria e le guide nuove darebbero 404.
### Stress Index (`/piattaforme/stress-index`)
Piattaforma di monitoraggio HRV portata dentro il sito da un prototipo Next.js esterno: nove viste — cruscotto giornaliero, elenco clienti, scheda cliente, analytics di studio, modulo sport con sessioni e atleti, team live, impostazioni, organizzazione. Il design system del prototipo (Tailwind, palette teal) è stato sostituito con quello del sito: lo stile sta in `src/styles/stress-index.css`, un foglio globale prefissato `.si` perché le stesse classi servono sia ai componenti Astro sia alle isole React, che lo scope di Astro non raggiungerebbe.
React è limitato a ciò che ha davvero bisogno di stato: la scheda cliente, la vista Analytics, il modulo Sport e i grafici Recharts. Il resto è Astro con qualche riga di JavaScript. I dati sono **dimostrativi** e stanno in `src/lib/stress-index/data.ts`; le aggregazioni con una logica propria (finestre temporali, distribuzioni, riepiloghi atleta) vivono in `analytics.ts` e `sport.ts` accanto, coperte da test.
## Struttura del progetto ## Struttura del progetto
``` ```
insanitylab-website/ insanitylab-website/
├── src/ ├── src/
│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus) │ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus, /piattaforme)
│ ├── components/ # Componenti (home/, content/, Header, Footer, …) │ ├── components/ # Componenti (home/, content/, stress-index/, Header, Footer, …)
│ ├── layouts/ # Layout Base, Admin, Campus │ ├── layouts/ # Layout Base, Admin, Campus, StressIndex
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed) │ ├── data/ # Dati statici + seed contenuti (services, trainings, site, piattaforme, content-seed)
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env │ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env, stress-index/
│ ├── assets/img/ # Immagini ottimizzate da Astro │ ├── assets/img/ # Immagini ottimizzate da Astro
│ └── styles/ # global.css │ └── styles/ # global.css, stress-index.css
├── data/ # SQLite (volume in produzione) ├── data/ # SQLite (volume in produzione)
├── uploads/ # Upload immagini (volume in produzione) ├── uploads/ # Upload immagini (volume in produzione)
├── campus-content/ # Contenuti del Campus generati da sync-campus ├── campus-content/ # Contenuti del Campus generati da sync-campus
├── scripts/ # create-user, sync-campus, smoke test ├── scripts/ # create-user, sync-campus, seed-blog-biohacking, smoke test
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects) ├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── compose.yaml # Stack Docker + label Traefik ├── compose.yaml # Stack Docker + label Traefik
├── Dockerfile # Build multi-stage ├── Dockerfile # Build multi-stage
@@ -137,11 +165,20 @@ Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/in
```bash ```bash
cd /opt/docker/insanitylab/src cd /opt/docker/insanitylab/src
git pull sudo git -c safe.directory="$PWD" pull origin main # il repo è di root: serve sudo, con password
docker compose up -d --build docker compose up -d --build # docker gira senza sudo
``` ```
Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello. Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello. Le migrazioni che cambiano un valore sono sempre guardate dal valore precedente, quindi non sovrascrivono ciò che è stato modificato dal pannello — con la conseguenza, da tenere presente, che un contenuto già ritoccato a mano **non** riceve il nuovo default: va corretto dal pannello.
Prima di ogni deploy conviene fare il backup del database, che è in modalità WAL: un `cp` del solo `.db` perderebbe le modifiche non ancora consolidate.
```bash
sqlite3 -cmd ".timeout 10000" /opt/docker/insanitylab/data/insanitylab.db ".backup '/dest/insanitylab.db'"
sqlite3 /dest/insanitylab.db "PRAGMA integrity_check;" # deve rispondere 'ok'
```
Gli articoli del blog non viaggiano con il codice: per caricarli in produzione, una volta sola, `docker exec insanitylab node scripts/seed-blog-biohacking.mjs`.
### Dominio e DNS ### Dominio e DNS
@@ -162,6 +199,10 @@ Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'av
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`. **Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
**Le guide nuove del Campus danno 404 in sviluppo**`src/lib/campus.ts` tiene l'indice in cache in memoria: dopo `npm run sync-campus` va riavviato il server.
**Il database di produzione sembra non aggiornato dopo il deploy**`sqlite3 -readonly` non legge il file `-wal`, dove stanno le modifiche non ancora consolidate, e mostra valori vecchi. Per verificare, interrogare il database in letturascrittura oppure — meglio — guardare direttamente le pagine del sito.
## Licenza ## Licenza
Proprietà di InsanityLab. Proprietà di InsanityLab.
+169
View File
@@ -0,0 +1,169 @@
# Articoli del blog — insanity.lab
Elenco completo degli articoli presenti sul sito, con categoria, stato di pubblicazione e sintesi.
Documento generato il 1 agosto 2026 per la revisione del cliente.
**Totale:** 20 articoli attivi (20 pubblicati, 0 in bozza).
## Articoli pubblicati
### 1. Il sonno si misura in cicli, non in ore
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 31 luglio 2026
- **Link:** https://insanitylab.it/blog/sonno-cicli-non-ore
Sette ore spezzate valgono meno di sei ore intere. Come funziona l'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.
### 2. HRV: che cosa dice davvero la variabilità cardiaca
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 29 luglio 2026
- **Link:** https://insanitylab.it/blog/hrv-variabilita-cardiaca
Non è la frequenza cardiaca e non è un voto sulla forma fisica. È la misura di quanto il sistema nervoso sa attivarsi e, soprattutto, tornare in equilibrio.
### 3. Quante proteine servono davvero
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 27 luglio 2026
- **Link:** https://insanitylab.it/blog/quante-proteine-servono
Prima la quantità, poi la qualità, solo alla fine distribuzione e timing. La gerarchia che conta, con i numeri per chi si allena e per chi no.
### 4. Creatina: che cosa dice la ricerca
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 25 luglio 2026
- **Link:** https://insanitylab.it/blog/creatina-cosa-dice-la-ricerca
È l'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.
### 5. Il freddo e l'ormesi: perché un piccolo stress fa bene
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 23 luglio 2026
- **Link:** https://insanitylab.it/blog/freddo-ormesi
Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell'organismo durante l'esposizione al freddo e dove sta il confine con l'esagerazione.
### 6. Respirare meno per ossigenare meglio: il ruolo della CO2
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 21 luglio 2026
- **Link:** https://insanitylab.it/blog/respirazione-anidride-carbonica
Non è l'ossigeno a mancare, ma l'anidride carbonica a servire. Come funziona lo scambio nei tessuti e perché la respirazione nasale cambia le cose.
### 7. La luce che regola l'orologio biologico
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 19 luglio 2026
- **Link:** https://insanitylab.it/blog/luce-orologio-biologico
Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.
### 8. Recupero attivo: perché fermarsi non basta
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 17 luglio 2026
- **Link:** https://insanitylab.it/blog/recupero-attivo
Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.
### 9. VO2max: l'indicatore che parla di longevità
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 15 luglio 2026
- **Link:** https://insanitylab.it/blog/vo2max-longevita
Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.
### 10. Leggere i dati di orologi e anelli senza farsi ingannare
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 13 luglio 2026
- **Link:** https://insanitylab.it/blog/leggere-i-dati-dei-dispositivi
Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.
### 11. Sette abitudini per dormire meglio da stasera
- **Categoria:** Contenuti educativi
- **Data:** 11 luglio 2026
- **Link:** https://insanitylab.it/blog/dormire-meglio-da-stasera
Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.
### 12. Muoversi ogni giorno: quanto basta davvero
- **Categoria:** Contenuti educativi
- **Data:** 09 luglio 2026
- **Link:** https://insanitylab.it/blog/muoversi-ogni-giorno
Fra il divano e la maratona c'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.
### 13. Il peso non si decide sulla bilancia
- **Categoria:** Contenuti educativi
- **Data:** 07 luglio 2026
- **Link:** https://insanitylab.it/blog/il-peso-non-e-solo-la-bilancia
Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.
### 14. Proteine a tavola: come distribuirle nella giornata
- **Categoria:** Contenuti educativi
- **Data:** 05 luglio 2026
- **Link:** https://insanitylab.it/blog/proteine-a-tavola
Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l'errore più comune: concentrare tutto a cena.
### 15. Doccia fredda: come iniziare senza esagerare
- **Categoria:** Contenuti educativi
- **Data:** 03 luglio 2026
- **Link:** https://insanitylab.it/blog/doccia-fredda-come-iniziare
Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.
### 16. Cinque minuti di respiro al giorno
- **Categoria:** Contenuti educativi
- **Data:** 01 luglio 2026
- **Link:** https://insanitylab.it/blog/cinque-minuti-di-respiro
Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.
### 17. La luce del mattino è la sveglia naturale
- **Categoria:** Contenuti educativi
- **Data:** 29 giugno 2026
- **Link:** https://insanitylab.it/blog/luce-del-mattino
Dieci minuti all'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.
### 18. Come capire quando serve fermarsi
- **Categoria:** Contenuti educativi
- **Data:** 27 giugno 2026
- **Link:** https://insanitylab.it/blog/quando-fermarsi
I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.
### 19. Il piatto della longevità
- **Categoria:** Contenuti educativi
- **Data:** 25 giugno 2026
- **Link:** https://insanitylab.it/blog/il-piatto-della-longevita
Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.
### 20. Tenere il cervello in allenamento
- **Categoria:** Contenuti educativi
- **Data:** 23 giugno 2026
- **Link:** https://insanitylab.it/blog/cervello-in-allenamento
Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+766
View File
@@ -0,0 +1,766 @@
# Longevity — motore di calcolo: 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:** portare in TypeScript il motore di punteggio del cliente, con un verificatore numerico che dimostri che i due producono gli stessi numeri.
**Architecture:** le curve di normalizzazione e la cascata a quattro livelli diventano funzioni pure in `src/lib/longevity/motore/`. Curve e pesi si leggono dal registro nel database, non da costanti nel codice. Il tipo di ritorno rende impossibile leggere il valore di un punteggio dichiarato insufficiente. Un oracolo Python versionato genera i casi di riferimento contro cui il porting si confronta.
**Tech Stack:** TypeScript, vitest, `python3` (solo per generare il riferimento). Nessuna dipendenza npm nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
**Piano precedente, già eseguito:** `docs/plans/2026-08-21-longevity-strato-dati.md` — lo strato dati è costruito (ramo `feat/longevity`, 20 commit).
## 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`, che usano `':memory:'`.
- Codice e commenti in italiano.
- Soglia di copertura: **0.40**, la costante `COPERTURA_MINIMA` già esportata da `src/lib/longevity/db.ts`. Mai un numero scritto a mano nei rami.
- `MODEL_VERSION` iniziale: **`v1.0`**.
- **Nessun dato reale di persone** nelle fixture: i casi di prova sono sintetici.
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` fallisce da prima (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
## Il principio che regge tutto il piano
Il porting delle curve è la parte più pericolosa dell'intera piattaforma, e il pericolo non è il codice che non compila: è **un coefficiente trascritto male**. Produce un punteggio sanitario sbagliato che sembra plausibile, che nessun test funzionale intercetta, e che diventa il valore "atteso" di tutto ciò che gli sta sopra.
Quindi la fedeltà **non è garantita dalla trascrizione**, ma dal confronto numerico con l'originale. L'oracolo (`tests/longevity/riferimento/isl_scoring_engine.py`, scritto dal cliente) genera i valori attesi su una griglia fitta di ingressi; il test TypeScript li rilegge e confronta. Un implementatore che sbagli una cifra se ne accorge subito, non fra sei mesi.
⚠️ **Corollario:** nessun task di questo piano è "finito" perché i suoi test passano. È finito quando il **confronto con l'oracolo** passa sulle funzioni che tocca.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `tests/longevity/riferimento/isl_scoring_engine.py` | l'oracolo (già presente, del cliente, non si modifica) |
| `tests/longevity/riferimento/genera-riferimento.py` | esegue l'oracolo su una griglia e scrive `riferimento.json` |
| `src/lib/longevity/motore/curve.ts` | le cinque curve del questionario + le utility |
| `src/lib/longevity/motore/test-fisici.ts` | le curve dei test misurati in sala e dagli strumenti |
| `src/lib/longevity/motore/cascata.ts` | aggregazione, assi, macro-score, Fitness Age |
| `src/lib/longevity/motore/index.ts` | la funzione che calcola una sessione leggendo dal registro |
---
### Task 1: L'oracolo genera il riferimento
Prima di portare una sola curva, serve il metro di paragone.
**Files:**
- Create: `tests/longevity/riferimento/genera-riferimento.py`
- Create (generato): `tests/longevity/riferimento/riferimento.json`
- Test: nessuno — è uno strumento; lo verificano i task seguenti
**Interfaces:**
- Consumes: `tests/longevity/riferimento/isl_scoring_engine.py`
- Produces: `riferimento.json`, con questa forma esatta:
```json
{
"generato_da": "isl_scoring_engine.py",
"casi": [
{ "fn": "score_plank", "args": [110, "M"], "atteso": 62.5 },
{ "fn": "score_bell_curve", "args": [7.5, 4, 7, 9, 12], "atteso": 100 }
]
}
```
- [ ] **Step 1: Scrivi il generatore**
⚠️ **Questo passo è già stato eseguito.** La griglia definitiva vive in
`tests/longevity/riferimento/genera-riferimento.py`, committata e verificata: rieseguirla
riproduce `riferimento.json` byte per byte (4018 casi, 25 funzioni, nessuna eccezione).
Qui **non** ne teniamo una seconda copia, e la ragione è un incidente vero: quando il piano
conteneva anche il codice, piano e script sono divergiti — il file di riferimento è rimasto
per un momento non riproducibile dal proprio generatore, che per un oracolo è il difetto
peggiore possibile. Due copie della stessa cosa si separano sempre; il codice sta nel repo,
il piano dice cosa deve fare e perché.
**Cosa la griglia copre**, che è ciò che va verificato se un domani si tocca:
- le curve del questionario su **tutto** il dominio, non su un campione, comprese le due
coppie di parametri di `score_decreasing` — quella normale e quella dell'alcol (7→14);
- `score_increasing_plateau` con `worst` **diverso da zero**: zero è il caso degenere in
cui un errore sull'offset resterebbe invisibile;
- `vo2max_from_2km_walk` variando **un parametro alla volta** dal caso base, così una
divergenza dice anche quale dei cinque coefficienti è sbagliato;
- i confini di banda **esatti** delle funzioni a bande (composizione, pressione, plank,
flamingo, SpO2), che una griglia a passo regolare salterebbe;
- le tre tabelle dei sollevamenti sul rapporto col peso corporeo, per entrambi i sessi.
- [ ] **Step 2: Eseguilo e guarda l'esito**
Run: `python3 tests/longevity/riferimento/genera-riferimento.py`
Expected: stampa il numero di casi (nell'ordine delle migliaia) e scrive il file.
⚠️ Se una chiamata solleva un'eccezione, **non aggirarla**: significa che l'oracolo ha un dominio più stretto di quanto la griglia assume. Restringi la griglia per quella funzione e **annota nel rapporto quale dominio hai dovuto escludere** — è un'informazione che serve a chi userà il motore.
- [ ] **Step 3: Verifica che il file sia sensato**
Run: `python3 -c "import json;d=json.load(open('tests/longevity/riferimento/riferimento.json'));print(len(d['casi']),'casi');print(sorted({c['fn'] for c in d['casi']}))"`
Expected: l'elenco delle funzioni coperte, senza buchi rispetto alla griglia sopra.
- [ ] **Step 4: Commit**
```bash
git add tests/longevity/riferimento/
git commit -m "longevity: l'oracolo del motore e la griglia di riferimento"
```
---
### Task 2: Le cinque curve del questionario
**Files:**
- Create: `src/lib/longevity/motore/curve.ts`
- Test: `tests/longevity/motore-curve.test.ts`
**Interfaces:**
- Consumes: `riferimento.json` (Task 1)
- Produces:
- `clamp(x: number, lo?: number, hi?: number): number`
- `lerp(x: number, x0: number, x1: number, y0: number, y1: number): number`
- `curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number`
- `curvaDecrescente(v: number, best: number, worst: number): number`
- `curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number`
- `curvaDirettaX10(v: number): number`
- `curvaDirettaX10Invertita(v: number): number`
- `curvaGradini(v: number, steps: [number, number][], zeroVal: number | undefined, decrescente: boolean): number`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-curve.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
describe('curve del questionario, confrontate con l oracolo', () => {
it('la campana combacia su tutto il dominio', () => {
const casi = casiDi('score_bell_curve');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, low, pl, ph, high] = c.args as number[];
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
}
});
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
const casi = casiDi('score_decreasing');
expect(casi.length).toBeGreaterThan(40);
for (const c of casi) {
const [v, best, worst] = c.args as number[];
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
}
});
it('la crescente con plateau combacia', () => {
for (const c of casiDi('score_increasing_plateau')) {
const [v, worst, plateau] = c.args as number[];
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
}
});
it('la diretta per dieci combacia', () => {
for (const c of casiDi('score_direct_x10')) {
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
}
});
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
it('la diretta invertita e il complemento della diretta', () => {
for (const v of [0, 2.5, 5, 7.5, 10]) {
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
}
expect(curvaDirettaX10Invertita(0)).toBe(100);
expect(curvaDirettaX10Invertita(10)).toBe(0);
});
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
});
it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => {
const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
expect(curvaGradini(0, steps, 100, true)).toBe(100);
expect(curvaGradini(15, steps, 100, true)).toBe(85);
});
it('clamp e lerp si comportano come nell oracolo', () => {
expect(clamp(150)).toBe(100);
expect(clamp(-5)).toBe(0);
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: FAIL — modulo `motore/curve` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo `tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario: curve generiche» più `clamp` e `_lerp` in cima al file. Nomi italiani come da interfacce sopra.
Due avvertenze che l'oracolo non dichiara e che i test qui sopra pretendono:
- **`_lerp` limita `t` all'intervallo [0,1]**, quindi le curve non escono mai oltre gli estremi: è il motivo per cui `curvaDecrescente(0, 7, 14)` vale 100 e non di più. È esattamente ciò che rende voluta la soglia dell'alcol.
- **`curvaGradini` non è nell'oracolo**: è la logica `decstep`/`incstep` del prototipo HTML del cliente. Se il valore è zero ed esiste `zeroVal`, vince quello; altrimenti si interpola fra i gradini, partendo da `(0, 100)` per le decrescenti e `(0, 20)` per le crescenti.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: PASS, 8 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/curve.ts tests/longevity/motore-curve.test.ts
git commit -m "longevity: le cinque curve del questionario, verificate contro l oracolo"
```
---
### Task 3: Le curve dei test fisici
**Files:**
- Create: `src/lib/longevity/motore/test-fisici.ts`
- Test: `tests/longevity/motore-fisici.test.ts`
**Interfaces:**
- Consumes: `clamp`, `lerp` da `motore/curve` (Task 2); `riferimento.json` (Task 1)
- Produces, con questi nomi esatti (l'ordine dei parametri è quello dell'oracolo):
- `scoreGrassoPercento(fatPct: number, sesso: Sesso): number`
- `scoreMuscoloPercento(musclePct: number, sesso: Sesso): number`
- `scoreWhr(whr: number, sesso: Sesso): number`
- `scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number`
- `scoreSpo2(spo2: number): number`
- `scorePressione(sistolica: number, diastolica: number): number`
- `scoreRecuperoCardiaco(caloBpm1min: number): number`
- `scoreHandgrip(kg: number, eta: number, sesso: Sesso): number`
- `scorePushup(reps: number, eta: number, sesso: Sesso): number`
- `scoreSollevamentoSuPeso(caricoKg, pesoKg, rip, tiersM, tiersF, sesso): number`
- `scoreTrazioneIsometrica(sec: number, sesso: Sesso): number`
- `scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi?: number): number`
- `scorePlank(sec: number, sesso: Sesso): number`
- `scoreBackScratch(cm: number, eta: number, sesso: Sesso): number`
- `scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number`
- `scoreFlamingo(cadute: number): number`
- `scoreSitAndReach(cm: number, sesso: Sesso): number`
- `vo2maxDaStepTest`, `vo2maxDa2kmWalk`, `vo2maxDaMutt`, `vo2maxDaMilfit`
- `type Sesso = 'M' | 'F'`
- le sei tabelle: `TIERS_BENCH_M`, `TIERS_BENCH_F`, `TIERS_SQUAT_M`, `TIERS_SQUAT_F`, `TIERS_ROW_M`, `TIERS_ROW_F`
⚠️ **Due funzioni dell'oracolo NON vanno portate**, e non è una dimenticanza:
- `score_agility_ms` — l'agilità è stata **rimossa dallo score** dal cliente il 13/08: 548 ms reali contro i 250 attesi davano 20/100, falsi, per una scala non comparabile fra protocolli. Nel motore la funzione è rimasta ma nessun peso la richiama.
- `score_generic_range_local` — definita in fondo al file e mai chiamata.
Portarle significherebbe riportare in vita una misura che il cliente ha escluso.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-fisici.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as F from '../../src/lib/longevity/motore/test-fisici';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
const COPPIE: [string, (...a: never[]) => number][] = [
['score_fat_percent', F.scoreGrassoPercento as never],
['score_muscle_percent', F.scoreMuscoloPercento as never],
['score_whr', F.scoreWhr as never],
['score_vo2max', F.scoreVo2max as never],
['score_spo2', F.scoreSpo2 as never],
['score_blood_pressure', F.scorePressione as never],
['score_hrr', F.scoreRecuperoCardiaco as never],
['score_handgrip', F.scoreHandgrip as never],
['score_pushup', F.scorePushup as never],
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
['score_sit_to_stand_1min', F.scoreSitToStand as never],
['score_plank', F.scorePlank as never],
['score_back_scratch', F.scoreBackScratch as never],
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
['score_flamingo', F.scoreFlamingo as never],
['score_sit_and_reach', F.scoreSitAndReach as never],
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
['vo2max_from_mutt', F.vo2maxDaMutt as never],
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
];
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
for (const [nomePython, fnTs] of COPPIE) {
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
const casi = RIF.casi.filter((c) => c.fn === nomePython);
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
const divergenti: string[] = [];
for (const c of casi) {
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
if (Math.abs(ottenuto - c.atteso) > 0.05) {
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
}
}
expect(divergenti.slice(0, 5).join('\n')).toBe('');
});
}
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle', () => {
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
expect(casi.length).toBeGreaterThan(50);
const perTabella = (nome: string) =>
({ bench: [F.TIERS_BENCH_M, F.TIERS_BENCH_F], squat: [F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
row: [F.TIERS_ROW_M, F.TIERS_ROW_F] } as Record<string, unknown[]>)[nome];
for (const c of casi) {
const [carico, peso, rip, tiersM, tiersF, sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
const ottenuto = F.scoreSollevamentoSuPeso(
carico, peso, rip,
tiersM as [number, number][], tiersF as [number, number][], sesso
);
expect(ottenuto, `carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
}
expect(perTabella('bench')).toBeTruthy();
});
it('l agilita NON e stata portata: il cliente l ha esclusa dallo score', () => {
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: FAIL — modulo `motore/test-fisici` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo, una per una, mantenendo **identici** i coefficienti, le tabelle di ancoraggio e l'ordine dei confronti. Le trovi nelle sezioni «Composizione Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza & Struttura» e «Stabilità & Mobilità».
⚠️ **Non "sistemare" nulla mentre porti**, nemmeno ciò che sembra un difetto: se una funzione ha un ramo irraggiungibile o un valore di ripiego che pare arbitrario, va riprodotto tale e quale. L'oracolo è la definizione, non una proposta — e il confronto numerico ti dirà subito se hai cambiato qualcosa.
⚠️ **Attenzione a `score_handgrip`**: usa un ciclo con `break` e un ramo `else` del `for` (costrutto che in TypeScript non esiste). Riproduci il comportamento: se l'età non cade in nessun intervallo fra le ancore, vale il valore dell'ultima.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: PASS, 22 test
Se un test elenca divergenze, il messaggio ti dà ingressi, atteso e ottenuto: **correggi la tua funzione**, non il riferimento.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/test-fisici.ts tests/longevity/motore-fisici.test.ts
git commit -m "longevity: le curve dei test fisici, verificate contro l oracolo"
```
---
### Task 4: La cascata, e il tipo che impedisce di mostrare un numero che non c'è
**Files:**
- Create: `src/lib/longevity/motore/cascata.ts`
- Test: `tests/longevity/motore-cascata.test.ts`
**Interfaces:**
- Consumes: `COPERTURA_MINIMA` da `src/lib/longevity/db.ts`
- Produces:
- `type Punteggio = { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number }`
- `type VocePesata = { punteggio: number | null; peso: number }`
- `aggrega(voci: VocePesata[]): Punteggio`
- `calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio`
- `calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio`
- `calcolaFitnessAge(etaAnagrafica: number, pesi: Record<string, number>, voci: Record<string, number | null>): { fitnessAge: number | null; composito: Punteggio }`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-cascata.test.ts
import { describe, it, expect } from 'vitest';
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
describe('aggregazione e rinormalizzazione', () => {
it('con tutti i dati fa la media pesata', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
});
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
expect(r.stato).toBe('ok');
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
});
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.2, 2);
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
expect((r as { valore?: number }).valore).toBeUndefined();
});
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
expect(pocoSotto.stato).toBe('insufficiente');
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
});
it('senza nessun dato e insufficiente con copertura zero', () => {
const r = aggrega([{ punteggio: null, peso: 1 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBe(0);
});
});
describe('assi, macro e Fitness Age', () => {
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
it('un asse si calcola sui suoi sotto-domini', () => {
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
expect(r.stato).toBe('ok');
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.25, 1);
});
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
const assi = {
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
B: { stato: 'insufficiente', copertura: 0.1 } as const,
};
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
expect(r.stato).toBe('ok');
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
});
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
// 40 - (75 - 50) * 0.4 = 30
expect(r.fitnessAge).toBeCloseTo(30, 1);
});
it('senza dati sufficienti la Fitness Age non esiste', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
expect(r.composito.stato).toBe('insufficiente');
expect(r.fitnessAge).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: FAIL — modulo `motore/cascata` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta `aggregate`, `compute_axis`, `compute_macro_scores` e `compute_fitness_age` dall'oracolo, **con una differenza deliberata**:
⚠️ Nell'oracolo `aggregate()` restituisce il punteggio pieno **anche quando lo dichiara insufficiente** — la docstring dice il contrario di ciò che il codice fa. È una trappola: chi consuma deve ricordarsi di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo `Punteggio` la chiude: nel ramo `insufficiente` **il campo `valore` non esiste**, quindi la regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa impossibile da violare per distrazione.
Il resto va riprodotto fedelmente: la soglia è **inclusiva** (`copertura < MINIMA` è insufficiente, quindi esattamente 0.40 è ok), i pesi si rinormalizzano su quelli disponibili, la Fitness Age è `eta - (composito - 50) * 0.4`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: PASS, 10 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/cascata.ts tests/longevity/motore-cascata.test.ts
git commit -m "longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente"
```
---
### Task 5: Il motore legge dal registro, non da costanti
È il task che collega il motore allo strato dati: curve, parametri e pesi vengono dal database, così aggiungere o togliere un test resta una modifica ai dati.
**Files:**
- Create: `src/lib/longevity/motore/index.ts`
- Modify: `src/lib/longevity/registro.ts` — aggiungere `MODEL_VERSION` e `pesiDi`
- Test: `tests/longevity/motore.test.ts`
**Interfaces:**
- Consumes: tutto quanto sopra, più `createLongevityDb`, `seedRegistro`, `seedPesi`, `apriSessione`, `registraMisure`
- Produces:
- `MODEL_VERSION: string` (`'v1.0'`, esportata da `registro.ts`)
- `pesiDi(db, modelVersion, livello, contenitore): Record<string, number>` in `registro.ts`
- `applicaCurva(voce: VoceRegistro, valore: number): number | null` — normalizza un valore grezzo secondo la curva dichiarata nel registro
- `calcolaSessione(db, sessioneId): { assi, macro, fitnessAge, modelVersion, questVersion }`
- `salvaScore(db, sessioneId, risultato): void` — congela i punteggi nella tabella `score`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
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;
}
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
describe('il motore legge le curve dal registro', () => {
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
});
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 0)).toBe(100);
expect(applicaCurva(v as never, 7)).toBe(100);
expect(applicaCurva(v as never, 14)).toBe(0);
});
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
.run(JSON.stringify({ best: 0, worst: 7 }));
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
});
it('un test disattivato non entra nel calcolo', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0 },
});
const prima = calcolaSessione(db, 1);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_ore_sonno'`).run();
const dopo = calcolaSessione(db, 1);
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
});
});
describe('calcolo e congelamento di una sessione', () => {
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
});
const r = calcolaSessione(db, 1);
expect(Object.keys(r.assi).length).toBe(7);
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
});
it('congela i punteggi con le DUE versioni', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 7 },
});
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
expect(righe.length).toBeGreaterThan(0);
for (const r of righe) {
expect(r.model_version).toBe(MODEL_VERSION);
expect(r.quest_version).toBe('v1.0');
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
}
});
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
const db = dbPronto();
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
expect(pesi.handgrip).toBe(0.25);
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore.test.ts`
Expected: FAIL — modulo `motore` non trovato
- [ ] **Step 3: Write minimal implementation**
In `registro.ts` aggiungi:
```ts
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
export const MODEL_VERSION = 'v1.0';
export function pesiDi(
db: Database.Database, modelVersion: string,
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
): Record<string, number> {
const righe = db.prepare(
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
}
```
In `motore/index.ts`: `applicaCurva` smista sulla curva dichiarata nel registro e passa i parametri letti da lì; `calcolaSessione` legge le misure attive della sessione, le normalizza, raggruppa per sotto-dominio (facendo la **media** dei test dello stesso sotto-dominio), calcola i sette assi, i tre macro e la Fitness Age; `salvaScore` scrive in `score` una riga per asse, macro e Fitness Age, con `valore` a `null` quando lo stato è insufficiente.
⚠️ **La copertura si calcola sui pesi, non sul numero di test.** Un sotto-dominio senza nessuna misura è `null` e il suo peso si ridistribuisce; è la regola dell'oracolo e vale a ogni livello.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS su tutta la suite nuova; atteso il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/index.ts src/lib/longevity/registro.ts tests/longevity/motore.test.ts
git commit -m "longevity: il motore legge curve e pesi dal registro, e congela i punteggi"
```
---
### Task 6: La prova sul caso reale documentato
L'ultimo controllo non è sulle funzioni: è sul fatto che l'insieme produca i numeri che il cliente ha già visto.
**Files:**
- Test: `tests/longevity/motore-caso-reale.test.ts`
**Interfaces:**
- Consumes: tutto il motore
⚠️ **I dati di Donata e Nicola non entrano nelle fixture** — sono dati sanitari di due persone identificabili, e le fixture stanno in git. Il caso qui sotto è **sintetico**, costruito per esercitare le stesse condizioni descritte nella documentazione: un profilo con composizione corporea completa e tutto il resto mancante.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-caso-reale.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione } from '../../src/lib/longevity/motore';
/**
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
* Dati sintetici: nessuna persona reale.
*/
describe('un profilo con una sola area misurata', () => {
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
expect(insufficienti).toBe(7);
expect(r.fitnessAge).toBeNull();
});
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
for (const [nome, asse] of Object.entries(r.assi)) {
if (asse.stato === 'insufficiente') {
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
}
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-caso-reale.test.ts`
Expected: FAIL se qualcosa nella catena non regge il caso senza misure.
- [ ] **Step 3: Correggi ciò che il caso reale smonta**
Non c'è codice nuovo da scrivere: se questo test fallisce, il difetto è in un task precedente. Correggilo lì e **annota nel rapporto quale task ha dovuto essere corretto** — è l'informazione che dice se il porting regge davvero o solo sui casi comodi.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS, con il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add tests/longevity/motore-caso-reale.test.ts
git commit -m "longevity: la prova sul profilo con una sola area misurata"
```
---
## Cosa esiste alla fine di questo piano
Il motore completo: le curve verificate una per una contro l'oracolo del cliente su migliaia di casi, la cascata a quattro livelli, la lettura di curve e pesi dal registro, il congelamento dei punteggi con le due versioni distinte.
**Non esiste ancora nessuna pagina.** È il piano successivo, e da lì in poi il radar ha numeri veri da mostrare.
## Nota per il piano dell'interfaccia
I sette assi che il motore restituisce sono `Punteggio`, cioè o `{stato:'ok', valore}` o `{stato:'insufficiente'}` senza valore. La dashboard **non può** stampare un numero dove non c'è: è il tipo a impedirlo, ed è il motivo per cui il tratteggio del radar non dipende dalla disciplina di chi scrive la vista.
⚠️ Con i dati di oggi — solo questionario, nessun test fisico — **sei assi su sette risultano insufficienti**. È corretto e atteso: i test fisici entrano col piano degli import. Chi guarderà la prima dashboard non deve scambiarlo per un difetto.
+504
View File
@@ -0,0 +1,504 @@
# IN-SANITY LONGEVITY PROJECT — piattaforma dati cliente
**Data:** 2026-08-21
**Stato:** design approvato, implementazione non iniziata
**Branch:** `feat/longevity``main` non viene toccato, nessun deploy
**Committente:** In-Sanity Lab (Nicola Antonelli) — sviluppo Tielogic
---
## 1. Cos'è, e come si chiama
Il nome esiste già e viene dal cliente: **IN-SANITY LONGEVITY PROJECT** è
l'iniziativa — *«servizio premium basato su dati, con monitoraggio
longitudinale»* — e **IN-SANITY LONGEVITY SCORE** è il sistema di punteggio che
ne è il prodotto di punta. Questa piattaforma è l'implementazione software del
primo. Nel codice il nome tecnico è **`longevity`**.
Non è una raccolta di applicazioni separate: è **un fascicolo del cliente**.
Questionario, check-up in sala, Wellness Tower e VALD non sono quattro
sottosistemi, sono **quattro sorgenti che scrivono nello stesso posto**;
dashboard, vista trainer e punteggi sono **viste e funzioni derivate** di quel
posto. È la differenza fra progettare per dati e progettare per schermate, e
determina tutto quello che segue.
## 2. Perimetro
**Dentro:** anagrafica cliente, questionario in pagina, registrazione delle
misure da tutte le sorgenti, motore di calcolo degli score, dashboard cliente,
vista trainer, export per le statistiche.
**Fuori, e per ragioni diverse:**
- **I percentili interni ISL** — non per scelta: la documentazione del cliente
dichiara che sotto 15 campioni sono inutilizzabili e sotto 30 provvisori.
Vanno costruiti quando i dati esistono, non prima.
- **Gli 8 indicatori posturali** della Wellness Tower — esistono solo nel PDF
stampato, non nell'export a 55 colonne. Il cliente li ha già esclusi dallo
score.
- **Il blocco Trimestre / 2 Mesocicli** — dichiarato aperto dal cliente stesso e
ancora in analisi da parte sua.
- **Il deploy** — la piattaforma si sviluppa e si prova in locale. Quando mettere
qualcosa in produzione è una decisione separata, che non appartiene a questo
documento.
## 3. Collocazione
La piattaforma vive **dentro l'applicazione Astro esistente**, come già fanno
Biohacking Campus e Stress Index. Non è un secondo servizio, non è un secondo
container.
```
src/pages/longevity/ rotte visibili
src/pages/api/longevity/ endpoint
src/lib/longevity/ modello dati, motore, import
tests/longevity/ test
```
Il motivo per cui non è un progetto separato è che l'autenticazione, le sessioni
e l'anagrafica utenti esistono già qui e funzionano in produzione: rifarle altrove
significherebbe mantenerne due.
⚠️ **Il calcolo è in TypeScript, non in Python.** Il motore fornito dal cliente
(`isl_scoring_engine.py`, 537 righe) è aritmetica pura — interpolazioni lineari,
tabelle di soglie, medie pesate — e importa solo `dataclasses` e `typing`: nessuna
libreria numerica. Portarlo in TypeScript costa poco e evita un secondo runtime
dentro un'immagine `node:22-slim` a processo unico. La stessa scelta vale per il
prototipo del questionario, che calcola già in JavaScript: tenere le formule in
due linguaggi è il modo silenzioso di farle divergere.
## 4. I tre database
SQLite, tre file distinti, aperti da connessioni distinte.
| File | Contiene | Non contiene |
|---|---|---|
| `insanitylab.db` | il sito: articoli, contenuti, **utenti e sessioni** | niente di clinico |
| `identity.db` | `client_code``user_id`, nome, cognome, nascita, recapiti | nessuna misura |
| `longevity.db` | misure, questionari, punteggi, registro test | **nessun nome, mai** |
La proprietà che ne discende è la richiesta esplicita del cliente del 21/08: *«la
mappatura codice↔nome va tenuta in un posto separato e più ristretto rispetto
all'export usato per le statistiche, così anche chi ha accesso ai dati clinici
non può risalire all'identità»*. Qui l'export per le statistiche **è**
`longevity.db`: già pseudonimizzato per costruzione, senza una funzione di
anonimizzazione che qualcuno debba ricordarsi di chiamare.
**Vincolo di implementazione, verificabile con un test:** esiste **un solo
modulo** che apre insieme `identity` e `longevity`. Nessun altro punto del codice
importa entrambe le connessioni.
## 5. Modello dati
```sql
-- ============ identity.db ============
CREATE TABLE clienti (
client_code TEXT PRIMARY KEY, -- es. 'ISL-0007'
user_id INTEGER, -- users.id in insanitylab.db (nessuna FK: file diverso)
nome TEXT NOT NULL,
cognome TEXT NOT NULL,
data_nascita TEXT, -- ISO; resta QUI, non passa in longevity
email TEXT,
telefono TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_clienti_user ON clienti (user_id);
-- ============ longevity.db ============
CREATE TABLE soggetti (
client_code TEXT PRIMARY KEY,
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE sessioni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
eta_alla_data INTEGER, -- l'età serve al motore; la data di nascita no
operatore TEXT,
note TEXT,
quest_version TEXT, -- solo per tipo='questionario'
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessioni_cliente ON sessioni (client_code, data DESC);
CREATE TABLE misure (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
valore_num REAL,
valore_txt TEXT, -- per le risposte non numeriche
unita TEXT,
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_misure_cliente_test ON misure (client_code, test_id);
CREATE INDEX idx_misure_sessione ON misure (sessione_id);
CREATE TABLE registro_test (
test_id TEXT PRIMARY KEY, -- 'handgrip_dx', 'plank', 'q_sonno_ore'
etichetta TEXT NOT NULL,
unita TEXT,
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','x10_inv','decstep','incstep'
params TEXT, -- JSON dei parametri della curva
asse TEXT, -- uno dei 7 assi; NULL = non entra nello score
sotto_dominio TEXT, -- a QUALE sotto-dominio contribuisce; il peso sta in `pesi`
range_min REAL, -- atteso, non vincolante: fuori range si marca
range_max REAL,
attivo_da TEXT NOT NULL,
attivo_a TEXT, -- NULL = ancora attivo
note TEXT
);
CREATE TABLE pesi (
model_version TEXT NOT NULL,
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
contenitore TEXT NOT NULL, -- l'asse, il macro-score, o 'fitness_age'
elemento TEXT NOT NULL, -- il sotto-dominio, o l'asse, o la voce isolata
peso REAL NOT NULL,
PRIMARY KEY (model_version, livello, contenitore, elemento)
);
CREATE TABLE profilo_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER REFERENCES sessioni(id),
campo_id TEXT NOT NULL, -- 'farmaci', 'problematiche_attuali', 'obiettivi'
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE score (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
elemento TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ...
valore REAL, -- NULL quando stato='insufficiente'
copertura REAL NOT NULL,
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
quest_version TEXT,
model_version TEXT NOT NULL,
calcolato_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_score_cliente ON score (client_code, calcolato_at DESC);
```
### Perché l'età e non la data di nascita
Il motore ha bisogno dell'età — `score_handgrip(kg, eta, sex)`,
`score_vo2max(vo2max, eta, sex)`, le bande per decade — ma gli serve **un numero
al momento della misura**, non una data. Sesso ed età non identificano nessuno;
una data di nascita esatta, combinata con pochi altri campi, quasi sì. Costa una
colonna e toglie un quasi-identificatore dal database clinico.
### Perché il peso non sta sulla riga del test
Un sotto-dominio può essere coperto da **test alternativi**: la *spinta* si
misura con i push-up o con il 5RM di panca, a seconda del livello del cliente, e
il peso — 20% dentro *Forza & Struttura* — è del sotto-dominio, non del singolo
test. Se il peso stesse sulla riga del test si ripeterebbe su ogni alternativa,
e prima o poi due copie divergerebbero.
I pesi stanno quindi in una tabella loro, **con la `model_version` nella chiave**.
È ciò che rende reale il congelamento del §6: uno score del passato si può
rileggere con i pesi che erano in vigore quando è stato calcolato, invece che con
quelli di oggi. Senza questo, `model_version` sarebbe un'etichetta senza niente
dietro.
⚠️ Nota per l'implementazione: nel motore Python `plank` compare in **due** assi
`core` in *Forza & Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20).
È lo stesso doppio conteggio ammesso dal cliente per l'handgrip nella Fitness Age,
ma quello del plank **non è dichiarato da nessuna parte**. Questo schema lo
rappresenta senza problemi (due righe in `pesi`), però va segnalato al cliente
invece che riprodotto in silenzio.
### Perché il registro dei test è dato e non codice
Il cliente ha già i test in forma di registro: un foglio di 30 righe con fonte e
stato per ciascuno. Qui diventa una tabella, e la conseguenza è che **il set dei
test è mobile senza migrazioni**, cosa che è già successa tre volte in un mese:
l'agilità è stata rimossa dallo score, la capacità vitale pure, e l'HRR è passato
dalla finestra a 2' a quella a 1'.
Con questo modello: disattivare un test è valorizzare `attivo_a`; cambiare la
finestra dell'HRR è **un `test_id` nuovo che convive col vecchio**, e le
misurazioni già fatte restano leggibili e restano marcate per quello che sono,
invece di diventare confrontabili per sbaglio con le nuove.
## 6. Versioni e congelamento
Decisione del cliente, 21/08: quando cambia una formula i punteggi storici si
**congelano**, non si ricalcolano. La ragione è di prodotto ed è corretta — se
lo score storico cambia, il cliente non distingue più il proprio miglioramento
da un cambio di matematica, e il confronto nel tempo perde valore.
L'implementazione ha due conseguenze:
1. **Gli score si salvano**, non si ricalcolano a ogni visualizzazione. La
tabella `score` è la memoria di ciò che il cliente ha visto.
2. **Le misure grezze si salvano tutte**, quindi un ricalcolo con formule nuove
resta sempre possibile in lettura, per analisi, senza sovrascrivere niente.
**Due versioni, non una.** Ogni score porta `quest_version` (quali domande) e
`model_version` (quali curve e quali pesi). Il cliente ne ha implementata una
sola perché dal suo lato coincidevano, ma i pesi degli assi possono cambiare
senza che cambi una domanda: con una versione sola un punteggio del passato non
saprebbe dire quale delle due cose è cambiata.
⚠️ **Il congelamento sposta il problema del confronto, non lo elimina.** Due
compilazioni con versioni diverse restano non confrontabili anche se nessuno le
ha riscritte. Il grafico della progressione deve quindi **dichiarare
visivamente il cambio di versione**, altrimenti il cliente vede una linea
continua di numeri calcolati con matematiche diverse — che è l'equivoco che la
decisione voleva evitare.
## 7. Flusso dei dati
### Ingresso: un solo varco
```
questionario in pagina ─┐
gestionale trainer ─┤
import file (WT, VALD) ─┼─→ registraMisure(sessione, misure[]) ─→ misure
API (VALD Hub) ─┘ ↑ valida contro registro_test
```
`registraMisure` è l'unica funzione che scrive in `misure`. Valida ogni riga
contro il registro: **un `test_id` sconosciuto viene rifiutato**, non scritto in
silenzio. Un valore fuori dal range atteso viene invece **scritto e marcato**
(`fuori_range = 1`), perché scartarlo perderebbe un dato vero e clamparlo lo
falserebbe — vedi §9.
### Calcolo
Il motore legge misure e registro, e produce gli score seguendo la cascata a
quattro livelli del cliente: sotto-metrica → asse → macro-score → Fitness Age,
con rinormalizzazione dei pesi disponibili a ogni livello e soglia di copertura
al **40%**.
Il tipo di ritorno impedisce la trappola presente nel motore Python, dove
`aggregate()` restituisce il punteggio pieno **anche quando lo dichiara
insufficiente**:
```ts
type Punteggio =
| { stato: 'ok'; valore: number; copertura: number }
| { stato: 'insufficiente'; copertura: number } // non c'è nessun valore da leggere
```
La regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa
così impossibile da violare per distrazione: chi disegna il radar non ha il campo
da cui prendere il numero.
### Uscita
Dashboard cliente (radar a 7 assi, 3 macro-score, Fitness Age), vista trainer, ed
export per le statistiche — che è il contenuto di `longevity.db`, senza passare
da nessuna funzione di anonimizzazione.
## 8. Ruoli e accessi
Il sito ha oggi `admin | superuser | user | piattaforme`, e nessuno di questi è
il cliente finale né il trainer. Se ne aggiungono **due**: `cliente` e `trainer`.
| Rotta | Chi entra |
|---|---|
| `/longevity/io` | `cliente` (solo il proprio fascicolo), `trainer`, `admin` |
| `/longevity/gestionale` | `trainer`, `admin` |
| `/api/longevity/**` | come sopra, verificato lato server |
**Il cliente vede solo i propri dati, e non è una regola di interfaccia:** gli
endpoint ricavano il `client_code` dalla sessione e **mai** dalla richiesta. Un
client_code che arriva dal browser viene ignorato.
## 9. Errori e casi limite
**Misura fuori dal range atteso: si registra e si marca.** Il caso è già
successo — la capacità vitale di 5148 mL su un fondoscala dichiarato di
2000-3000 mL non era una prestazione eccezionale, era il range del fornitore
sbagliato, e clamparla a 100 produceva un dato apparentemente ottimo. Le misure
marcate finiscono in una lista che il trainer vede; non entrano nello score
finché qualcuno non le conferma.
**Scala non comparabile: si esclude dal registro, non si corregge nel codice.**
L'agilità a 548 ms contro i 250 attesi dava 20/100 su Stabilità — falso, e la
causa era il protocollo diverso. Un caso così si risolve disattivando il test nel
registro, non aggiungendo un fattore di correzione da qualche parte.
**Copertura sotto il 40%:** l'elemento è `insufficiente` e non ha un valore.
**Test sconosciuto in ingresso:** errore esplicito, la sessione non viene scritta
a metà.
## 10. Test
Il test principale è il **verificatore contro il motore Python**: gli stessi
ingressi devono produrre gli stessi numeri, e ogni curva va confrontata su tutto
il suo dominio, non su un caso singolo. È l'unico modo per sapere che il porting
non ha spostato niente.
Poi: le regole di copertura e rinormalizzazione, il rifiuto dei test sconosciuti,
la marcatura del fuori range, l'isolamento dei tre database (nessun modulo, a
parte quello designato, apre insieme identity e longevity), e il fatto che un
`client_code` proveniente dalla richiesta non venga mai onorato.
⚠️ **I dati reali di Donata e Nicola non entrano nelle fixture.** Sono dati
sanitari di due persone identificabili, e le fixture stanno in git, dove restano
per sempre e le legge chiunque abbia accesso al repository. I casi di prova si
costruiscono sintetici; i referti veri restano dove sono, per il confronto a mano
quando serve.
## 11. Punti aperti che il codice non risolve
Questi non sono dettagli implementativi: sono decisioni che spettano al cliente,
e finché non arrivano il codice si comporterà come qui dichiarato — non come
capita.
1. **Base giuridica per i dati sanitari (art. 9 GDPR).** Il campo `consenso` del
questionario riguarda le comunicazioni promozionali, non il trattamento di
dati di salute, e la finalità statistica è ulteriore rispetto a quella di
cura. Serve un consenso esplicito dedicato. **Non è un blocco allo sviluppo,
è un blocco alla messa in produzione con clienti veri.**
2. **Chi è il medico supervisore**, nominato nella specifica del questionario. Se
è un professionista sanitario, cambia la base giuridica utilizzabile
(art. 9.2.h), in meglio.
3. **La regola dei buchi nel questionario.** Oggi esiste in due versioni: il
prototipo annulla un sotto-dominio solo se non c'è **nessuna** risposta, il
motore usa il 40%. La piattaforma userà **una soglia unica, dichiarata nel
registro come parametro**, e in attesa della risposta assume il **40%**,
coerente con la regola dei sette assi.
4. **Il cambio di versione sul grafico** della progressione (§6).
5. **Il registro delle versioni**: cosa cambia a ogni incremento di
`quest_version` e `model_version`, altrimenti fra sei mesi «v1.3» non dirà
niente a nessuno.
6. **Nomi dei Livelli 1/2/3** — SPARK/FLUX/APEX o PULSE/FLOW/PEAK, aperti da
sessioni precedenti del cliente. Il livello è già una colonna del profilo.
7. **Export CSV/API di Stress Index**, l'unico canale la cui automazione non è
confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale
va inserito a mano.
## 12-bis. Cosa ha scoperto la costruzione del motore (22/08)
Il motore è stato costruito il 22/08 (ramo `feat/longevity`, 6 task, 308 test). Il porting
è fedele: eseguendo la funzione dimostrativa del cliente sui due motori, i sette assi, i tre
macro-score e la Fitness Age **combaciano numero per numero**.
### Due divergenze deliberate dall'oracolo, entrambe volute
1. **Un punteggio insufficiente non espone un valore.** Nell'originale la funzione di
aggregazione restituisce il numero anche quando dichiara l'elemento insufficiente; qui il
tipo non ha proprio il campo. È ciò che rende impossibile mostrare un numero dove i dati
non bastano.
2. **La Fitness Age esclude gli assi insufficienti**, mentre l'oracolo li include comunque.
⚠️ **Cambia il numero, e parecchio**: sullo stesso profilo parziale, 25,0 contro 34,8 —
quasi dieci anni. Chi confrontasse il nostro referto con il Python del cliente su un
profilo incompleto concluderebbe che il porting è rotto. **Da dire a Nicola.**
### ⚠️ Le curve dei test fisici non sono collegate, e agganciarle non sarà «solo dati»
Le venti curve dei test misurati sono portate e verificate, ma **irraggiungibili dal
motore**: il vocabolario delle curve del registro non ha nomi per loro, e la funzione che
applica una curva non può ricevere età e sesso, che quasi tutte richiedono.
📌 Questo contraddice la §5, dove il registro è presentato come dato che si estende senza
toccare il codice. **È vero per il questionario, non per i test fisici**: agganciarli
richiede di estendere il vocabolario e la firma. Il piano degli import deve saperlo, perché
oggi crede di trovare la presa già montata.
### Il confine dove la barriera finisce
Il tipo protegge il codice; la tabella `score` ha ora anche un vincolo che rifiuta un valore
scritto sotto uno stato che lo nega. ⚠️ Ma quel vincolo entra solo nei database **creati da
qui in avanti**: lo schema gira con `CREATE TABLE IF NOT EXISTS`, che su un file esistente
non fa nulla. Oggi nessun database esiste ancora — diventa un problema il giorno del primo
deploy, ed è quello il momento di ricordarsene.
### La storia dei punteggi si tiene
Un ricalcolo **aggiunge** righe invece di sovrascriverle, coerentemente col congelamento del
§6: sovrascrivere cancellerebbe proprio ciò che il congelamento protegge. La lettura passa da
una funzione tipata che restituisce l'ultimo calcolo per sessione — la dashboard non deve
inventarsi una query sua.
### 🔲 Una domanda per Nicola, sulla voce più pesante del modello
`hrv` pesa **0,50 su due assi***Recupero* ed *Energia* — ed è la singola voce più pesante
di tutto il modello. Nel motore è un dato solo, condiviso. Ma nella funzione dimostrativa
dell'oracolo i due assi ricevono **numeri diversi sotto lo stesso nome** (68 e 65).
O è la stessa misura riusata su due assi — come il plank, che Nicola ha già confermato — e i
due numeri sono illustrativi; oppure sono due metriche distinte, e allora le stiamo fondendo
per sbaglio. Diventa concreto appena si collega Stress Index, che è la sorgente di quel dato.
---
## 12. Cosa l'implementazione ha scoperto — da chiudere prima del motore
Lo strato dati è stato costruito il 21/08 (ramo `feat/longevity`, 19 commit, 241 test).
Costruirlo ha fatto emergere sei punti che questo documento non aveva previsto o su cui
si contraddiceva. **Nessuno è bloccante per lo strato dati; tutti lo diventano per il
motore di calcolo.**
1. **La conferma delle misure fuori range non ha dove stare.** La §9 dice che una misura
marcata «non entra nello score finché qualcuno non la conferma», ma lo schema della §5
non prevede nessuna colonna che registri quella conferma. Le due sezioni si
contraddicono: va aggiunta la colonna o riscritta la regola.
2. **`registraMisure` valida l'esistenza del test, non il valore.** Oggi accetta una
misura senza nessun valore, un testo dove il registro dichiara un numero, e un `NaN`
(che viene scritto come nullo e **non** marcato fuori range, perché ogni confronto con
NaN è falso). Una riga vuota conta come «test presente» ai fini della copertura del 40%.
3. **`seedRegistro` rilanciato disfa il registro.** Usa `INSERT OR REPLACE` e riazzera
`attivo_a` e le note: disattivare un test e poi rilanciare il seed lo riattiva. È
esattamente l'opposto della promessa della §5, dove disattivare un test è valorizzare
`attivo_a`.
4. **`MODEL_VERSION` non esiste.** `QUEST_VERSION` sta in `questionario.ts`; la gemella
che versiona curve e pesi non è dichiarata da nessuna parte, e `seedPesi` la riceve
come parametro che solo i test valorizzano. Finché non esiste, il congelamento del §6
ha metà del suo significato.
5. **La soglia del 40% è una costante di codice**, non un parametro nel registro come
chiede la §11.3 — e nessuno la consuma ancora.
6. **Registro e pesi sono disallineati per costruzione.** Il registro contiene oggi i 20
campi del questionario; i pesi citano handgrip, vo2max, plank, flamingo — sotto-domini
che nessun test ancora alimenta. Con i dati attuali sei assi su sette resterebbero
permanentemente «insufficiente». È atteso, perché i test fisici arrivano col piano degli
import, ma va saputo: il primo che lancia il motore lo legge come un difetto.
⚠️ **Una trappola già armata per il piano delle API.** Nelle regole di accesso, tutto ciò
che sta sotto `/api/longevity/` e non sotto `/api/longevity/gestionale/` è raggiungibile da
**ogni** cliente. Oggi è innocuo perché non esiste nessun endpoint, ma significa che la
scelta dei nomi delle rotte è una questione di sicurezza, non di stile: un domani
`/api/longevity/clienti` sarebbe l'elenco di tutti, aperto a tutti.
⚠️ **Due limiti noti dell'anagrafica**, accettati con cognizione: se fallisse anche la
cancellazione compensativa su identity, l'errore diagnostico verrebbe mascherato e
resterebbe un cliente senza pendant clinico; e i codici sono ordinati come stringhe,
quindi oltre `ISL-9999` l'ordinamento sbaglia e un codice potrebbe essere riusato.
📌 **Da dire a Nicola, non ancora detto:** il plank pesa su due assi — `core` in *Forza &
Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20). È lo stesso doppio conteggio
che lui dichiara per l'handgrip nella Fitness Age, ma questo non è dichiarato da nessuna
parte.
---
## 12. Decisioni prese, e da chi
| Decisione | Chi | Quando |
|---|---|---|
| Un record per compilazione + export Excel | Nicola | 21/08 |
| Versione del questionario su ogni record | Nicola | 21/08 |
| Formula cambiata → punteggi storici congelati | Nicola | 21/08 |
| Export pseudonimizzato, mappatura separata e ristretta | Nicola | 21/08 |
| Curva alcol 0-7 = 100 (soglia NIAAA/WHO), voluta | Nicola | 21/08 |
| La piattaforma vive dentro il sito, area riservata | Adriano | 13/08 |
| Ramo separato, nessun deploy | Adriano | 21/08 |
| Tre database separati | Adriano | 21/08 |
| Registro di misure invece di colonne fisse | Adriano | 21/08 |
| Il progetto è unico: un framework sui dati del cliente | Adriano | 21/08 |
| Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 |
+181 -3
View File
@@ -22,6 +22,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1", "recharts": "^3.10.1",
@@ -32,6 +33,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"marked": "^18.0.7", "marked": "^18.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -1964,6 +1966,30 @@
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oslojs/encoding": { "node_modules/@oslojs/encoding": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2387,6 +2413,15 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tiptap/core": { "node_modules/@tiptap/core": {
"version": "3.27.1", "version": "3.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
@@ -2979,6 +3014,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -3583,6 +3628,24 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -3751,6 +3814,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4163,6 +4235,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "8.0.4", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
@@ -4449,7 +4527,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-string-truncated-width": { "node_modules/fast-string-truncated-width": {
@@ -4534,6 +4611,23 @@
"fontkitten": "^1.0.2" "fontkitten": "^1.0.2"
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkitten": { "node_modules/fontkitten": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
@@ -4889,6 +4983,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5222,6 +5322,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkifyjs": { "node_modules/linkifyjs": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
@@ -5692,6 +5811,12 @@
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-srcset": { "node_modules/parse-srcset": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -5712,6 +5837,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/piccolore": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -5736,6 +5875,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -6216,6 +6363,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/retext-smartypants": { "node_modules/retext-smartypants": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
@@ -6782,8 +6935,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "license": "0BSD"
"optional": true
}, },
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
@@ -6853,6 +7005,32 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+2
View File
@@ -28,6 +28,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1", "recharts": "^3.10.1",
@@ -38,6 +39,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"marked": "^18.0.7", "marked": "^18.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+4 -2
View File
@@ -8,8 +8,10 @@ if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1); process.exit(1);
} }
if (!['admin', 'superuser', 'user', 'campus'].includes(role)) { // Lista duplicata di proposito: questo file è JavaScript e non può importare da
console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus'); // src/lib/auth.ts. La fonte vera è ROLES in src/lib/auth.ts - se cambia là, va allineata qui.
if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer');
process.exit(1); process.exit(1);
} }
const path = process.env.DB_PATH ?? 'data/insanitylab.db'; const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Porta nel database i testi decisi dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
*
* Perché serve uno script e non basta il deploy: i testi mostrati dal sito vivono in
* `content_blocks`, e `resolveContent` legge **prima** il database e solo dopo il seed nel
* codice. Un tag già presente in tabella continua quindi a mostrare il vecchio valore anche
* dopo che il seed è stato aggiornato — in produzione sono 19 righe.
*
* Perché una tantum e non una migrazione all'avvio: una riscrittura a ogni restart
* rimetterebbe le mani su testi che il cliente può cambiare dal pannello. Qui si passa una
* volta, si guarda cosa è cambiato, e poi quei testi tornano suoi.
*
* Uso: node scripts/testi-17-agosto.mjs [percorso.db] [--dry-run]
*/
import Database from 'better-sqlite3';
/**
* "Small Group" diventa "Personalized Training Group", **con gli articoli giusti**.
*
* Una sostituzione meccanica produrrebbe "Lo Personalized Training Group" e "gli
* personalized training group": le due frasi che portano un articolo si correggono per
* intero, prima. Il resto è la sostituzione del nome, che resta capitalizzato come nome
* proprio anche dove prima era minuscolo ("in formula small group" → "in formula
* Personalized Training Group").
*
* Idempotente: al secondo giro non c'è più niente da sostituire.
*/
export function rinominaSmallGroup(value) {
return value
.replace(/\bLo Small Group\b/g, 'Il Personalized Training Group')
.replace(/\bgli small groups?\b/gi, 'i Personalized Training Group')
.replace(/\bsmall groups?\b/gi, 'Personalized Training Group');
}
/**
* I testi riscritti per intero, tag per tag: il cliente ne ha dettato il contenuto nuovo,
* quindi non c'è una parola da sostituire ma una frase da rimpiazzare.
*
* Sono solo i tag che in produzione esistono già con il valore vecchio. Quelli nuovi (la
* sezione «Check-up Avanzato», gli eventi, la pagina di scelta) non stanno in tabella e li
* serve il seed dal codice, senza bisogno di scriverli qui.
*/
export const TESTI = {
'service.profilazione.title': 'Check-up',
'service.profilazione.subtitle': 'Base e avanzato',
'service.profilazione.excerpt':
'Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — unanalisi completa e scientifica che guida le nostre scelte.',
'footer.services.4.label': 'Check-up',
'services.meta.description':
'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.',
};
/** I cambiamenti da fare, con il prima e il dopo. Non tocca niente. */
export function calcola(db) {
const righe = db.prepare('SELECT tag, value FROM content_blocks').all();
return righe
.map((r) => ({
tag: r.tag,
prima: r.value,
dopo: r.tag in TESTI ? TESTI[r.tag] : rinominaSmallGroup(r.value),
}))
.filter((c) => c.prima !== c.dopo);
}
export function applica(db, { dryRun = false } = {}) {
const cambi = calcola(db);
if (!dryRun && cambi.length) {
const upd = db.prepare(
"UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = 'testi-17-agosto' WHERE tag = ?",
);
db.transaction(() => { for (const c of cambi) upd.run(c.dopo, c.tag); })();
}
return cambi;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const path = args.find((a) => !a.startsWith('--')) ?? process.env.DB_PATH ?? 'data/insanitylab.db';
const db = new Database(path);
const cambi = applica(db, { dryRun });
for (const c of cambi) console.log(`${c.tag}\n - ${c.prima}\n + ${c.dopo}\n`);
console.log(`${cambi.length} contenuti ${dryRun ? 'da aggiornare (dry-run, niente scritto)' : 'aggiornati'} in ${path}`);
db.close();
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 554 KiB

+242
View File
@@ -0,0 +1,242 @@
---
// Popup a pieno schermo con gli orari delle class in Sala Piccola.
//
// I calendari sono ricostruiti dal dato (`data/calendari.ts`) e non sono le immagini ricevute
// dal cliente: si leggono da telefono, si scaricano in PDF, e per correggere una cella non
// serve rifare uno screenshot.
//
// Si vedono tutti gli orari non ancora scaduti, uno sotto l'altro: chi guarda ad agosto vuole
// sapere com'è settembre per intero. Quelli passati spariscono da soli.
//
// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina.
import { calendariDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Calendario, type Giorno } from '../data/calendari';
import { t } from '../lib/content';
const adesso = new Date();
const mostrati = calendariDaMostrare(adesso);
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
const avvisi = avvisiAttivi(adesso);
const dataDi = (iso: string) => new Date(iso).toLocaleDateString('it-IT', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
});
// Legenda per griglia: elencare discipline che lì non compaiono farebbe cercare lezioni
// che quel periodo non ha.
const disciplinePresenti = (cal: Calendario) =>
DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
---
{mostrati.length > 0 && (
<dialog class="cal-dlg" aria-label="Calendario delle class">
<div class="cal-dlg__box">
<header class="cal-dlg__top">
<div>
<p class="cal-dlg__sala">{mostrati[0].cal.sala}</p>
<h2 class="cal-dlg__titolo">Calendario delle class</h2>
</div>
<div class="cal-dlg__azioni">
<a class="cal-dlg__btn" href="/calendario/tutti.pdf" download>{t('calendario.pdf-label')}</a>
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
</div>
</header>
{avvisi.length > 0 && (
<ul class="cal-avvisi">
{avvisi.map((a) => (
<li><strong>{a.quando}</strong> — {a.testo}</li>
))}
</ul>
)}
<div class="cal-dlg__scroll">
{mostrati.map(({ cal, stato }) => (
<section class="cal-blocco">
<div class="cal-blocco__top">
<h3 class="cal-blocco__titolo">{cal.titolo}</h3>
<span class:list={['cal-tag', stato === 'in-corso' && 'is-ora']}>
{stato === 'in-corso' ? 'In vigore' : `${t('calendario.futuro-label')} ${dataDi(cal.from)}`}
</span>
</div>
<table class="cal-tab">
<thead>
<tr>
<th scope="col" class="cal-tab__ora-h">Ora</th>
{GIORNI.map((g) => (
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
{g}
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{cal.righe.map((r) => (
<tr class:list={[r.clou && 'is-clou']}>
<th scope="row" class="cal-tab__ora">{r.ora}</th>
{GIORNI.map((g: Giorno) => {
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
return (
<td class:list={['cal-tab__cella', chiuso && 'is-chiuso', lezione && `is-${lezione.toLowerCase()}`]}>
{chiuso ? <span class="cal-tab__chiuso-txt">{t('calendario.chiuso-label')}</span> : lezione ?? ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<ul class="cal-legenda">
{disciplinePresenti(cal).map((d) => (
<li class={`is-${d.toLowerCase()}`}>{d}</li>
))}
</ul>
</section>
))}
</div>
<footer class="cal-dlg__giu">
<p class="cal-dlg__nota">{t('calendario.nota')}</p>
</footer>
</div>
</dialog>
)}
<script>
// Apertura/chiusura del popup. Nessuna libreria: <dialog> nativo dà da solo il fondale, il
// focus trap e la chiusura con Esc.
const dlg = document.querySelector<HTMLDialogElement>('.cal-dlg');
if (dlg) {
document.querySelectorAll<HTMLElement>('[data-apri-calendario]').forEach((el) => {
el.addEventListener('click', (e) => { e.preventDefault(); dlg.showModal(); });
});
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
// Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro.
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
// "Scarica in PDF" è un link a /calendario/tutti.pdf, cioè un file vero generato dal
// server, un orario per pagina: si salva sul telefono e si gira su WhatsApp. Il CSS di
// stampa più sotto resta per chi preme Ctrl+P col popup aperto.
}
</script>
<style is:global>
.cal-dlg {
width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0;
border: 0; background: transparent; color: var(--c-text);
}
.cal-dlg::backdrop { background: rgba(43,43,43,.75); }
.cal-dlg__box {
display: flex; flex-direction: column; height: 100%; background: var(--c-bg);
padding: 28px clamp(16px, 4vw, 48px) 24px;
}
.cal-dlg__top { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
.cal-dlg__sala {
margin: 0; font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
}
.cal-dlg__titolo { margin: 6px 0 0; font-size: 1.5rem; }
.cal-dlg__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.cal-dlg__btn {
display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0;
cursor: pointer; padding: 13px 22px; background: var(--c-accent); color: #fff;
transition: background .2s;
}
.cal-dlg__btn:hover { background: var(--c-accent-dark); }
.cal-dlg__chiudi {
width: 42px; height: 42px; border: 1px solid #ddd; background: #fff; cursor: pointer;
font-size: 1.4rem; line-height: 1; color: var(--c-heading);
}
.cal-dlg__chiudi:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
/* Giornate fuori griglia: stanno sopra le tabelle perché sono l'eccezione a ciò che le
tabelle dicono, e sotto passerebbero per una postilla. */
.cal-avvisi {
list-style: none; margin: 18px 0 0; padding: 12px 16px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); display: flex; flex-direction: column; gap: 4px;
}
.cal-avvisi li { font-size: .84rem; color: var(--c-text); }
.cal-avvisi strong { color: var(--c-heading); }
.cal-dlg__scroll { flex: 1; overflow: auto; margin: 18px 0 0; }
.cal-blocco + .cal-blocco { margin-top: 34px; padding-top: 26px; border-top: 1px solid #e4e0d9; }
.cal-blocco__top { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
.cal-blocco__titolo { margin: 0; font-size: 1.05rem; }
.cal-tag {
font-family: var(--font-heading); font-size: .62rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; padding: 4px 9px; background: var(--c-bg-alt); color: var(--c-text-light);
}
/* L'orario valido adesso si distingue a colpo d'occhio da quello che comincerà dopo: senza,
due griglie affiancate si leggono come alternative fra cui scegliere. */
.cal-tag.is-ora { background: var(--c-accent); color: #fff; }
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
.cal-tab { border-collapse: collapse; width: 100%; min-width: 720px; }
.cal-tab th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
.cal-tab thead th {
background: var(--c-bg-alt);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; color: var(--c-heading);
}
.cal-tab__nota {
display: block; font-size: .62rem; letter-spacing: .06em; text-transform: none;
color: var(--c-text-light); font-weight: 400;
}
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
.cal-tab__ora {
background: var(--c-bg);
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
white-space: nowrap;
}
.cal-tab tr.is-clou .cal-tab__ora { background: #f7f2e6; }
.cal-tab__cella { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; color: var(--c-heading); }
/* Ogni disciplina ha un fondo tenue e una barra a sinistra: la barra si vede anche quando il
browser stampa senza sfondi. */
.cal-tab__cella.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.cal-tab__cella.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-tab__cella.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-tab__cella.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-tab__cella.is-chiuso { background: repeating-linear-gradient(135deg, #f5f3f0, #f5f3f0 6px, #ece8e2 6px, #ece8e2 12px); }
.cal-tab__chiuso-txt { font-size: .62rem; letter-spacing: .12em; color: var(--c-text-light); }
.cal-tab thead th.is-chiuso { color: var(--c-text-light); }
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 12px 0 0; padding: 0; }
.cal-legenda li {
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
color: var(--c-heading);
}
.cal-legenda li.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.cal-legenda li.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-legenda li.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-legenda li.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-dlg__giu { padding-top: 16px; }
.cal-dlg__nota { margin: 0; font-size: .8rem; color: var(--c-text-light); }
/* Stampa dalla pagina (Ctrl+P col popup aperto): resta solo il calendario, un orario per
foglio. Il bottone scarica invece il PDF generato dal server, che è il documento buono da
girare. `visibility` invece di `display` perché il dialog è annidato nel corpo della
pagina e nascondere gli antenati nasconderebbe anche lui. */
@media print {
@page { size: A4 landscape; margin: 10mm; }
body { visibility: hidden; }
.cal-dlg, .cal-dlg * { visibility: visible; }
.cal-dlg { position: absolute; inset: 0; height: auto; }
.cal-dlg::backdrop { background: transparent; }
.cal-dlg__box { padding: 0; height: auto; }
.cal-dlg__azioni { display: none; }
.cal-dlg__scroll { overflow: visible; }
.cal-blocco { break-inside: avoid; }
.cal-blocco + .cal-blocco { break-before: page; border-top: 0; padding-top: 0; }
.cal-tab { min-width: 0; }
.cal-tab th, .cal-tab td { padding: 5px 6px; }
.cal-tab__cella { font-size: .62rem; }
}
@media (max-width: 600px) {
.cal-dlg__box { padding: 20px 14px 18px; }
.cal-dlg__titolo { font-size: 1.2rem; }
.cal-dlg__btn { padding: 11px 16px; letter-spacing: .12em; }
}
</style>
+10 -1
View File
@@ -42,5 +42,14 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; } .method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; } .method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
.method__step p { font-size: .85rem; color: var(--c-text-light); } .method__step p { font-size: .85rem; color: var(--c-text-light); }
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } } @media (max-width: 767px) {
/* una colonna con lo step in orizzontale: a due colonne i titoli lunghi
si spezzavano su quattro righe. */
.method__row { grid-template-columns: 1fr; gap: 28px; text-align: left; }
.method__step { display: grid; grid-template-columns: 104px 1fr; column-gap: 18px; align-items: start; }
.method__circle { width: 104px; height: 104px; margin: 0; grid-row: 1 / span 2; }
.method__circle span { font-size: 1.8rem; top: -4px; left: -4px; }
.method__step h3 { margin-top: 4px; }
.method__note { text-align: left; margin-left: 0; max-width: none; }
}
</style> </style>
+5
View File
@@ -16,4 +16,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
.pstrip { background: var(--c-accent); padding-block: 40px; } .pstrip { background: var(--c-accent); padding-block: 40px; }
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; } .pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
.pstrip img { height: 44px; width: auto; opacity: .9; } .pstrip img { height: 44px; width: auto; opacity: .9; }
@media (max-width: 767px) {
.pstrip { padding-block: 28px; }
.pstrip__in { justify-content: center; gap: 20px 28px; }
.pstrip img { height: 30px; }
}
</style> </style>
+12 -2
View File
@@ -4,6 +4,10 @@ import { trainings } from '../../data/trainings';
import T from '../content/T.astro'; import T from '../content/T.astro';
import TImg from '../content/TImg.astro'; import TImg from '../content/TImg.astro';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
// `items` permette di mostrare solo una parte dei training (le viste di /training):
// senza, la fascia di card resterebbe l'elenco intero sopra ai blocchi filtrati.
interface Props { items?: readonly (typeof trainings)[number][] }
const { items = trainings } = Astro.props;
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
const tones = ['taupe', 'tan', 'dark', 'charcoal']; const tones = ['taupe', 'tan', 'dark', 'charcoal'];
@@ -11,7 +15,7 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1; const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
--- ---
<section class="tcards"> <section class="tcards">
{trainings.map((tr, i) => { {items.map((tr, i) => {
const img = imgOf(tr.image); const img = imgOf(tr.image);
const card = ( const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}> <div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
@@ -46,5 +50,11 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
.tcards__photo { overflow: hidden; } .tcards__photo { overflow: hidden; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; } .tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
.tcards__photo:hover .tcards__img { transform: scale(1.06); } .tcards__photo:hover .tcards__img { transform: scale(1.06); }
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 767px) {
.tcards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tcards__cell { padding: 22px 16px; }
.tcards__cell h3 { font-size: 1rem; letter-spacing: .08em; }
.tcards__cell p { font-size: .74rem; }
.tcards__link { font-size: .62rem; letter-spacing: .14em; margin-top: 12px; }
}
</style> </style>
+188
View File
@@ -0,0 +1,188 @@
// Calendari delle class in Sala Piccola.
//
// Trascritti dai due fogli forniti dal cliente il 10/08/2026 (immagini su Wasabi, cartella
// del progetto). Sono dato e non immagini di proposito: così la griglia si legge da telefono,
// si stampa in PDF e si corregge una cella senza rifare uno screenshot.
//
// Ogni calendario porta la sua finestra di validità: la pagina mostra quello in vigore, e
// quando nessuno lo è ancora mostra il prossimo dichiarando da quando entra. Il passaggio da
// un orario all'altro avviene da solo, senza deploy — stesso principio delle edizioni della
// pagina promo (`data/promo-edizioni.ts`).
export const GIORNI = ['LUN', 'MAR', 'MER', 'GIO', 'VEN', 'SAB', 'DOM'] as const;
export type Giorno = (typeof GIORNI)[number];
/** Le discipline della sala: il colore di ognuna è definito nel componente del calendario. */
export const DISCIPLINE = ['REFORMER', 'PILATES', 'YOGA', 'MOBILITY'] as const;
export type Disciplina = (typeof DISCIPLINE)[number];
export interface RigaCalendario {
ora: string;
/** Fascia messa in risalto nel foglio del cliente (le righe gialle). */
clou?: boolean;
celle: Partial<Record<Giorno, Disciplina>>;
}
export interface Calendario {
id: string;
/** Come lo chiama il cliente: è anche l'etichetta mostrata in pagina. */
titolo: string;
sala: string;
/** Validità, ISO con offset esplicito (il server gira in UTC). `to: null` = a tempo indeterminato. */
from: string;
to: string | null;
/** Giorni in cui la sala è chiusa: colonna barrata, non vuota — vuoto e chiuso non sono lo stesso. */
chiusi: Giorno[];
/** Precisazione su una colonna, es. il sabato aperto una volta sola. */
noteGiorno?: Partial<Record<Giorno, string>>;
righe: RigaCalendario[];
}
export const calendari: Calendario[] = [
{
id: 'settembre-1-18',
titolo: "Dall'1 al 18 settembre",
sala: 'Sala piccola',
from: '2026-09-01T00:00:00+02:00',
to: '2026-09-18T23:59:59+02:00',
chiusi: ['DOM'],
noteGiorno: { SAB: 'solo il 12 settembre' },
righe: [
{ ora: '6.15', celle: {} },
{ ora: '7.00', clou: true, celle: { MAR: 'YOGA' } },
{ ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER' } },
{ ora: '8.30', celle: { MER: 'REFORMER', VEN: 'MOBILITY' } },
{ ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } },
{ ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } },
{ ora: '10.45', celle: { SAB: 'REFORMER' } },
{ ora: '11.30', celle: { SAB: 'REFORMER' } },
{ ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } },
{ ora: '13.45', celle: {} },
{ ora: '14.30', celle: { MAR: 'REFORMER' } },
{ ora: '15.15', celle: { MAR: 'MOBILITY' } },
{ ora: '16.00', celle: {} },
{ ora: '16.45', celle: { MAR: 'REFORMER' } },
{ ora: '17.30', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '18.15', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.00', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.45', clou: true, celle: { MAR: 'REFORMER', GIO: 'PILATES' } },
],
},
{
id: 'settembre-21-in-poi',
titolo: 'Dal 21 settembre in poi',
sala: 'Sala piccola',
from: '2026-09-21T00:00:00+02:00',
to: null,
chiusi: ['DOM'],
righe: [
{ ora: '6.15', celle: { MER: 'MOBILITY' } },
{ ora: '7.00', clou: true, celle: { MAR: 'YOGA', MER: 'REFORMER' } },
{ ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER', VEN: 'PILATES' } },
{ ora: '8.30', celle: { MAR: 'REFORMER', MER: 'REFORMER', VEN: 'MOBILITY' } },
{ ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } },
{ ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } },
{ ora: '10.45', celle: { SAB: 'REFORMER' } },
{ ora: '11.30', celle: { GIO: 'YOGA', SAB: 'REFORMER' } },
{ ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } },
{ ora: '13.45', celle: {} },
{ ora: '14.30', celle: { MAR: 'REFORMER' } },
{ ora: '15.15', celle: { MAR: 'MOBILITY' } },
{ ora: '16.00', celle: {} },
{ ora: '16.45', celle: { MAR: 'REFORMER' } },
// Mercoledì 17.30 e 18.15 sono vuote: nel foglio ricevuto le due celle erano coperte
// dal riquadro "Opzioni Incolla" di Excel, e il 17/08/2026 Adriano ha confermato che
// lì non c'è niente. Il vuoto qui è una risposta, non una lettura mancata.
{ ora: '17.30', clou: true, celle: { LUN: 'PILATES', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '18.15', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.00', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.45', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'PILATES' } },
],
},
];
/**
* Giornate che non stanno in nessuna griglia: eventi e chiusure.
*
* Il 19 e il 20 settembre non compaiono in nessuno dei due fogli, e la ragione non è una
* dimenticanza — il sabato c'è la presentazione della nuova apertura e la domenica si sta
* chiusi (detto da Adriano il 17/08/2026). Scritto qui, quel vuoto smette di sembrare un
* errore di trascrizione e diventa un'informazione per chi legge gli orari.
*/
export interface AvvisoCalendario {
id: string;
/** `null` = visibile da subito. */
from: string | null;
/** Ultimo istante in cui ha senso mostrarlo: dopo, sparisce da solo. */
to: string;
quando: string;
testo: string;
}
export const avvisi: AvvisoCalendario[] = [
{
id: 'presentazione-19-settembre',
from: null,
to: '2026-09-19T23:59:59+02:00',
quando: 'Sabato 19 settembre',
testo: 'Presentazione della nuova apertura: nessuna class in programma.',
},
{
id: 'chiusura-20-settembre',
from: null,
to: '2026-09-20T23:59:59+02:00',
quando: 'Domenica 20 settembre',
testo: 'Chiuso.',
},
];
/** Gli avvisi ancora validi, in ordine di scadenza: i più imminenti per primi. */
export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): AvvisoCalendario[] {
const t = now.getTime();
return list
.filter((a) => (a.from === null || t >= Date.parse(a.from)) && t <= Date.parse(a.to))
.sort((x, y) => Date.parse(x.to) - Date.parse(y.to));
}
/**
* Le schede servizio da cui si apre il calendario della sala.
*
* Sono le due class che si fanno davvero: la Performance Class è uscita di scena il
* 17/08/2026 (il cliente non la eroga più) e con lei la sua scheda.
*/
export const PAGINE_CON_CALENDARIO = ['holistic-class', 'reformer-class'];
/**
* Le card della griglia /services che portano il bottone del calendario.
*
* È un elenco a parte, e non un doppione del precedente: nella griglia la card si chiama
* «Class» ma ha slug `performance-class`, e da lì si va alla vista Training con Holistic e
* Reformer. Il calendario è lo stesso, il punto da cui si apre no.
*/
export const CARD_CON_CALENDARIO = ['performance-class'];
export interface CalendarioMostrato {
cal: Calendario;
stato: 'in-corso' | 'futuro';
}
/**
* I calendari da mostrare: quello in vigore e quelli che verranno, in ordine di partenza.
*
* Si vedono **tutti insieme**, non solo quello del momento: chi guarda a fine agosto vuole
* sapere com'è settembre per intero, e vedere una griglia sola gli farebbe credere che sia
* quella per sempre. Ognuna porta il proprio stato, così l'orario in vigore adesso non si
* confonde con quello che comincerà dopo.
*
* Gli orari **scaduti spariscono da soli**: dal 21 settembre resta il solo foglio valido, e
* nessuno rischia di leggere le lezioni di un periodo finito.
*/
export function calendariDaMostrare(now: Date, list: Calendario[] = calendari): CalendarioMostrato[] {
const t = now.getTime();
return list
.filter((c) => c.to === null || t <= Date.parse(c.to))
.sort((a, b) => Date.parse(a.from) - Date.parse(b.from))
.map((cal) => ({ cal, stato: t >= Date.parse(cal.from) ? 'in-corso' : 'futuro' }));
}
+131 -10
View File
@@ -165,7 +165,7 @@ export const contentSeed: SeedEntry[] = [
// --- About: meta, hero --- // --- About: meta, hero ---
T('about.meta.title', 'Insanitylab', []), T('about.meta.title', 'Insanitylab', []),
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []), T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
T('about.hero.title', 'Insanitylab', []), T('about.hero.title', 'About us', []),
// --- About: mission --- // --- About: mission ---
T('about.mission.title', 'Mission'), T('about.mission.title', 'Mission'),
@@ -222,7 +222,7 @@ export const contentSeed: SeedEntry[] = [
T('about.storylab.title', 'Storylab'), T('about.storylab.title', 'Storylab'),
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'), T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
T('about.timeline.label', 'INSANITYLAB 2021-2024', []), T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."), T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come i Personalized Training Group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []), T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []), T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
@@ -231,7 +231,7 @@ export const contentSeed: SeedEntry[] = [
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Services: lista (/services) --- // --- Services: lista (/services) ---
T('services.meta.title', 'Servizi', []), T('services.meta.title', 'Servizi', []),
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []), T('services.meta.description', 'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.', []),
T('services.hero.title', 'Servizi', []), T('services.hero.title', 'Servizi', []),
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."), T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('services.card.more-label', 'Per saperne di più →', []), T('services.card.more-label', 'Per saperne di più →', []),
@@ -247,6 +247,13 @@ export const contentSeed: SeedEntry[] = [
T(`service.${p.slug}.faq.${i + 1}.q`, item.q), T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
T(`service.${p.slug}.faq.${i + 1}.a`, item.a), T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
]), ]),
...(p.extra
? [
T(`service.${p.slug}.extra.title`, p.extra.title),
T(`service.${p.slug}.extra.body`, p.extra.body),
...p.extra.items.map((it, i) => T(`service.${p.slug}.extra.item.${i + 1}`, it)),
]
: []),
I(`service.${p.slug}.image`), I(`service.${p.slug}.image`),
]), ]),
@@ -277,8 +284,9 @@ export const contentSeed: SeedEntry[] = [
// --- Training: meta, hero, intro, trial --- // --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []), T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []), T('training.meta.description', 'One to One, Personalized Training Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
T('training.hero.title', 'training', []), T('training.hero.title', 'training', []),
T('training.tutti.cta-label', 'Vedi tutti i training', []),
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'), T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
// --- Contact --- // --- Contact ---
@@ -293,6 +301,29 @@ export const contentSeed: SeedEntry[] = [
T('contact.info.email-label', 'E-mail'), T('contact.info.email-label', 'E-mail'),
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'), T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
// --- Promo ed eventi: scelta (/promo-eventi) ---
T('promo-eventi.meta.title', 'Promo ed eventi', []),
T('promo-eventi.meta.description', 'Le promozioni in corso e gli eventi di InsanityLab, a partire dallinaugurazione del nuovo spazio.', []),
T('promo-eventi.hero.title', 'Promo ed eventi', []),
T('promo-eventi.intro', 'Scegli cosa vuoi vedere: le promozioni attive in questo momento oppure gli eventi in programma al centro.'),
T('promo-eventi.promo.label', 'Promo'),
T('promo-eventi.promo.desc', 'I pacchetti e le offerte del periodo, con prezzi e condizioni.'),
T('promo-eventi.promo.nessuna', 'Nessuna promozione in corso', []),
T('promo-eventi.promo.cta', 'Vedi le promo', []),
T('promo-eventi.eventi.label', 'Eventi'),
T('promo-eventi.eventi.desc', 'Le date da segnare: aperture, presentazioni e appuntamenti in sede.'),
T('promo-eventi.eventi.cta', 'Vedi gli eventi', []),
// --- Eventi (/promo-eventi/eventi) ---
T('eventi.meta.title', 'Eventi', []),
T('eventi.meta.description', 'Gli eventi InsanityLab: inaugurazione del nuovo spazio il 19 settembre 2026, con aperitivo e dj set.', []),
T('eventi.hero.title', 'Eventi', []),
T('eventi.inaugurazione.title', 'Inaugurazione'),
T('eventi.inaugurazione.date', '19 settembre 2026'),
T('eventi.inaugurazione.body', 'Apriamo le porte del nuovo spazio con un aperitivo e dj set. Sarà loccasione per conoscere lo staff, visitare la struttura e prenotare una prova gratuita dei nostri servizi. Il programma completo della giornata sarà pubblicato a breve.'),
T('eventi.cta.label', 'Scrivici su WhatsApp', []),
T('eventi.torna.label', '← Torna a promo ed eventi', []),
// --- 404 --- // --- 404 ---
T('notfound.meta.title', 'Pagina non trovata', []), T('notfound.meta.title', 'Pagina non trovata', []),
T('notfound.eyebrow', 'Errore 404', []), T('notfound.eyebrow', 'Errore 404', []),
@@ -327,10 +358,10 @@ export const contentSeed: SeedEntry[] = [
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'), T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'), T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'), T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 2 — Small Group (in evidenza) // Card 2 — Personalized Training Group (in evidenza)
T('promo.card.2.badge', 'Più scelto', []), T('promo.card.2.badge', 'Più scelto', []),
T('promo.card.2.eyebrow', 'Personal training', []), T('promo.card.2.eyebrow', 'Personal training', []),
T('promo.card.2.title', 'Small Group'), T('promo.card.2.title', 'Personalized Training Group'),
T('promo.card.2.price', '125 €', []), T('promo.card.2.price', '125 €', []),
T('promo.card.2.price-old', '220 €', []), T('promo.card.2.price-old', '220 €', []),
T('promo.card.2.unit', '5 incontri', []), T('promo.card.2.unit', '5 incontri', []),
@@ -369,12 +400,16 @@ export const contentSeed: SeedEntry[] = [
T('promo.validity-note', 'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []), T('promo.validity-note', 'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
// FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp // FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'), T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'), // La FAQ 0 è mostrata per prima: numerata così per non rinumerare le esistenti (i tag già
H('promo.faq.1.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare le singole sessioni in base agli orari disponibili. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'), // presenti nei DB non verrebbero riscritti dall'INSERT OR IGNORE del seed).
T('promo.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
H('promo.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona quello che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
H('promo.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'), T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'),
H('promo.faq.2.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell\'orario che desideri. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'), H('promo.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'), T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'), H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'), T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'), H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('promo.faq.5.q', 'Il pagamento è sicuro?'), T('promo.faq.5.q', 'Il pagamento è sicuro?'),
@@ -382,6 +417,92 @@ export const contentSeed: SeedEntry[] = [
T('promo.faq.6.q', 'Posso chiedere un rimborso?'), T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'), H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
// --- Calendario delle class (popup dalle pagine servizio) ---
// La griglia è dato (`data/calendari.ts`); qui ci sono solo le etichette attorno.
T('calendario.link-label', 'Calendario', []),
T('calendario.pdf-label', 'Scarica in PDF', []),
T('calendario.chiudi-label', 'Chiudi', []),
T('calendario.futuro-label', 'In vigore dal', []),
T('calendario.chiuso-label', 'Chiuso', []),
T('calendario.nota', 'Gli orari possono subire variazioni. Le lezioni si prenotano dall\'app Bsport.'),
// --- Pagina promo, stato "nessuna promozione in corso" ---
// Fra un'edizione e la successiva la pagina resta raggiungibile (è nel menu e nei link già
// girati ai clienti): mostra questo invece dei prezzi di una promozione finita.
T('promo.chiusa.title', 'Nessuna promozione in corso'),
H('promo.chiusa.desc', 'Le offerte di apertura sono terminate.<br>Scrivici su WhatsApp: ti raccontiamo i percorsi attivi e troviamo quello giusto per te.'),
T('promo.chiusa.cta', 'Scrivici su WhatsApp', []),
// --- Opening Bundles (edizione `bundle-apertura`, dal 20 agosto al 4 settembre 2026) ---
// Testi dal volantino fornito dal cliente il 13/08. La struttura delle card (domanda,
// descrizione, riquadro date) è dichiarata in `data/promo-edizioni.ts`.
T('bundle.hero.badge', 'New Opening · Offerta esclusiva'),
T('bundle.hero.title1', 'Opening Bundles.'),
T('bundle.hero.accent', 'Non è uno sconto.'),
T('bundle.hero.title2', 'È un accesso privilegiato.'),
H('bundle.hero.desc', 'Tre percorsi costruiti su misura, disponibili solo per la nuova apertura.<br>Acquistabili fino al 4 settembre, utilizzabili dal 1° settembre al 1° dicembre.'),
T('bundle.hero.cta', 'Scopri i tre percorsi', []),
T('bundle.buy-label', 'Acquista', []),
T('bundle.card.1.eyebrow', 'Personal Training', []),
T('bundle.card.1.title', 'Intensive'),
T('bundle.card.1.question', 'Ti alleni ma non vedi risultati?'),
T('bundle.card.1.price', '450 €', []),
T('bundle.card.1.price-old', '770 €', []),
T('bundle.card.1.feature.1', 'Profilazione Avanzata'),
T('bundle.card.1.feature.2', '8 sessioni PT 1:1'),
T('bundle.card.1.feature.3', '4 Community Class'),
T('bundle.card.1.desc', 'Un check-up avanzato per conoscere davvero il tuo corpo — poi 2 sessioni di Personal Training e una Community Class a settimana. Un mese per capire di cosa sei capace.'),
T('bundle.card.1.note-label', 'Periodo di utilizzo', []),
H('bundle.card.1.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.2.eyebrow', 'Pilates & Holistic', []),
T('bundle.card.2.title', 'Restore'),
T('bundle.card.2.question', 'Hai dolori, rigidità o ti senti bloccato?'),
T('bundle.card.2.price', '170 €', []),
T('bundle.card.2.price-old', '220 €', []),
T('bundle.card.2.feature.1', '4 Pilates Reformer'),
T('bundle.card.2.feature.2', '4 Holistic Class'),
T('bundle.card.2.desc', 'Se hai dolori, rigidità, o semplicemente vuoi prenderti cura di te con dolcezza. Reformer e Holistic Class lavorano insieme per rimettere il corpo in equilibrio — mobilità, core, respiro. Senza forzare, senza improvvisare.'),
T('bundle.card.2.note-label', 'Periodo di utilizzo', []),
H('bundle.card.2.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.3.eyebrow', 'Training & Wellness', []),
T('bundle.card.3.title', 'Recharge'),
T('bundle.card.3.question', 'Sei stanco e svuotato anche dopo una notte di sonno?'),
T('bundle.card.3.price', '250 €', []),
T('bundle.card.3.price-old', '310 €', []),
T('bundle.card.3.feature.1', 'Analisi HRV'),
T('bundle.card.3.feature.2', '4 Pilates Reformer'),
T('bundle.card.3.feature.3', '3 Training Group'),
T('bundle.card.3.desc', 'Stanco, svuotato, fuori ritmo. Attraverso l\'analisi HRV misuriamo il tuo livello di stress e il tuo stato energetico reale — poi Reformer e Training Group per farti ripartire davvero.'),
T('bundle.card.3.note-label', 'Periodo di utilizzo', []),
H('bundle.card.3.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
// La deadline è la stessa fine dell'edizione in `promo-edizioni.ts`: un test lo verifica,
// perché un countdown che scade a un'ora diversa dallo scambio fa contraddire il sito.
T('bundle.countdown.label', 'I bundle scadono tra', []),
T('bundle.countdown.deadline', '2026-09-04T23:59:59+02:00', []),
T('bundle.countdown.unit.days', 'giorni', []),
T('bundle.countdown.unit.hours', 'ore', []),
T('bundle.countdown.unit.minutes', 'minuti', []),
T('bundle.countdown.unit.seconds', 'secondi', []),
T('bundle.validity-note', 'Acquistabili fino al 4 settembre 2026 · Utilizzabili dal 1° settembre al 1° dicembre 2026'),
T('bundle.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('bundle.faq.0.q', 'Come faccio ad acquistare un bundle?'),
H('bundle.faq.0.a', 'Clicca sul bundle che desideri e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona il bundle che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('bundle.faq.1.q', 'Entro quando posso acquistarlo, e entro quando devo usarlo?'),
H('bundle.faq.1.a', 'I bundle si acquistano <span class="hl">fino al 4 settembre 2026</span> e si utilizzano <span class="hl">dal 1° settembre al 1° dicembre 2026</span>. Dopo l\'acquisto scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> per fissare il primo incontro: verrai ricontattato subito tramite messaggio o chiamata.'),
T('bundle.faq.2.q', 'Posso scambiare gli incontri fra un bundle e l\'altro?'),
H('bundle.faq.2.a', 'No: ogni bundle è un percorso costruito su un obiettivo preciso, e le attività che contiene lavorano insieme. Se ti interessano servizi di bundle diversi puoi acquistarne più di uno, oppure scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> e ti aiutiamo a scegliere.'),
T('bundle.faq.3.q', 'Posso regalare un bundle a qualcun altro?'),
H('bundle.faq.3.a', 'Sì. Puoi acquistare il bundle a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('bundle.faq.4.q', 'Il pagamento è sicuro?'),
H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
]; ];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+94
View File
@@ -0,0 +1,94 @@
// Edizioni della pagina "Promo ed eventi".
//
// Una promozione ha una fine, e la pagina che la mostra deve saperla: qui sta scritto quale
// set di contenuti è in vetrina e in quale finestra di tempo. La pagina non decide niente,
// legge l'edizione attiva nell'istante della richiesta — così il passaggio da una promozione
// alla successiva avviene da solo, alla mezzanotte giusta, senza deploy e senza che qualcuno
// debba ricordarsene.
//
// I testi restano nei tag (`<prefix>.*`, modificabili senza deploy). Qui c'è solo la
// struttura: quante card, quali campi ha ciascuna, quali FAQ. Aggiungere un'edizione =
// una voce in questo elenco + i suoi tag nel seed; i test in `tests/promo-edizioni.test.ts`
// verificano che non manchi niente e che le finestre non si sovrappongano né lascino buchi.
export interface CardSpec {
/** Numero della card: forma i tag `<prefix>.card.<id>.*`. */
id: number;
/** Quante voci ha l'elenco puntato (`feature.1` … `feature.N`). */
features: number;
/** Card in evidenza: cornice diversa e badge `card.<id>.badge`. */
featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */
desc?: boolean;
/** Riquadro in fondo col periodo di acquisto e di utilizzo. */
note?: boolean;
}
export interface Edizione {
id: string;
/** Prefisso dei tag di contenuto di questa edizione. */
prefix: string;
/** Inizio e fine validità, ISO **con offset esplicito**: il server gira in UTC e senza
* offset lo scambio avverrebbe due ore prima di quanto scritto sul sito. */
from: string;
to: string;
cards: CardSpec[];
/** Numeri delle FAQ, nell'ordine di comparsa. */
faq: number[];
/** Sezioni "launch" e "why5" fra l'hero e le card. */
launch?: boolean;
}
export const edizioni: Edizione[] = [
{
// Promo di lancio: 4 pacchetti da 5 incontri, online dal 1° agosto.
id: 'agosto-2026',
prefix: 'promo',
from: '2026-08-01T00:00:00+02:00',
to: '2026-08-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true, featured: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
],
faq: [0, 1, 2, 3, 4, 5, 6],
},
{
// Opening Bundles: subentra alla promo agosto il giorno dopo la sua scadenza, si
// acquistano fino al 4 settembre e si usano dal 1° settembre al 1° dicembre.
id: 'bundle-apertura',
prefix: 'bundle',
from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00',
cards: [
{ id: 1, features: 3, question: true, desc: true, note: true },
{ id: 2, features: 2, question: true, desc: true, note: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli
// dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true },
],
faq: [0, 1, 2, 3, 4],
},
];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
export const tagEdizione = (e: Edizione, suffisso: string): string => `${e.prefix}.${suffisso}`;
/**
* L'edizione valida in un istante, o `null` se non ce n'è nessuna (e allora la pagina mostra
* lo stato "nessuna promozione in corso" invece di prezzi scaduti).
*
* Il confronto è fra istanti assoluti: `Date.parse` interpreta l'offset scritto nel dato, e
* `now` arriva dall'orologio del server. Nessuna delle due parti dipende dal fuso della
* macchina, che in produzione è UTC e in sviluppo no.
*/
export function edizioneAttiva(now: Date, list: Edizione[] = edizioni): Edizione | null {
const t = now.getTime();
return list.find((e) => t >= Date.parse(e.from) && t <= Date.parse(e.to)) ?? null;
}
+39 -9
View File
@@ -13,12 +13,24 @@ export interface Service {
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services /** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */ * (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
grid?: boolean; grid?: boolean;
/** Dove porta la card (foto e "Per saperne di più"). Assente = la scheda del servizio.
* Serve per le card che instradano a una vista di /training invece che a una scheda. */
href?: string;
/** false = nessuna pagina dettaglio: /services/<slug> risponde 404.
* La voce resta in griglia con il proprio `href`. */
page?: boolean;
/** Sezione in coda alla scheda, oltre alla descrizione principale.
* Tag: service.<slug>.extra.title | .body | .item.N */
extra?: { title: string; body: string; items: string[] };
} }
export const services: Service[] = [ export const services: Service[] = [
{ {
slug: "one-to-one", slug: "one-to-one",
title: "One to One", title: "One to One",
// La card di /services porta alla vista Training con One to One, Personalized Training
// Group e Rehab: è come il cliente vuole instradare chi cerca l'allenamento seguito.
href: "/training/one-to-one",
heroTitle: "Personal Training", heroTitle: "Personal Training",
subtitle: "Allenamento con un trainer dedicato", subtitle: "Allenamento con un trainer dedicato",
excerpt: "Lallenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.", excerpt: "Lallenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
@@ -33,13 +45,13 @@ export const services: Service[] = [
}, },
{ {
slug: "small-group", slug: "small-group",
title: "Small Group", title: "Personalized Training Group",
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato", subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.", excerpt: "Il Personalized Training Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.", longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
features: [], features: [],
faq: [ faq: [
{ q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." }, { q: "Quante persone ci sono in un Personalized Training Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." }, { q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." }, { q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
], ],
@@ -47,8 +59,14 @@ export const services: Service[] = [
grid: false, grid: false,
}, },
{ {
// La Performance Class non si fa più (cliente, 17/08/2026): la scheda che la spiegava
// sparisce (`page: false` → /services/performance-class risponde 404) mentre la card
// "Class" resta in griglia e porta alla vista Training con Holistic e Reformer.
// Il pricing è caduto con la scheda: era l'unico servizio ad averlo.
slug: "performance-class", slug: "performance-class",
title: "Class", title: "Class",
href: "/training/class",
page: false,
subtitle: "Gruppo max 8 persone", subtitle: "Gruppo max 8 persone",
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.", excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso allinterno di un piano più ampio.", longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso allinterno di un piano più ampio.",
@@ -61,7 +79,7 @@ export const services: Service[] = [
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." }, { q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
{ q: "Qual è lintensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti." }, { q: "Qual è lintensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti." },
], ],
hasPricing: true, hasPricing: false,
}, },
{ {
slug: "coaching", slug: "coaching",
@@ -81,16 +99,28 @@ export const services: Service[] = [
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training). // NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente. // Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
slug: "profilazione", slug: "profilazione",
title: "Profilazione", title: "Check-up",
subtitle: "Check-up e valutazione fisica", subtitle: "Base e avanzato",
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.", excerpt: "Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — unanalisi completa e scientifica che guida le nostre scelte.",
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.", longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"], features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
faq: [ faq: [
{ q: "Quando si fa la profilazione?", a: "Allinizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." }, { q: "Quando si fa la profilazione?", a: "Allinizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
], ],
hasPricing: false, hasPricing: false,
// Il testo della scheda descrive il Check-up Base; l'Avanzato è la parte aggiunta il
// 17/08/2026 su richiesta del cliente, con la Wellness Tower e i sensori HRV.
extra: {
title: "Check-up Avanzato",
body: "Con la Wellness Tower, tecnologia di ultima generazione, andiamo oltre: bioimpedenza, scansione posturale 3D e spirometria restituiscono un quadro completo e oggettivo del tuo corpo. A completare il quadro, misuriamo anche lHRV attraverso sensori dedicati — un dato chiave per capire come il tuo corpo recupera e si adatta al carico di lavoro. Ogni dato raccolto guida scelte più precise nella costruzione del programma e nel monitoraggio dei progressi.",
items: [
"Composizione corporea di precisione (massa grassa, magra, metabolismo basale)",
"Scansione posturale 3D e analisi di simmetria",
"Spirometria e parametri cardiovascolari",
"Analisi HRV tramite sensori dedicati",
"Report immediato, confrontabile nel tempo",
],
},
}, },
{ {
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training). // NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
+4 -4
View File
@@ -21,17 +21,17 @@ export const site = {
], ],
footerTraining: [ footerTraining: [
{ label: "One to one", href: "/training#one-to-one" }, { label: "One to one", href: "/training#one-to-one" },
{ label: "Small group", href: "/training#small-groups" }, { label: "Personalized Training Group", href: "/training#small-groups" },
{ label: "Rehab", href: "/training#rehab" }, { label: "Rehab", href: "/training#rehab" },
{ label: "Holistic class", href: "/training#holistic-class" }, { label: "Holistic class", href: "/training#holistic-class" },
{ label: "Reformer class", href: "/training#reformer-class" }, { label: "Reformer class", href: "/training#reformer-class" },
{ label: "Coaching", href: "/training#coaching" }, { label: "Coaching", href: "/training#coaching" },
], ],
footerServices: [ footerServices: [
{ label: "Personal Training", href: "/services/personal-training" }, { label: "Personal Training", href: "/training/one-to-one" },
{ label: "Class", href: "/services/performance-class" }, { label: "Class", href: "/training/class" },
{ label: "Coaching", href: "/services/coaching" }, { label: "Coaching", href: "/services/coaching" },
{ label: "Profilazione", href: "/services/profilazione" }, { label: "Check-up", href: "/services/profilazione" },
{ label: "Osteopatia", href: "/services/osteopatia" }, { label: "Osteopatia", href: "/services/osteopatia" },
{ label: "Nutrizione", href: "/services/nutrition" }, { label: "Nutrizione", href: "/services/nutrition" },
], ],
+28 -1
View File
@@ -1,8 +1,35 @@
export const trainings = [ export const trainings = [
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } }, { id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
{ id: "small-groups", title: "Small Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } }, { id: "small-groups", title: "Personalized Training Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
{ id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } }, { id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } },
{ id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } }, { id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } },
{ id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } }, { id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } },
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } }, { id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
] as const; ] as const;
/**
* Le viste di /training: la pagina mostra tutti i training, oppure il solo sottoinsieme
* chiesto dal cliente (documento MODIFICHE_PER_SITO.pdf del 17/08/2026).
*
* Da /services la card «One to One» porta a One to One / Personalized Training Group / Rehab,
* la card «Class» a Holistic Class / Reformer Class: chi clicca cerca quella famiglia di
* servizi, e vedersi comparire l'elenco intero costringe a cercare di nuovo.
*/
export const VISTE_TRAINING: Record<string, { titolo: string; blocchi: readonly string[] }> = {
'one-to-one': { titolo: 'Allenamento seguito', blocchi: ['one-to-one', 'small-groups', 'rehab'] },
class: { titolo: 'Class', blocchi: ['holistic-class', 'reformer-class'] },
};
/**
* I training da mostrare per una vista. `undefined` = pagina intera.
*
* Una vista sconosciuta restituisce `null`, e la pagina risponde 404: mostrare l'elenco
* completo al posto di un filtro sbagliato farebbe passare per buono un indirizzo rotto —
* il link continuerebbe a "funzionare" mentre porta altrove.
*/
export function trainingDaMostrare(vista?: string): typeof trainings[number][] | null {
if (!vista) return [...trainings];
const v = VISTE_TRAINING[vista];
if (!v) return null;
return v.blocchi.map((id) => trainings.find((t) => t.id === id)!).filter(Boolean);
}
+31 -4
View File
@@ -5,7 +5,13 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session'; export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme'; // Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole,
// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti
// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs -
// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta
// duplicato: è JavaScript, non può importare da qui, vedi il commento lì).
export const ROLES = ['user', 'superuser', 'piattaforme', 'admin', 'cliente', 'trainer'] as const;
export type Role = typeof ROLES[number];
export function hashPassword(plain: string): string { export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12); return bcrypt.hashSync(plain, 12);
@@ -51,8 +57,9 @@ export function logout(db: Database.Database, token: string): void {
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola // Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a // che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo // qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
// utenti già autenticati). // /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
// qui arrivano solo utenti già autenticati su una di quelle rotte.
const RULES: [RegExp, Role[]][] = [ const RULES: [RegExp, Role[]][] = [
[/^\/admin\/users(\/|$)/, ['admin']], [/^\/admin\/users(\/|$)/, ['admin']],
[/^\/api\/admin\/users(\/|$)/, ['admin']], [/^\/api\/admin\/users(\/|$)/, ['admin']],
@@ -62,6 +69,11 @@ const RULES: [RegExp, Role[]][] = [
// sotto /piattaforme. Stesso ruolo per entrambe. // sotto /piattaforme. Stesso ruolo per entrambe.
[/^\/campus(\/|$)/, ['admin', 'piattaforme']], [/^\/campus(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']], [/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
// Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer.
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
[/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
]; ];
export function canAccessAdminPath(role: string, pathname: string): boolean { export function canAccessAdminPath(role: string, pathname: string): boolean {
@@ -71,6 +83,8 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
} }
// piattaforme vede solo le sue sezioni (coperte da RULES) e il logout // piattaforme vede solo le sue sezioni (coperte da RULES) e il logout
if (role === 'piattaforme') return pathname === '/admin/logout'; if (role === 'piattaforme') return pathname === '/admin/logout';
// cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano.
if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout';
return true; // blog, upload, logout, showtags: tutti i loggati return true; // blog, upload, logout, showtags: tutti i loggati
} }
@@ -111,11 +125,24 @@ export function randomPassword(): string {
} }
export function isRole(v: unknown): v is Role { export function isRole(v: unknown): v is Role {
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme'; return (ROLES as readonly unknown[]).includes(v);
} }
export function landingFor(role: string): string { export function landingFor(role: string): string {
if (role === 'superuser') return '/admin/content'; if (role === 'superuser') return '/admin/content';
if (role === 'piattaforme') return '/piattaforme'; if (role === 'piattaforme') return '/piattaforme';
if (role === 'cliente') return '/longevity/io';
if (role === 'trainer') return '/longevity/gestionale';
return '/admin'; return '/admin';
} }
export function isProtectedPath(pathname: string): boolean {
return (
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin') ||
pathname.startsWith('/campus') ||
pathname.startsWith('/piattaforme') ||
pathname.startsWith('/longevity') ||
pathname.startsWith('/api/longevity')
);
}
+187
View File
@@ -0,0 +1,187 @@
import PDFDocument from 'pdfkit';
import { GIORNI, type AvvisoCalendario, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
// PDF del calendario, disegnato dal dato con PDFKit.
//
// Non è la stampa del browser: è un file vero, che si scarica da un link e si può girare su
// WhatsApp. Serve un documento, non uno screenshot della pagina — chi lo riceve non ha il
// sito aperto davanti.
//
// Niente browser headless di mezzo: Chromium in un container da 2 vCPU costa centinaia di
// megabyte e un processo per ogni richiesta, per una griglia di sette colonne.
/** Colori delle discipline, con la barra a sinistra più scura del fondo (come in pagina). */
const COLORI: Record<Disciplina, { fondo: string; barra: string }> = {
REFORMER: { fondo: '#ece5d7', barra: '#9c8b70' },
PILATES: { fondo: '#f6efe0', barra: '#cbb98f' },
YOGA: { fondo: '#e3eadf', barra: '#8ba57f' },
MOBILITY: { fondo: '#f0e3e3', barra: '#b78e8e' },
};
const C_TESTO = '#2b2b2b';
const C_TENUE = '#8a8a8a';
const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni uguali in fasce
// consecutive devono restare due, non sembrare un blocco unico
const C_ACCENTO = '#9c8b70';
const C_ALT = '#f5f3f0';
/** Un solo orario: il nome porta il suo id. Più orari insieme: il nome è generico. */
export const nomeFilePdf = (cals: Calendario[]): string =>
cals.length === 1 ? `insanitylab-calendario-${cals[0].id}.pdf` : 'insanitylab-calendario-class.pdf';
/**
* Genera il PDF e restituisce il file completo: **un orario per pagina**.
*
* Ogni griglia sta in una pagina intera perché spezzarla renderebbe illeggibile proprio ciò
* che si va a cercare — l'incrocio fra un giorno e un'ora; e due orari sullo stesso foglio si
* confonderebbero fra loro. Le giornate fuori griglia si ripetono su ogni pagina: un foglio
* stampato da solo deve bastare a sé stesso.
*/
export function generaCalendariPdf(cals: Calendario[], avvisi: AvvisoCalendario[] = []): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 32,
info: {
// Il titolo è quello che compare nella scheda del lettore PDF e nel nome suggerito dai
// telefoni: con un orario solo dice quale, con più orari dice il periodo coperto.
Title: cals.length === 1
? `InsanityLab — ${cals[0].sala}, ${cals[0].titolo}`
: `InsanityLab — Calendario delle class (${cals.map((c) => c.titolo).join('; ')})`,
Author: 'InsanityLab',
Subject: 'Calendario delle class',
},
});
const pezzi: Buffer[] = [];
const finito = new Promise<Buffer>((risolvi, rifiuta) => {
doc.on('data', (p: Buffer) => pezzi.push(p));
doc.on('end', () => risolvi(Buffer.concat(pezzi)));
doc.on('error', rifiuta);
});
cals.forEach((cal, indice) => {
if (indice > 0) doc.addPage();
disegnaPagina(doc, cal, avvisi);
});
doc.end();
return finito;
}
/** Una pagina: testata, griglia, legenda, giornate fuori griglia, piè di pagina. */
function disegnaPagina(doc: PDFKit.PDFDocument, cal: Calendario, avvisi: AvvisoCalendario[]): void {
const x0 = doc.page.margins.left;
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// --- Testata ---
doc.font('Helvetica-Bold').fontSize(16).fillColor(C_TESTO)
.text('INSANITYLAB', x0, doc.page.margins.top);
doc.font('Helvetica').fontSize(8).fillColor(C_ACCENTO)
.text(cal.sala.toUpperCase(), x0, doc.y + 2, { characterSpacing: 1.6 });
doc.font('Helvetica-Bold').fontSize(12).fillColor(C_TESTO)
.text(cal.titolo, x0, doc.y + 4);
// --- Griglia ---
const larghezzaOra = 52;
const larghezzaGiorno = (larghezzaUtile - larghezzaOra) / GIORNI.length;
const conNota = GIORNI.some((g) => cal.noteGiorno?.[g]);
const altezzaTestata = conNota ? 26 : 18;
let y = doc.y + 12;
const xDi = (i: number) => x0 + larghezzaOra + i * larghezzaGiorno;
// Intestazione delle colonne
doc.rect(x0, y, larghezzaUtile, altezzaTestata).fill(C_ALT);
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_ACCENTO)
.text('ORA', x0 + 5, y + 6, { width: larghezzaOra - 10, characterSpacing: 1 });
GIORNI.forEach((g, i) => {
doc.font('Helvetica-Bold').fontSize(7).fillColor(cal.chiusi.includes(g) ? C_TENUE : C_TESTO)
.text(g, xDi(i) + 5, y + 6, { width: larghezzaGiorno - 10, characterSpacing: 1 });
const nota = cal.noteGiorno?.[g];
if (nota) {
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text(nota, xDi(i) + 5, y + 15, { width: larghezzaGiorno - 8 });
}
});
y += altezzaTestata;
// Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina
// qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano.
// legenda + nota + piè di pagina, più una riga per ogni giornata fuori griglia: senza
// tenerne conto la griglia si allungherebbe fino a spingere gli avvisi in una seconda pagina.
const spazioSotto = 52 + avvisi.length * 11;
const altezzaRiga = Math.min(
22,
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
);
// Prima passata: riempimenti e testi.
const yPrimaRiga = y;
for (const r of cal.righe) {
if (r.clou) doc.rect(x0, y, larghezzaOra, altezzaRiga).fill('#f7f2e6');
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(C_TESTO)
.text(r.ora, x0 + 5, y + altezzaRiga / 2 - 4, { width: larghezzaOra - 8 });
GIORNI.forEach((g: Giorno, i) => {
const x = xDi(i);
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
if (chiuso) {
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill('#efece7');
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text('CHIUSO', x + 5, y + altezzaRiga / 2 - 3, { width: larghezzaGiorno - 8, characterSpacing: 0.8 });
} else if (lezione) {
const c = COLORI[lezione];
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill(c.fondo);
doc.rect(x, y, 2.5, altezzaRiga).fill(c.barra);
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C_TESTO)
.text(lezione, x + 7, y + altezzaRiga / 2 - 3.5, { width: larghezzaGiorno - 10, characterSpacing: 0.5 });
}
});
y += altezzaRiga;
}
// Seconda passata: la griglia sopra i riempimenti. Tracciando le linee riga per riga, il
// riempimento della riga seguente copriva quella appena disegnata e due lezioni uguali una
// sotto l'altra si leggevano come un unico blocco lungo il doppio.
doc.lineWidth(0.4).strokeColor(C_BORDO);
for (let i = 0; i <= cal.righe.length; i++) {
const yy = yPrimaRiga + i * altezzaRiga;
doc.moveTo(x0, yy).lineTo(x0 + larghezzaUtile, yy).stroke();
}
for (let i = 0; i <= GIORNI.length; i++) {
const x = i === 0 ? x0 + larghezzaOra : xDi(i);
doc.moveTo(x, yPrimaRiga - altezzaTestata).lineTo(x, y).stroke();
}
doc.rect(x0, yPrimaRiga - altezzaTestata, larghezzaUtile, altezzaTestata + altezzaRiga * cal.righe.length).stroke();
// --- Legenda: solo le discipline che compaiono davvero in questa griglia ---
const presenti = (Object.keys(COLORI) as Disciplina[])
.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
let xLeg = x0;
const yLeg = y + 12;
for (const d of presenti) {
doc.rect(xLeg, yLeg, 9, 9).fill(COLORI[d].fondo);
doc.rect(xLeg, yLeg, 2.5, 9).fill(COLORI[d].barra);
doc.font('Helvetica').fontSize(7).fillColor(C_TESTO)
.text(d, xLeg + 13, yLeg + 2, { width: 70 });
xLeg += 13 + doc.widthOfString(d) + 18;
}
// --- Giornate fuori griglia (eventi, chiusure) ---
let yAvvisi = yLeg + 16;
for (const a of avvisi) {
doc.rect(x0, yAvvisi - 1, 2, 9).fill(C_ACCENTO);
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_TESTO)
.text(a.quando, x0 + 7, yAvvisi, { continued: true })
.font('Helvetica').fillColor(C_TENUE).text(`${a.testo}`);
yAvvisi += 11;
}
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
x0, yAvvisi + 3, { width: larghezzaUtile },
);
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
}
+21
View File
@@ -247,6 +247,27 @@ export function createDb(path?: string): Database.Database {
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti. // visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run(); db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
// FAQ "Come faccio ad acquistare i pacchetti?": coda con il rimando a WhatsApp aggiunta dopo
// il primo deploy → i DB esistenti hanno ancora la versione senza. Append guardato dalla fine
// del vecchio testo e dall'assenza della coda → idempotente, non tocca riscritture da pannello.
db.prepare(
`UPDATE content_blocks SET value = value || '<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'
WHERE tag = 'promo.faq.0.a' AND value LIKE '%effettuando il pagamento.'`,
).run();
// FAQ 1 e 2 riscritte (rimando alla prima domanda per l'acquisto, screening iniziale per
// One to One/Small Group, orari class entro Settembre). Guardia via frammento LIKE del vecchio
// testo → idempotente e rispettosa di eventuali riscritture da pannello.
const PROMO_FAQ_RISCRITTE: [string, string, string][] = [
['promo.faq.1.a', '%per prenotare le singole sessioni in base agli orari disponibili%',
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
['promo.faq.2.a', '%vai nella schermata Home, clicca il servizio che hai acquistato%',
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
];
const updFaq = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
const updFaqTx = db.transaction(() => { for (const [tag, frag, newV] of PROMO_FAQ_RISCRITTE) updFaq.run(newV, tag, frag); });
updFaqTx();
// Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti. // Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti.
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[]; const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
if (!colonnePost.some((c) => c.name === 'deleted_at')) { if (!colonnePost.some((c) => c.name === 'deleted_at')) {
+73
View File
@@ -0,0 +1,73 @@
//
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
// La separazione dei due database è la garanzia che chi legge i dati clinici non
// 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.
import type Database from 'better-sqlite3';
/**
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
* a una persona nuova: qui non torna mai disponibile.
*/
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
const numero = (code: string) => Number(code.slice(4));
const ultimoIdentity = identity.prepare(
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
).get() as { client_code: string } | undefined;
const ultimoLongevity = longevity.prepare(
`SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1`
).get() as { client_code: string } | undefined;
const max = Math.max(
ultimoIdentity ? numero(ultimoIdentity.client_code) : 0,
ultimoLongevity ? numero(ultimoLongevity.client_code) : 0,
);
return `ISL-${String(max + 1).padStart(4, '0')}`;
}
export function creaCliente(
identity: Database.Database,
longevity: Database.Database,
dati: {
nome: string; cognome: string; sesso: 'M' | 'F';
data_nascita?: string; email?: string; telefono?: string; user_id?: number;
}
): string {
const code = prossimoCodice(identity, longevity);
identity.prepare(
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null);
try {
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
} catch (err) {
// Compensazione: due database distinti, quindi nessuna transazione unica possibile.
// Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza
// corrispettivo nell'altro database.
identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code);
throw err;
}
return code;
}
export function codicePerUtente(identity: Database.Database, userId: number): string | null {
const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as
{ client_code: string } | undefined;
return r?.client_code ?? null;
}
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita);
const d = new Date(alla);
let eta = d.getFullYear() - n.getFullYear();
const m = d.getMonth() - n.getMonth();
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
return eta;
}
+136
View File
@@ -0,0 +1,136 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore.
* Vale a ogni livello E dentro il questionario: soglia unica, confermata da Nicola il
* 22/08 — due criteri diversi sarebbero due cose da spiegare e da ricordare.
*/
export const COPERTURA_MINIMA = 0.4;
const SCHEMA_LONGEVITY = `
CREATE TABLE IF NOT EXISTS soggetti (
client_code TEXT PRIMARY KEY,
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS registro_test (
test_id TEXT PRIMARY KEY,
etichetta TEXT NOT NULL,
unita TEXT,
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
curva TEXT,
params TEXT,
asse TEXT,
sotto_dominio TEXT,
range_min REAL,
range_max REAL,
attivo_da TEXT NOT NULL,
attivo_a TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS sessioni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
eta_alla_data INTEGER,
operatore TEXT,
note TEXT,
quest_version TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC);
CREATE TABLE IF NOT EXISTS misure (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
valore_num REAL,
valore_txt TEXT,
unita TEXT,
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id);
CREATE TABLE IF NOT EXISTS pesi (
model_version TEXT NOT NULL,
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
contenitore TEXT NOT NULL,
elemento TEXT NOT NULL,
peso REAL NOT NULL,
PRIMARY KEY (model_version, livello, contenitore, elemento)
);
CREATE TABLE IF NOT EXISTS profilo_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER REFERENCES sessioni(id),
campo_id TEXT NOT NULL,
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS score (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
elemento TEXT NOT NULL,
valore REAL,
copertura REAL NOT NULL,
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
quest_version TEXT,
model_version TEXT NOT NULL,
calcolato_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Il tipo Punteggio protegge il codice ma non la tabella: l'interfaccia leggera'
-- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive
-- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e
-- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega.
-- ATTENZIONE al limite, che non e' quello che sembra: lo schema qui sopra gira con
-- CREATE TABLE IF NOT EXISTS, che su un file gia' esistente e' un'operazione a vuoto.
-- Quindi un longevity.db creato prima di questa modifica NON ha questo vincolo, e
-- continuera' a non averlo finche' qualcuno non lo migra a mano.
-- (Non e' un limite di SQLite: la versione imbarcata da better-sqlite3 e' la 3.53,
-- che ALTER TABLE ... ADD CONSTRAINT lo supporta. E' che qui una migrazione non c'e'.
-- Verificato il 22/08: la CLI di sistema e' la 3.37 e non lo supporta, quindi provarlo
-- da riga di comando da' un errore fuorviante.)
-- Oggi nessun longevity.db esiste ancora, quindi il caso e' teorico: lo diventa il
-- giorno del primo deploy, ed e' quello il momento di ricordarselo.
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
);
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
`;
const SCHEMA_IDENTITY = `
CREATE TABLE IF NOT EXISTS clienti (
client_code TEXT PRIMARY KEY,
user_id INTEGER,
nome TEXT NOT NULL,
cognome TEXT NOT NULL,
data_nascita TEXT,
email TEXT,
telefono TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id);
`;
function apri(p: string, schema: string): Database.Database {
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schema);
return db;
}
export function createLongevityDb(path?: string): Database.Database {
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
}
export function createIdentityDb(path?: string): Database.Database {
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
}
+36
View File
@@ -0,0 +1,36 @@
//
// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene
// nomi: non serve nessuna funzione di anonimizzazione, e non c'è niente da ricordarsi.
// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi.
import type Database from 'better-sqlite3';
const INTESTAZIONI = [
'client_code', 'sesso', 'eta', 'data', 'tipo_sessione',
'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range',
];
export function esportaMisure(db: Database.Database): {
intestazioni: string[];
righe: (string | number | null)[][];
} {
const righe = db.prepare(
`SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version,
m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range
FROM misure m
JOIN sessioni s ON s.id = m.sessione_id
JOIN soggetti sg ON sg.client_code = m.client_code
ORDER BY s.data, m.id`
).raw().all() as (string | number | null)[][];
return { intestazioni: INTESTAZIONI, righe };
}
export function esportaCsv(db: Database.Database): string {
const { intestazioni, righe } = esportaMisure(db);
const cella = (v: string | number | null) => {
if (v === null) return '';
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n';
}
+74
View File
@@ -0,0 +1,74 @@
import type Database from 'better-sqlite3';
import { esisteTest } from './registro';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
export type MisuraIn = {
test_id: string;
valore_num?: number;
valore_txt?: string;
unita?: string;
};
export function apriSessione(
db: Database.Database,
s: {
client_code: string; data: string; tipo: 'checkup' | 'questionario';
eta_alla_data?: number; operatore?: string; quest_version?: string;
}
): number {
const r = db.prepare(
`INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version)
VALUES (?, ?, ?, ?, ?, ?)`
).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null);
return Number(r.lastInsertRowid);
}
/**
* L'unico punto in cui si scrive nella tabella `misure`.
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
* sessione scritta a metà. Un valore fuori dal range atteso invece si scrive e si
* marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe.
*/
export function registraMisure(
db: Database.Database,
sessioneId: number,
fonte: Fonte,
misure: MisuraIn[]
): { scritte: number; fuoriRange: string[] } {
const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sess) throw new Error(`sessione ${sessioneId} inesistente`);
for (const m of misure) {
if (!esisteTest(db, m.test_id)) {
throw new Error(`test_id non nel registro: ${m.test_id}`);
}
}
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
const ins = db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
const fuoriRange: string[] = [];
const tx = db.transaction(() => {
for (const m of misure) {
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null };
let fuori = 0;
if (m.valore_num !== undefined) {
if ((r.range_min !== null && m.valore_num < r.range_min) ||
(r.range_max !== null && m.valore_num > r.range_max)) {
fuori = 1;
fuoriRange.push(m.test_id);
}
}
ins.run(sessioneId, sess.client_code, m.test_id,
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori);
}
});
tx();
return { scritte: misure.length, fuoriRange };
}
+140
View File
@@ -0,0 +1,140 @@
/**
* La cascata a quattro livelli: dalle sotto-metriche normalizzate ai
* punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score
* (Performance/Energy/Recovery), fino alla Fitness Age.
*
* Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e
* `compute_fitness_age` dall'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4).
* Regola di copertura, uguale a ogni livello: se un elemento manca, il suo
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
*
* DUE DIVERGENZE VOLUTE dall'oracolo:
*
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
* dichiara insufficiente (la docstring dice il contrario di cio' che il
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
*
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
* del cliente su un profilo parziale concludera' che il porting e' rotto:
* non lo e', diverge di proposito qui.
*
* Tutto il resto e' fedele all'oracolo.
*/
import { COPERTURA_MINIMA } from '../db';
import { arrotonda1 } from './curve';
/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */
export type Punteggio =
| { stato: 'ok'; valore: number; copertura: number }
| { stato: 'insufficiente'; copertura: number };
/** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */
export type VocePesata = { punteggio: number | null; peso: number };
/**
* Media pesata delle voci disponibili, con i pesi delle voci mancanti
* ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la
* differenza di tipo descritta sopra.
*
* ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia
* usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella
* arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve
* solo al valore esposto nel campo `copertura` del risultato. Confrontare la
* copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura
* grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il
* numero pieno fasullo che il tipo `Punteggio` esiste per impedire.
*/
export function aggrega(voci: VocePesata[]): Punteggio {
const pesoTotale = voci.reduce((s, v) => s + v.peso, 0);
const disponibili = voci.filter((v) => v.punteggio !== null);
const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0);
const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0;
const copertura = arrotonda1(coperturaGrezza, 2);
if (disponibili.length === 0) {
return { stato: 'insufficiente', copertura: 0 };
}
if (coperturaGrezza < COPERTURA_MINIMA) {
return { stato: 'insufficiente', copertura };
}
const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0);
const valore = arrotonda1(sommaPesata / pesoDisponibile);
return { stato: 'ok', valore, copertura };
}
/**
* Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis`
* nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse,
* `punteggi` i valori 0-100 disponibili (assenti = mancanti).
*/
export function calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio {
const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: punteggi[chiave] ?? null,
peso,
}));
return aggrega(voci);
}
/**
* Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery).
* Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra
* col suo valore (che qui non esiste nemmeno), entra come mancante e il suo
* peso si ridistribuisce.
*/
export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio {
const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => {
const p = assi[asse];
return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso };
});
return aggrega(voci);
}
/**
* Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
* insufficiente non c'e' Fitness Age da mostrare.
*
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
* solo questo ultimo livello si ferma.
*/
export function calcolaFitnessAge(
etaAnagrafica: number | null,
pesi: Record<string, number>,
voci: Record<string, number | null>
): { fitnessAge: number | null; composito: Punteggio } {
const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: voci[chiave] ?? null,
peso,
}));
const composito = aggrega(vociPesate);
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
return { fitnessAge: null, composito };
}
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
return { fitnessAge, composito };
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Curve di normalizzazione del questionario Longevity: portano una risposta
* grezza a un punteggio 0-100.
*
* `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau`
* e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`,
* `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e
* `score_direct_x10` dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario:
* curve generiche»). Non toccare quella logica: è verificata riga per riga
* contro `riferimento.json`.
*
* `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python:
* vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`)
* e non hanno un oracolo di riferimento — sono coperte solo dai test scritti
* a mano in `tests/longevity/motore-curve.test.ts`.
*/
/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */
export function clamp(x: number, lo = 0, hi = 100): number {
return Math.max(lo, Math.min(hi, x));
}
/**
* Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a
* [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1].
* Come `_lerp` nell'oracolo.
*/
export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number {
if (x1 === x0) return y0;
let t = (x - x0) / (x1 - x0);
t = Math.max(0, Math.min(1, t));
return y0 + t * (y1 - y0);
}
/**
* Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)`
* in Python: sui pareggi ESATTI (il valore binario del double è esattamente a
* metà, es. 86.25 per un decimale) arrotonda al pari (banker's rounding), non
* sempre in su come farebbe `Math.round`. Alcune curve a gradini producono
* pareggi veri — `lerp` che restituisce 86.25 esatto va a 86.2, non 86.3.
*
* Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un
* valore come 86.35 NON è un pareggio vero a un decimale (il double che gli
* sta dietro è 86.34999999999999431..., quindi Python arrotonda a 86.3, non
* a metà), ma `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 —
* un pareggio fasullo creato dalla moltiplicazione, non presente nel valore
* originale. Una prima versione con tolleranza ci cadeva (dava 86.4). Per
* evitarlo si scompone il double nella sua mantissa ed esponente esatti (bit
* IEEE 754, via `DataView`) e si confrontano `ax*10^decimali` e il pareggio
* con aritmetica razionale su `BigInt`, senza mai passare per una
* moltiplicazione in virgola mobile che potrebbe introdurre o cancellare un
* pareggio.
*
* Unica funzione di arrotondamento del motore: la usano `curve.ts`,
* `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due
* decimali, la copertura), per non avere due implementazioni che sui pareggi
* potrebbero divergere fra loro.
*/
export function arrotonda1(x: number, decimali = 1): number {
if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x;
const negativo = x < 0;
const ax = Math.abs(x);
const potenza = 10n ** BigInt(decimali);
// Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente.
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, ax);
const hi = view.getUint32(0);
const lo = view.getUint32(4);
const expBits = (hi >>> 20) & 0x7ff;
let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
let exponent: number;
if (expBits === 0) {
exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque
} else {
mantissa |= 1n << 52n; // bit implicito
exponent = expBits - 1075;
}
// ax*10^decimali = num/den, con num e den interi esatti: nessuna
// moltiplicazione in virgola mobile, quindi nessun pareggio fasullo
// introdotto qui.
let num: bigint;
let den: bigint;
if (exponent >= 0) {
num = mantissa * (potenza << BigInt(exponent));
den = 1n;
} else {
num = mantissa * potenza;
den = 1n << BigInt(-exponent);
}
const n = num / den; // floor(ax*10^decimali), esatto
const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto
let risultato: bigint;
if (resto2 < den) risultato = n;
else if (resto2 > den) risultato = n + 1n;
else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari
const valore = Number(risultato) / Number(potenza);
return negativo ? -valore : valore;
}
/**
* Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce
* verso low/high ai lati. Come `score_bell_curve` nell'oracolo.
*/
export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number {
if (v >= peakLow && v <= peakHigh) return 100;
if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100)));
return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10)));
}
/**
* Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing`
* nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale
* sopra 100 fuori da [best, worst].
*/
export function curvaDecrescente(v: number, best: number, worst: number): number {
return clamp(arrotonda1(lerp(v, best, worst, 100, 0)));
}
/**
* Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi.
* Come `score_increasing_plateau` nell'oracolo.
*/
export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number {
return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100)));
}
/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */
export function curvaDirettaX10(v: number): number {
return clamp(arrotonda1(v * 10));
}
/**
* Complemento della diretta: valore 0-10, punteggio decrescente. Assente
* nell'oracolo Python (è la quinta curva, quella che al motore del cliente
* manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`,
* cioè (10 - v) * 10.
*/
export function curvaDirettaX10Invertita(v: number): number {
return clamp(arrotonda1((10 - v) * 10));
}
/**
* Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per
* valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML
* del cliente (assente nell'oracolo Python).
*
* Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a
* tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le
* curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo
* gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che
* funge da "infinito").
*/
export function curvaGradini(
v: number,
steps: [number, number][],
zeroVal: number | undefined,
decrescente: boolean
): number {
if (v === 0 && zeroVal !== undefined) return zeroVal;
const ancoraggio: [number, number] = [0, decrescente ? 100 : 20];
const punti: [number, number][] = [ancoraggio, ...steps];
for (let i = 0; i < punti.length - 1; i++) {
const [x0, y0] = punti[i];
const [x1, y1] = punti[i + 1];
if (v <= x1) {
return clamp(arrotonda1(lerp(v, x0, x1, y0, y1)));
}
}
// v oltre l'ultimo gradino: resta sull'ultimo valore.
return punti[punti.length - 1][1];
}
+307
View File
@@ -0,0 +1,307 @@
/**
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
* test, o cambiare un parametro, resta una modifica ai dati.
*
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
* calcolo.
*
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`,
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette
* assi, i tre macro-score e la Fitness Age.
*
* `salvaScore` congela il risultato nella tabella `score`, una riga per
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
* a mano su una colonna nullable.
*
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
* forma negativa; `applicaCurva` valida i parametri della curva prima di
* applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
* (punto aperto in §12).
*/
import type Database from 'better-sqlite3';
import {
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
} from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = {
low?: number; peakLow?: number; peakHigh?: number; high?: number;
best?: number; worst?: number; plateauStart?: number;
steps?: [number, number][]; zeroVal?: number;
};
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
function numeroValido(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x);
}
/**
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
* punteggio pieno, la peggior direzione di guasto possibile per un motore
* clinico.
*/
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
switch (curva) {
case 'bell':
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
case 'lin_dec':
return numeroValido(p.best) && numeroValido(p.worst);
case 'inc_plateau':
return numeroValido(p.worst) && numeroValido(p.plateauStart);
case 'x10':
case 'x10_inv':
return true; // nessun parametro richiesto
case 'decstep':
case 'incstep':
return Array.isArray(p.steps) && p.steps.length > 0 &&
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
default:
return false;
}
}
/**
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
*/
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
if (!voce.curva) return null;
const p = (voce.params ?? {}) as ParamsCurva;
if (!paramsValidi(voce.curva, p)) return null;
switch (voce.curva) {
case 'bell':
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
case 'lin_dec':
return curvaDecrescente(valore, p.best as number, p.worst as number);
case 'inc_plateau':
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
case 'x10':
return curvaDirettaX10(valore);
case 'x10_inv':
return curvaDirettaX10Invertita(valore);
case 'decstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
case 'incstep':
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
default:
return null;
}
}
/**
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age.
* `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
* e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la
* tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il
* composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che
* altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'.
*/
export type RisultatoSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
fitnessAge: number | null;
fitnessAgeCopertura: number;
modelVersion: string;
questVersion: string | null;
};
/**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
*/
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare(
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const attivi = testAttivi(db, sessione.data);
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
// passare le misure confermate, non tolto del tutto.
const misurate = new Map<string, number>(
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as
{ test_id: string; valore_num: number | null }[])
.filter((m) => m.valore_num !== null)
.map((m) => [m.test_id, m.valore_num as number])
);
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
const perSottoDominio = new Map<string, number[]>();
for (const voce of attivi) {
if (!voce.sotto_dominio) continue;
const grezzo = misurate.get(voce.test_id);
if (grezzo === undefined) continue;
const punteggio = applicaCurva(voce, grezzo);
if (punteggio === null) continue;
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
lista.push(punteggio);
perSottoDominio.set(voce.sotto_dominio, lista);
}
const sottoDominio: Record<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
const nomiAssi = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const assi: Record<string, Punteggio> = {};
for (const nomeAsse of nomiAssi) {
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
}
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
const nomiMacro = (db.prepare(
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const macro: Record<string, Punteggio> = {};
for (const nomeMacro of nomiMacro) {
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
}
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
// come nell'oracolo, che guarda quei due sotto-domini da soli.
const valoreAsse = (nome: string): number | null => {
const a = assi[nome];
return a && a.stato === 'ok' ? a.valore : null;
};
const vociFitnessAge: Record<string, number | null> = {
cardio: valoreAsse('Cardio-Respiratorio'),
handgrip_isolato: sottoDominio.handgrip ?? null,
hrv_isolato: sottoDominio.hrv ?? null,
forza_resto: valoreAsse('Forza & Struttura'),
composizione: valoreAsse('Composizione Corporea'),
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
};
const { fitnessAge, composito } = calcolaFitnessAge(
sessione.eta_alla_data,
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
vociFitnessAge
);
return {
assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura,
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
};
}
/**
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
*/
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const ins = db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
ins.run(
sessione.client_code, sessioneId, tipo, elemento,
p.stato === 'ok' ? p.valore : null,
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
);
};
const tx = db.transaction(() => {
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null
? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura }
: { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura };
riga('fitness_age', 'fitness_age', fitnessAgePunteggio);
});
tx();
}
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
export type PunteggiSessione = {
assi: Record<string, Punteggio>;
macro: Record<string, Punteggio>;
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
fitnessAge: Punteggio | null;
};
/**
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
*
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
* che non è un criterio).
*/
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
const righe = db.prepare(
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
FROM score s
JOIN (
SELECT tipo, elemento, MAX(id) AS ultimo_id
FROM score
WHERE sessione_id = ?
GROUP BY tipo, elemento
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
WHERE s.sessione_id = ?`
).all(sessioneId, sessioneId) as
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
const assi: Record<string, Punteggio> = {};
const macro: Record<string, Punteggio> = {};
let fitnessAge: Punteggio | null = null;
for (const r of righe) {
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
else fitnessAge = aPunteggio(r);
}
return { assi, macro, fitnessAge };
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Curve dei test fisici: da misura grezza (kg al dinamometro, secondi di
* plank, VO2max stimato...) a punteggio 0-100.
*
* Porting letterale delle funzioni dell'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni «Composizione
* Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza &
* Struttura», «Stabilità & Mobilità»). Coefficienti, soglie e tabelle sono
* copiati tali e quali e verificati contro `riferimento.json` in
* `tests/longevity/motore-fisici.test.ts`. Non "aggiustare" nulla qui dentro:
* l'oracolo è la definizione, non una proposta.
*
* `score_agility_ms` e `score_generic_range_local` NON sono portate: la prima
* perché il cliente ha rimosso l'agilità dallo score il 13/08 (scale non
* comparabili fra protocolli), la seconda perché non è mai chiamata da
* nessun peso.
*
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts`
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`,
* senza modo di passare età e sesso, che quasi tutte queste funzioni
* richiedono. Agganciarle è lavoro del piano degli import, non di questa
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far
* arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai
* dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve
* anche codice nuovo nel motore, non solo righe nuove in `registro_test`.
*/
import { clamp, lerp, arrotonda1 } from './curve';
export type Sesso = 'M' | 'F';
/** Come `sex.strip().lower().startswith("m")` nell'oracolo. */
function eMaschio(sesso: Sesso): boolean {
return sesso.trim().toLowerCase().startsWith('m');
}
// --- Composizione Corporea -------------------------------------------------
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */
export function scoreGrassoPercento(fatPct: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const bands: [number, number, number][] = m
? [[0, 2, 40], [2, 5, 65], [5, 13, 100], [13, 17, 90], [17, 24, 65], [24, 35, 30], [35, 200, 10]]
: [[0, 10, 40], [10, 13, 65], [13, 20, 100], [20, 24, 90], [24, 31, 65], [31, 40, 30], [40, 200, 10]];
for (const [lo, hi, val] of bands) {
if (lo <= fatPct && fatPct < hi) return val;
}
return 50;
}
/** Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device). Come `score_muscle_percent`. */
export function scoreMuscoloPercento(musclePct: number, sesso: Sesso): number {
const [lo, hi] = eMaschio(sesso) ? [42.9, 52.4] : [37.8, 46.2];
if (musclePct >= hi) return 100;
if (musclePct <= lo - 10) return 20;
return arrotonda1(lerp(musclePct, lo - 10, hi, 20, 100));
}
/** Come `score_whr`. */
export function scoreWhr(whr: number, sesso: Sesso): number {
const threshold = eMaschio(sesso) ? 0.9 : 0.85;
if (whr <= threshold - 0.1) return 100;
if (whr <= threshold) return arrotonda1(lerp(whr, threshold - 0.1, threshold, 100, 60));
return clamp(arrotonda1(60 - (whr - threshold) * 300));
}
// --- Cardio-Respiratorio ----------------------------------------------------
/** Queen's College Step Test (McArdle et al. 1972). Come `vo2max_from_step_test`. */
export function vo2maxDaStepTest(hrRecovery: number, sesso: Sesso): number {
if (eMaschio(sesso)) return 111.33 - 0.42 * hrRecovery;
return 65.81 - 0.1847 * hrRecovery;
}
/** UKK 2km Walking Test (Laukkanen/Oja). Come `vo2max_from_2km_walk`. */
export function vo2maxDa2kmWalk(tempoMin: number, hr: number, eta: number, bmi: number): number {
return 116.2 - 2.98 * tempoMin - 0.11 * hr - 0.14 * eta - 0.39 * bmi;
}
/** Formula ACSM su VAM raggiunta al tapis. Teorico/stimato. Come `vo2max_from_mutt`. */
export function vo2maxDaMutt(vamMMin: number): number {
return 0.2 * vamMMin + 3.5;
}
/** Formula ACSM cicloergometro. Teorico/stimato, valida 50-200W. Come `vo2max_from_milfit`. */
export function vo2maxDaMilfit(watt: number, pesoKg: number): number {
return (10.8 * watt) / pesoKg + 7;
}
/** ACSM/FRIEND Registry, bande approssimate per fascia d'età e sesso. Come `score_vo2max`. */
export function scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
const goodThresholdsM: Record<number, number> = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 };
const goodThresholdsF: Record<number, number> = { 20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23 };
const table = m ? goodThresholdsM : goodThresholdsF;
const decade = Math.min(70, Math.max(20, Math.floor(eta / 10) * 10));
const good = table[decade];
const poor = good * 0.65;
const superior = good * 1.35;
if (vo2max <= poor) return clamp(arrotonda1(lerp(vo2max, 0, poor, 10, 40)));
if (vo2max <= good) return arrotonda1(lerp(vo2max, poor, good, 40, 70));
return clamp(arrotonda1(lerp(vo2max, good, superior, 70, 100)));
}
/** Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico. Come `score_spo2`. */
export function scoreSpo2(spo2Pct: number): number {
if (spo2Pct >= 97) return 100;
if (spo2Pct >= 95) return 85;
if (spo2Pct >= 90) return 50;
return 15;
}
// --- Recupero & Sistema Nervoso --------------------------------------------
/** ESC/ESH 2018. Vale la categoria più alta tra sistolica e diastolica. Come `score_blood_pressure`. */
export function scorePressione(sistolica: number, diastolica: number): number {
if (sistolica >= 180 || diastolica >= 110) return 5;
if (sistolica >= 160 || diastolica >= 100) return 25;
if (sistolica >= 140 || diastolica >= 90) return 45;
if (sistolica >= 130 || diastolica >= 85) return 70;
if (sistolica >= 120 || diastolica >= 80) return 90;
return 100;
}
/** Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999). Come `score_hrr`. */
export function scoreRecuperoCardiaco(caloBpm1min: number): number {
if (caloBpm1min >= 12) return clamp(arrotonda1(lerp(caloBpm1min, 12, 30, 70, 100)));
return clamp(arrotonda1(lerp(caloBpm1min, 0, 12, 20, 70)));
}
// --- Forza & Struttura -------------------------------------------------------
/**
* Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'età.
* Come `score_handgrip`.
*
* L'oracolo usa un `for...else` di Python: se il ciclo trova l'intervallo fra
* due ancore, fa `break` con `mean_val` calcolato; se lo esaurisce senza mai
* entrare nel ramo, l'`else` del `for` scatta e vale l'ultima ancora. In
* TypeScript non esiste il for-else: lo si riproduce con un flag.
*/
export function scoreHandgrip(kg: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
// ancore (età, valore medio kg) approssimate dalla letteratura citata
const anchorsM: [number, number][] = [[25, 49.7], [40, 46], [60, 38], [75, 30]];
const anchorsF: [number, number][] = [[25, 30], [40, 28], [60, 24], [75, 18.7]];
const anchors = m ? anchorsM : anchorsF;
const etaC = clamp(eta, 25, 75);
let meanVal: number | undefined;
for (let i = 0; i < anchors.length - 1; i++) {
const [aEta, aVal] = anchors[i];
const [bEta, bVal] = anchors[i + 1];
if (aEta <= etaC && etaC <= bEta) {
meanVal = lerp(etaC, aEta, bEta, aVal, bVal);
break;
}
}
if (meanVal === undefined) meanVal = anchors[anchors.length - 1][1];
// media = punteggio 70; +/- 40% della media copre la banda 20-100
const ratio = meanVal ? kg / meanVal : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.5, 20, 100)));
}
/** ACSM/CSEP, bande approssimate per decade. Come `score_pushup`. */
export function scorePushup(reps: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const baseGoodM = 22;
const baseGoodF = 15; // 35-39 anni, 'buono' minimo
const decadeOffset = Math.max(0, Math.floor((eta - 35) / 10)) * 2.5; // calo ~2.5 rip/decade dopo i 35
const good = (m ? baseGoodM : baseGoodF) - decadeOffset;
const superior = good * 1.6;
const poor = good * 0.5;
if (reps <= poor) return clamp(arrotonda1(lerp(reps, 0, poor, 10, 40)));
if (reps <= good) return arrotonda1(lerp(reps, poor, good, 40, 70));
return clamp(arrotonda1(lerp(reps, good, superior, 70, 100)));
}
/** Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite). Come `score_bw_ratio_lift`. */
export function scoreSollevamentoSuPeso(
caricoKg: number,
pesoKg: number,
rip: number,
tiersM: [number, number][],
tiersF: [number, number][],
sesso: Sesso
): number {
const rm1 = (caricoKg * 36) / (37 - rip); // Brzycki
const ratio = rm1 / pesoKg;
const tiers = eMaschio(sesso) ? tiersM : tiersF;
// tiers = [(soglia_ratio, punteggio), ...] crescente
let prevR = 0;
let prevS = 10;
for (const [r, s] of tiers) {
if (ratio <= r) return arrotonda1(lerp(ratio, prevR, r, prevS, s));
prevR = r;
prevS = s;
}
return 100;
}
export const TIERS_BENCH_M: [number, number][] = [[0.5, 30], [1.0, 60], [1.25, 80], [1.5, 100]];
export const TIERS_BENCH_F: [number, number][] = [[0.3, 30], [0.6, 60], [0.75, 80], [1.0, 100]];
export const TIERS_SQUAT_M: [number, number][] = [[0.75, 30], [1.5, 60], [1.75, 80], [2.0, 100]];
export const TIERS_SQUAT_F: [number, number][] = [[0.5, 30], [1.1, 60], [1.3, 80], [1.5, 100]];
export const TIERS_ROW_M: [number, number][] = [[0.45, 30], [0.70, 60], [0.95, 80], [1.20, 100]];
export const TIERS_ROW_F: [number, number][] = [[0.30, 30], [0.45, 60], [0.60, 80], [0.80, 100]];
/** Come `score_flexed_arm_hang`. */
export function scoreTrazioneIsometrica(sec: number, sesso: Sesso): number {
const [good, superior] = eMaschio(sesso) ? [45, 70] : [25, 50];
if (sec <= good * 0.4) return clamp(arrotonda1(lerp(sec, 0, good * 0.4, 10, 40)));
if (sec <= good) return arrotonda1(lerp(sec, good * 0.4, good, 40, 70));
return clamp(arrotonda1(lerp(sec, good, superior, 70, 100)));
}
/** Reference equation adulti 18-95 (Zalewski et al.-style). Come `score_sit_to_stand_1min`. */
export function scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi = 24): number {
const sexCode = eMaschio(sesso) ? 0 : 1;
const predicted = 61.53 - 0.34 * eta - 3.57 * sexCode - 0.33 * bmi;
const ratio = predicted ? reps / predicted : 1;
return clamp(arrotonda1(lerp(ratio, 0.5, 1.3, 20, 100)));
}
/** Come `score_plank`. */
export function scorePlank(sec: number, sesso: Sesso): number {
const bandsM: [number, number][] = [[79, 20], [97, 40], [122, 55], [157, 75], [201, 90]];
const bandsF: [number, number][] = [[35, 20], [63, 40], [84, 55], [108, 75], [142, 90]];
const bands = eMaschio(sesso) ? bandsM : bandsF;
let prevT = 0;
let prevS = 10;
for (const [t, s] of bands) {
if (sec <= t) return arrotonda1(lerp(sec, prevT, t, prevS, s));
prevT = t;
prevS = s;
}
return 100;
}
// --- Stabilità & Mobilità ----------------------------------------------------
/**
* Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
* Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato
* solido), estrapolato linearmente (~4cm/5 anni) per le altre età.
* Approssimazione da dichiarare, non tabella completa. Come `score_back_scratch`.
*/
export function scoreBackScratch(cm: number, eta: number, sesso: Sesso): number {
const m = eMaschio(sesso);
const anchorEta = 62;
const anchorCm = m ? -8.6 : -1.8;
const declinePerYear = 0.8; // cm peggioramento per anno di età in più
const expected = anchorCm - (eta - anchorEta) * declinePerYear;
// punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
const diff = cm - expected;
return clamp(arrotonda1(lerp(diff, -15, 15, 20, 100)));
}
/** Wellness Tower, riferimento 180 gradi (outreach/buckling). Come `score_shoulder_mobility_wt`. */
export function scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number {
const avg = (outreachDeg + bucklingDeg) / 2;
return clamp(arrotonda1((avg / 180) * 100));
}
/** Decrescente: meno cadute = meglio. Come `score_flamingo`. */
export function scoreFlamingo(cadute: number): number {
if (cadute <= 3) return 100;
if (cadute <= 7) return arrotonda1(lerp(cadute, 3, 7, 100, 80));
if (cadute <= 15) return arrotonda1(lerp(cadute, 7, 15, 80, 50));
return clamp(arrotonda1(lerp(cadute, 15, 30, 50, 10)));
}
/** Come `score_sit_and_reach`. */
export function scoreSitAndReach(cm: number, sesso: Sesso): number {
const median = eMaschio(sesso) ? 24 : 31;
return clamp(arrotonda1(lerp(cm, median - 20, median + 10, 20, 100)));
}
+58
View File
@@ -0,0 +1,58 @@
import type Database from 'better-sqlite3';
import { apriSessione, registraMisure } from './misure';
import { esisteTest } from './registro';
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
export const QUEST_VERSION = 'v1.0';
/**
* Campi testuali che si salvano ma non entrano MAI in una formula
* (indicazione esplicita del cliente, punto 7 della specifica).
*/
export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi'];
export function salvaCompilazione(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
liberi?: Record<string, string>;
}
): number {
// Validare PRIMA della transazione: se un test_id è sconosciuto, non apriamo
// nemmeno una sessione. La validazione non è nel corpo della transazione.
for (const id of Object.keys(input.risposte)) {
if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`);
}
// Avvolgi le tre scritture (apertura sessione, registra misure, inserisci note)
// in un'unica transazione: tutto o niente, senza tracce parziali su dati sanitari.
const tx = db.transaction(() => {
const sessioneId = apriSessione(db, {
client_code: input.client_code,
data: input.data,
tipo: 'questionario',
eta_alla_data: input.eta,
quest_version: QUEST_VERSION,
});
registraMisure(
db, sessioneId, 'questionario',
Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num }))
);
if (input.liberi) {
const ins = db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
);
for (const [campo, testo] of Object.entries(input.liberi)) {
if (testo.trim() === '') continue;
ins.run(input.client_code, sessioneId, campo, testo);
}
}
return sessioneId;
});
return tx();
}
+199
View File
@@ -0,0 +1,199 @@
import type Database from 'better-sqlite3';
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
export const MODEL_VERSION = 'v1.0';
export type VoceRegistro = {
test_id: string;
etichetta: string;
unita?: string;
tipo_valore: 'num' | 'txt';
curva?: Curva;
params?: unknown;
asse?: string;
sotto_dominio?: string;
range_min?: number;
range_max?: number;
attivo_da: string;
attivo_a?: string;
};
const DA = '2026-01-01';
// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html).
// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma
// dichiarata per ciascuna curva, non nell'array posizionale del prototipo.
const QUESTIONARIO: VoceRegistro[] = [
{ test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num',
curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA },
{ test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio',
tipo_valore: 'num', curva: 'x10_inv', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali',
tipo_valore: 'num', curva: 'x10', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA },
{ test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA },
// Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO),
// confermata il 21/08. Non e' un refuso per 0=100 -> 7=0.
{ test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 7, worst: 14 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA },
];
export function seedRegistro(db: Database.Database): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO registro_test
(test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio,
range_min, range_max, attivo_da, attivo_a, note)
VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio,
@range_min, @range_max, @attivo_da, @attivo_a, @note)`
);
const tx = db.transaction((voci: VoceRegistro[]) => {
for (const v of voci) {
ins.run({
test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null,
tipo_valore: v.tipo_valore, curva: v.curva ?? null,
params: v.params === undefined ? null : JSON.stringify(v.params),
asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null,
range_min: v.range_min ?? null, range_max: v.range_max ?? null,
attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null,
});
}
});
tx(QUESTIONARIO);
}
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono
// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge.
// ⚠️ Il plank compare volutamente in DUE assi: 'core' in Forza & Struttura (0.15) e
// 'plank' in Stabilità & Mobilità (0.20). Confermato da Nicola il 22/08 e motivato:
// il plank non è forza pura, richiede controllo neuromuscolare del tronco, quindi
// contribuisce davvero a entrambi i domini che misura. Non è un refuso da correggere.
const PESI_ASSE: Record<string, Record<string, number>> = {
'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 },
'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 },
'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 },
'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 },
'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 },
'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 },
'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 },
};
const PESI_MACRO: Record<string, Record<string, number>> = {
PERFORMANCE: {
'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15,
'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10,
'Energia & Regolazione Stress': 0.05,
},
ENERGY: {
'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20,
'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10,
},
RECOVERY: {
'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15,
'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05,
},
};
const PESI_FITNESS_AGE: Record<string, number> = {
cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20,
forza_resto: 0.15, composizione: 0.10, stabilita: 0.05,
};
export function seedPesi(db: Database.Database, modelVersion: string): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso)
VALUES (?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
for (const [asse, sd] of Object.entries(PESI_ASSE))
for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p);
for (const [macro, assi] of Object.entries(PESI_MACRO))
for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p);
for (const [el, p] of Object.entries(PESI_FITNESS_AGE))
ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p);
});
tx();
}
export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] {
const righe = db.prepare(
`SELECT * FROM registro_test
WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?)
ORDER BY test_id`
).all(alla, alla) as Record<string, unknown>[];
return righe.map((r) => ({
...r,
params: r.params ? JSON.parse(r.params as string) : undefined,
})) as VoceRegistro[];
}
export function esisteTest(db: Database.Database, testId: string): boolean {
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
return r !== undefined;
}
/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */
export function pesiDi(
db: Database.Database, modelVersion: string,
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
): Record<string, number> {
const righe = db.prepare(
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
}
+3 -8
View File
@@ -1,15 +1,10 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db'; import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth'; import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor, isProtectedPath } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => { export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url; const { pathname } = context.url;
const isProtected = if (!isProtectedPath(pathname)) return next();
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin') ||
pathname.startsWith('/campus') ||
pathname.startsWith('/piattaforme');
if (!isProtected) return next();
const token = context.cookies.get(SESSION_COOKIE)?.value; const token = context.cookies.get(SESSION_COOKIE)?.value;
const user = token ? getSessionUser(getDb(), token) : null; const user = token ? getSessionUser(getDb(), token) : null;
@@ -21,7 +16,7 @@ export const onRequest = defineMiddleware((context, next) => {
} }
// Le piattaforme riservate si raggiungono dal sito, quindi passano dal login // Le piattaforme riservate si raggiungono dal sito, quindi passano dal login
// pubblico e ci tornano dopo l'accesso; il pannello ha il proprio. // pubblico e ci tornano dopo l'accesso; il pannello ha il proprio.
if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme')) if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme') || pathname.startsWith('/longevity'))
return context.redirect(`/login?next=${encodeURIComponent(pathname)}`); return context.redirect(`/login?next=${encodeURIComponent(pathname)}`);
return context.redirect('/admin/login'); return context.redirect('/admin/login');
} }
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { listUsers } from '../../lib/auth'; import { listUsers, ROLES } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const users = listUsers(getDb()); const users = listUsers(getDb());
const me = Astro.locals.user!; const me = Astro.locals.user!;
@@ -45,7 +45,7 @@ const me = Astro.locals.user!;
<td>{u.username}{u.id === me.id && ' (tu)'}</td> <td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td> <td>
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}> <select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
{['user', 'superuser', 'piattaforme', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)} {ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select> </select>
</td> </td>
<td class="uactions"> <td class="uactions">
+13 -4
View File
@@ -2,7 +2,19 @@
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e // Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
// il modulo ha spazio per le sue spiegazioni. // il modulo ha spazio per le sue spiegazioni.
import Admin from '../../../layouts/Admin.astro'; import Admin from '../../../layouts/Admin.astro';
import { ROLES } from '../../../lib/auth';
export const prerender = false; export const prerender = false;
// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina
// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto.
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
user: 'scrive i propri articoli',
superuser: 'articoli e contenuti del sito',
piattaforme: 'solo Campus e Stress Index',
admin: 'accesso completo',
cliente: 'solo il proprio fascicolo Longevity',
trainer: 'gestionale Longevity',
};
--- ---
<Admin title="Nuovo utente"> <Admin title="Nuovo utente">
<h1>Nuovo utente</h1> <h1>Nuovo utente</h1>
@@ -17,10 +29,7 @@ export const prerender = false;
<label class="alabel" for="role">Ruolo</label> <label class="alabel" for="role">Ruolo</label>
<select class="afield" id="role" name="role"> <select class="afield" id="role" name="role">
<option value="user">user — scrive i propri articoli</option> {ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
<option value="superuser">superuser — articoli e contenuti del sito</option>
<option value="piattaforme">piattaforme — solo Campus e Stress Index</option>
<option value="admin">admin — accesso completo</option>
</select> </select>
<div class="aactions"> <div class="aactions">
+41
View File
@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
export const prerender = false;
// Il PDF degli orari, come file scaricabile.
//
// /calendario/tutti.pdf tutti gli orari validi, uno per pagina
// /calendario/settembre-1-18.pdf un orario solo
//
// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
// coi clienti.
export const GET: APIRoute = async ({ params }) => {
// "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si
// porta dietro il periodo finito farebbe cercare lezioni che non esistono più.
const cals = params.id === 'tutti'
? calendariDaMostrare(new Date()).map((m) => m.cal)
: calendari.filter((c) => c.id === params.id);
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
// credere che il calendario sia vuoto.
if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 });
// Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo
// riceve non ha davanti la pagina dove sono scritte.
const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date()));
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
// scheda e su telefono resterebbe lì, senza finire fra i file.
'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`,
'Content-Length': String(pdf.length),
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
'Cache-Control': 'public, max-age=3600',
},
});
};
-260
View File
@@ -1,260 +0,0 @@
---
import Base from '../layouts/Base.astro';
import T from '../components/content/T.astro';
import { t, resolveContent } from '../lib/content';
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account,
// inserisce i dati e paga via Stripe dentro la piattaforma. Link centralizzato qui:
// quando avremo gli ID membership per singolo prodotto basterà mappare BSPORT_URL card per card.
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false;
// I testi sono editabili dal pannello contenuti (tag `promo.*`). Qui resta solo la
// struttura: quante card, quante feature ciascuna e quale è "in evidenza".
const cards = [
{ id: 1, features: 4, featured: false },
{ id: 2, features: 4, featured: true },
{ id: 3, features: 3, featured: false },
{ id: 4, features: 3, featured: false },
];
const faqs = [1, 2, 3, 4, 5, 6];
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
const wa = t('global.whatsapp');
// Scadenza della promo (tag editabile dal pannello, formato ISO con offset). Viene solo passata
// al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto col tempo
// del visitatore e non con quello del render (la pagina è SSR ma può essere servita da cache).
const countdownDeadline = t('promo.countdown.deadline');
---
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
<div class="lp">
<div class="page">
<section class="hero">
<T tag="promo.hero.badge" as="div" class="badge" />
<T tag="promo.hero.title1" as="h1" />
<T tag="promo.hero.accent" as="p" class="accent" />
<T tag="promo.hero.title2" as="h1" />
<T tag="promo.hero.desc" as="p" class="desc" />
<a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
</section>
<section class="launch">
<T tag="promo.launch.badge" as="div" class="badge" />
<T tag="promo.launch.title" as="h1" />
<T tag="promo.launch.accent" as="p" class="accent" />
<T tag="promo.launch.desc" as="p" class="desc" />
</section>
<section class="why5">
<T tag="promo.why5.text" as="p" />
</section>
<section class="cards-wrap" id="proposte">
<div class="countdown" data-deadline={countdownDeadline} hidden>
<T tag="promo.countdown.label" as="div" class="cd-label" />
<div class="cd-boxes">
<div class="cd-box"><span class="cd-num" data-cd="days"></span><T tag="promo.countdown.unit.days" as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="hours"></span><T tag="promo.countdown.unit.hours" as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="minutes"></span><T tag="promo.countdown.unit.minutes" as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="seconds"></span><T tag="promo.countdown.unit.seconds" as="span" class="cd-unit" /></div>
</div>
</div>
<div class="cards">
{cards.map((c) => (
<div class:list={['card', c.featured && 'featured']}>
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
<T tag={`promo.card.${c.id}.title`} as="div" class="title" />
<div class="price-row">
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
</div>
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
))}
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
</div>
))}
</div>
<T tag="promo.validity-note" as="p" class="validity-note" />
</section>
<section class="faq-wrap">
<T tag="promo.faq.title" as="h1" />
{faqs.map((n) => {
const a = resolveContent(`promo.faq.${n}.a`);
return (
<div class="faq-block">
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
</div>
);
})}
</section>
</div>
</div>
</Base>
<script>
// Countdown alla scadenza della promo. Parte nascosto (attributo `hidden` nel markup) e viene
// mostrato solo se la deadline è una data valida ancora futura: così, a promo finita, il blocco
// semplicemente non compare — nessun messaggio, nessun salto di layout.
const cd = document.querySelector<HTMLElement>('.lp .countdown');
if (cd) {
const end = Date.parse(cd.dataset.deadline || '');
const nums = new Map<string, HTMLElement>();
cd.querySelectorAll<HTMLElement>('.cd-num').forEach((n) => nums.set(n.dataset.cd!, n));
const pad = (n: number) => String(n).padStart(2, '0');
// Dichiarato prima di `tick` perché la prima chiamata è sincrona: se la promo è già scaduta
// il timer va fermato subito, e leggerlo dopo il `setInterval` darebbe un errore di TDZ.
let timer = 0;
const tick = () => {
const left = end - Date.now();
if (Number.isNaN(end) || left <= 0) { cd.hidden = true; clearInterval(timer); return; }
const s = Math.floor(left / 1000);
nums.get('days')!.textContent = String(Math.floor(s / 86400));
nums.get('hours')!.textContent = pad(Math.floor(s / 3600) % 24);
nums.get('minutes')!.textContent = pad(Math.floor(s / 60) % 60);
nums.get('seconds')!.textContent = pad(s % 60);
cd.hidden = false;
};
tick();
timer = setInterval(tick, 1000);
}
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
const block = q.closest('.faq-block');
const toggle = q.querySelector('.faq-toggle');
q.setAttribute('role', 'button');
q.setAttribute('tabindex', '0');
q.setAttribute('aria-expanded', 'false');
const set = (open: boolean) => {
block?.classList.toggle('is-open', open);
q.setAttribute('aria-expanded', String(open));
if (toggle) toggle.textContent = open ? '' : '+';
};
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
q.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
});
});
</script>
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
<style is:global>
.lp { background: #ECE7DC; }
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
/* HERO */
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
.lp .badge {
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
color: #C4A882; font-weight: 500; text-transform: uppercase;
}
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
.lp .btn-sand {
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
text-decoration: none;
}
/* La freccia è decorazione (il testo del tag non la include più) */
.lp .btn-sand::after { content: ' →'; }
.lp .card .buy::after { content: ' →'; }
/* LAUNCH */
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
/* PERCHE 5 */
.lp .why5 { padding: 48px 64px 8px; }
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
/* COUNTDOWN */
/* `hidden` va forzato: i display flex qui sotto avrebbero comunque la meglio sul default UA. */
.lp .countdown[hidden] { display: none !important; }
.lp .countdown {
display: flex; flex-direction: column; align-items: center; gap: 14px;
background: #2D2D2D; border-radius: 14px; padding: 26px 24px; margin: 0 0 28px;
}
.lp .countdown .cd-label {
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; color: #C4A882;
}
.lp .countdown .cd-boxes { display: flex; gap: 10px; }
.lp .countdown .cd-box {
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 74px;
background: rgba(255,255,255,0.06); border: 0.5px solid rgba(196,168,130,0.35);
border-radius: 10px; padding: 12px 10px;
}
.lp .countdown .cd-num { font-size: 30px; font-weight: 700; line-height: 1; color: #FFFFFF; font-variant-numeric: tabular-nums; }
.lp .countdown .cd-unit { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #B8B6AE; }
/* CARDS */
.lp .cards-wrap { padding: 32px 64px 72px; }
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
.lp .card.featured { border: 2px solid #2D2D2D; }
.lp .featured-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
}
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
.lp .card .buy {
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
}
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
/* FAQ */
.lp .faq-wrap { padding: 72px 64px; }
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
.lp .faq-toggle {
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
.lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; }
@media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
.lp .cards { grid-template-columns: 1fr; }
.lp .countdown .cd-boxes { gap: 6px; }
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
.lp .countdown .cd-num { font-size: 22px; }
.lp .countdown .cd-unit { font-size: 9.5px; }
.lp .hero h1 { font-size: 32px; }
.lp .launch h1 { font-size: 28px; }
}
</style>
+38
View File
@@ -0,0 +1,38 @@
---
import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro';
import T from '../../components/content/T.astro';
import { t } from '../../lib/content';
export const prerender = false;
// Gli eventi del centro. Oggi ce n'è uno solo — l'inaugurazione del 19 settembre — e il
// programma della giornata non c'è ancora: il testo lo dichiara invece di lasciare una
// pagina vuota, ed è editabile dal pannello, così il cliente lo completa senza un deploy.
const wa = t('global.whatsapp');
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
---
<Base title={t('eventi.meta.title')} description={t('eventi.meta.description')}>
<PageHero title={t('eventi.hero.title')} />
<section class="section">
<div class="container">
<article class="evento" data-reveal>
<T tag="eventi.inaugurazione.title" as="h2" />
<T tag="eventi.inaugurazione.date" as="p" class="evento__data" />
<T tag="eventi.inaugurazione.body" as="p" />
<a class="btn" href={waLink} target="_blank" rel="noopener">{t('eventi.cta.label')}</a>
</article>
<p class="evento__torna"><a href="/promo-eventi">{t('eventi.torna.label')}</a></p>
</div>
</section>
</Base>
<style>
.evento { max-width: 760px; margin: 0 auto; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.evento :global(h2) { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
.evento :global(.evento__data) { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .12em; font-size: clamp(1.15rem, 2.4vw, 1.6rem); color: var(--c-accent-dark); margin-top: -4px; }
.evento :global(p) { margin: 0; }
.evento__torna { text-align: center; margin-top: 46px; }
.evento__torna a { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-heading); text-decoration: none; }
.evento__torna a:hover { color: var(--c-accent-dark); }
</style>
+56
View File
@@ -0,0 +1,56 @@
---
import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro';
import T from '../../components/content/T.astro';
import { t } from '../../lib/content';
import { edizioneAttiva } from '../../data/promo-edizioni';
export const prerender = false;
// Chi entra da «Promo ed eventi» sceglie: le promozioni in corso oppure gli eventi del
// centro (chiesto dal cliente il 17/08/2026). Erano la stessa pagina, e chi cercava
// l'inaugurazione finiva in mezzo ai prezzi dei pacchetti.
//
// Il bottone delle promo dice se ce n'è davvero una in corso: mandare su una pagina che
// risponde «nessuna promozione» senza averlo detto prima è un giro a vuoto.
const ed = edizioneAttiva(new Date());
// La pagina cambia contenuto col passare del tempo (l'edizione promo scade da sola): una
// copia in cache la congelerebbe a com'era.
Astro.response.headers.set('Cache-Control', 'no-store');
---
<Base title={t('promo-eventi.meta.title')} description={t('promo-eventi.meta.description')}>
<PageHero title={t('promo-eventi.hero.title')} />
<section class="section">
<div class="container">
<div class="section-heading">
<T tag="promo-eventi.intro" as="p" />
</div>
<div class="scelta">
<a class="scelta__card" href="/promo-eventi/promo">
<T tag="promo-eventi.promo.label" as="h2" />
<T tag="promo-eventi.promo.desc" as="p" />
{!ed && <T tag="promo-eventi.promo.nessuna" as="p" class="scelta__nota" />}
<span class="btn">{t('promo-eventi.promo.cta')}</span>
</a>
<a class="scelta__card scelta__card--dark" href="/promo-eventi/eventi">
<T tag="promo-eventi.eventi.label" as="h2" />
<T tag="promo-eventi.eventi.desc" as="p" />
<span class="btn btn--light">{t('promo-eventi.eventi.cta')}</span>
</a>
</div>
</div>
</section>
</Base>
<style>
.scelta { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
.scelta__card { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; padding: 48px 40px; background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.08); text-decoration: none; transition: transform .3s ease, box-shadow .3s ease; }
.scelta__card:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(0,0,0,.12); }
.scelta__card :global(h2) { text-transform: uppercase; letter-spacing: .12em; font-size: 1.4rem; margin: 0; }
.scelta__card :global(p) { font-size: .92rem; margin: 0; }
.scelta__card .btn { margin-top: auto; }
.scelta__card--dark { background: var(--c-dark); }
.scelta__card--dark :global(h2), .scelta__card--dark :global(p) { color: #efe9e3; }
.scelta__nota { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .scelta { grid-template-columns: 1fr; } .scelta__card { padding: 34px 26px; } }
</style>
+356
View File
@@ -0,0 +1,356 @@
---
import Base from '../../layouts/Base.astro';
import T from '../../components/content/T.astro';
import { t, resolveContent } from '../../lib/content';
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
import { getDb } from '../../lib/db';
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
// il catalogo lo presenta bsport dopo l'accesso.
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false;
// Quale promozione è in vetrina lo decide il calendario, non un deploy: l'edizione attiva è
// quella la cui finestra contiene questo istante (vedi `data/promo-edizioni.ts`). Alla
// scadenza dell'una subentra l'altra da sola, e quando non ce n'è nessuna la pagina lo dice
// invece di lasciare online i prezzi di un'offerta finita.
//
// Anteprima: `?anteprima=2026-08-20` mostra l'edizione di quella data. Solo per chi è loggato
// — un visitatore non deve poter vedere prezzi non ancora validi, o non più.
const param = Astro.url.searchParams.get('anteprima');
const token = Astro.cookies.get(SESSION_COOKIE)?.value;
const utente = token ? getSessionUser(getDb(), token) : null;
// Una data secca (`2026-08-20`) sarebbe letta come UTC: la si porta a mezzogiorno italiano,
// lontano dai confini di mezzanotte dove sta la differenza fra un'edizione e l'altra.
const quandoParam = param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? `${param}T12:00:00+02:00` : param;
const dataLeggibile = Boolean(quandoParam && !Number.isNaN(Date.parse(quandoParam)));
const anteprima = Boolean(utente && dataLeggibile);
// Un'anteprima chiesta e non applicata va **detta**: altrimenti la pagina risponde con
// l'edizione di oggi e due date diverse danno lo stesso risultato — che si legge come «la
// funzione non funziona» invece che «non sei entrato».
const anteprimaIgnorata = !param || anteprima ? null : (!utente ? 'login' : 'data');
const quando = anteprima ? new Date(quandoParam!) : new Date();
const ed = edizioneAttiva(quando);
// La pagina cambia contenuto col passare del tempo: una copia in cache la congelerebbe alla
// vecchia edizione, e lo scambio avverrebbe quando pare al proxy invece che a mezzanotte.
Astro.response.headers.set('Cache-Control', 'no-store');
// Tag dell'edizione attiva: `tg('card.1.price')` → `promo.card.1.price` o `bundle.card.1.price`.
const tg = (suffisso: string) => (ed ? tagEdizione(ed, suffisso) : suffisso);
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
const wa = t('global.whatsapp');
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
// Scadenza dell'edizione in corso (tag editabile dal pannello, ISO con offset). Viene solo
// passata al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto
// col tempo del visitatore.
const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
---
<Base title="Promo ed eventi — InsanityLab" description={ed ? t(tg('hero.desc')).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : 'Le promozioni di apertura sono terminate: scrivici su WhatsApp per i percorsi attivi.'}>
<div class="lp">
<div class="page">
{anteprima && (
<div class="anteprima">
Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} —
{ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'}
</div>
)}
{anteprimaIgnorata === 'login' && (
<div class="anteprima ignorata">
Anteprima non applicata: questa è la pagina di oggi. Entra da <a href="/admin/login">/admin/login</a> e ricarica.
</div>
)}
{anteprimaIgnorata === 'data' && (
<div class="anteprima ignorata">
Anteprima non applicata: «{param}» non è una data leggibile. Usa il formato 2026-08-25.
</div>
)}
{ed ? (
<Fragment>
<section class="hero">
<T tag={tg('hero.badge')} as="div" class="badge" />
<T tag={tg('hero.title1')} as="h1" />
<T tag={tg('hero.accent')} as="p" class="accent" />
<T tag={tg('hero.title2')} as="h1" />
<T tag={tg('hero.desc')} as="p" class="desc" />
<a class="btn-sand" href="#proposte"><T tag={tg('hero.cta')} as="span" /></a>
</section>
{ed.launch && (
<Fragment>
<section class="launch">
<T tag={tg('launch.badge')} as="div" class="badge" />
<T tag={tg('launch.title')} as="h1" />
<T tag={tg('launch.accent')} as="p" class="accent" />
<T tag={tg('launch.desc')} as="p" class="desc" />
</section>
<section class="why5">
<T tag={tg('why5.text')} as="p" />
</section>
</Fragment>
)}
<section class="cards-wrap" id="proposte">
<div class="countdown" data-deadline={countdownDeadline} hidden>
<T tag={tg('countdown.label')} as="div" class="cd-label" />
<div class="cd-boxes">
<div class="cd-box"><span class="cd-num" data-cd="days"></span><T tag={tg('countdown.unit.days')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="hours"></span><T tag={tg('countdown.unit.hours')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="minutes"></span><T tag={tg('countdown.unit.minutes')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="seconds"></span><T tag={tg('countdown.unit.seconds')} as="span" class="cd-unit" /></div>
</div>
</div>
<div class:list={['cards', `cards-${ed.cards.length}`]}>
{ed.cards.map((c) => (
<div class:list={['card', c.featured && 'featured']}>
{c.featured && <T tag={tg(`card.${c.id}.badge`)} as="div" class="featured-badge" />}
<T tag={tg(`card.${c.id}.eyebrow`)} as="div" class="eyebrow" />
<T tag={tg(`card.${c.id}.title`)} as="div" class="title" />
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
<div class="price-row">
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
<T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />
</div>
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={tg(`card.${c.id}.feature.${m}`)} as="li" />
))}
</ul>
{c.desc && <T tag={tg(`card.${c.id}.desc`)} as="p" class="card-desc" />}
{c.note && (
<div class="note-box">
<T tag={tg(`card.${c.id}.note-label`)} as="div" class="note-label" />
<T tag={tg(`card.${c.id}.note`)} as="div" class="note-text" />
</div>
)}
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag={tg('buy-label')} as="span" /></a>
</div>
))}
</div>
<T tag={tg('validity-note')} as="p" class="validity-note" />
</section>
<section class="faq-wrap">
<T tag={tg('faq.title')} as="h1" />
{ed.faq.map((n) => {
const a = resolveContent(tg(`faq.${n}.a`));
return (
<div class="faq-block">
<div class="faq-q"><T tag={tg(`faq.${n}.q`)} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={tg(`faq.${n}.a`)} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
</div>
);
})}
</section>
</Fragment>
) : (
<section class="hero chiusa">
<T tag="promo.chiusa.title" as="h1" />
<T tag="promo.chiusa.desc" as="p" class="desc" />
<a class="btn-sand" href={waLink} target="_blank" rel="noopener"><T tag="promo.chiusa.cta" as="span" /></a>
</section>
)}
</div>
</div>
</Base>
<script>
// Countdown alla scadenza della promo. Parte nascosto (attributo `hidden` nel markup) e viene
// mostrato solo se la deadline è una data valida ancora futura: così, a promo finita, il blocco
// semplicemente non compare — nessun messaggio, nessun salto di layout.
const cd = document.querySelector<HTMLElement>('.lp .countdown');
if (cd) {
const end = Date.parse(cd.dataset.deadline || '');
const nums = new Map<string, HTMLElement>();
cd.querySelectorAll<HTMLElement>('.cd-num').forEach((n) => nums.set(n.dataset.cd!, n));
const pad = (n: number) => String(n).padStart(2, '0');
// Dichiarato prima di `tick` perché la prima chiamata è sincrona: se la promo è già scaduta
// il timer va fermato subito, e leggerlo dopo il `setInterval` darebbe un errore di TDZ.
let timer = 0;
const tick = () => {
const left = end - Date.now();
if (Number.isNaN(end) || left <= 0) { cd.hidden = true; clearInterval(timer); return; }
const s = Math.floor(left / 1000);
nums.get('days')!.textContent = String(Math.floor(s / 86400));
nums.get('hours')!.textContent = pad(Math.floor(s / 3600) % 24);
nums.get('minutes')!.textContent = pad(Math.floor(s / 60) % 60);
nums.get('seconds')!.textContent = pad(s % 60);
cd.hidden = false;
};
tick();
timer = setInterval(tick, 1000);
}
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
const block = q.closest('.faq-block');
const toggle = q.querySelector('.faq-toggle');
q.setAttribute('role', 'button');
q.setAttribute('tabindex', '0');
q.setAttribute('aria-expanded', 'false');
const set = (open: boolean) => {
block?.classList.toggle('is-open', open);
q.setAttribute('aria-expanded', String(open));
if (toggle) toggle.textContent = open ? '' : '+';
};
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
q.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
});
});
</script>
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
<style is:global>
.lp { background: #ECE7DC; }
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
/* HERO */
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
.lp .badge {
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
color: #C4A882; font-weight: 500; text-transform: uppercase;
}
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
.lp .btn-sand {
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
text-decoration: none;
}
/* La freccia è decorazione (il testo del tag non la include più) */
.lp .btn-sand::after { content: ' →'; }
.lp .card .buy::after { content: ' →'; }
/* LAUNCH */
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
/* PERCHE 5 */
.lp .why5 { padding: 48px 64px 8px; }
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
/* COUNTDOWN */
/* `hidden` va forzato: i display flex qui sotto avrebbero comunque la meglio sul default UA. */
.lp .countdown[hidden] { display: none !important; }
.lp .countdown {
display: flex; flex-direction: column; align-items: center; gap: 14px;
background: #2D2D2D; border-radius: 14px; padding: 26px 24px; margin: 0 0 28px;
}
.lp .countdown .cd-label {
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; color: #C4A882;
}
.lp .countdown .cd-boxes { display: flex; gap: 10px; }
.lp .countdown .cd-box {
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 74px;
background: rgba(255,255,255,0.06); border: 0.5px solid rgba(196,168,130,0.35);
border-radius: 10px; padding: 12px 10px;
}
.lp .countdown .cd-num { font-size: 30px; font-weight: 700; line-height: 1; color: #FFFFFF; font-variant-numeric: tabular-nums; }
.lp .countdown .cd-unit { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #B8B6AE; }
/* CARDS */
.lp .cards-wrap { padding: 32px 64px 72px; }
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
.lp .card.featured { border: 2px solid #2D2D2D; }
.lp .featured-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
}
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
.lp .card .buy {
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
}
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
/* CARD DELLE EDIZIONI A TRE (Opening Bundles): domanda in testa, descrizione e riquadro
col periodo di utilizzo. Le card restano in colonna, quindi il bottone finisce ad altezze
diverse se i testi hanno lunghezze diverse: `flex` + `margin-top:auto` lo tiene in fondo. */
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
.lp .cards-3 .card { display: flex; flex-direction: column; }
.lp .cards-3 .card .buy { margin-top: auto; }
.lp .card .question {
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
}
.lp .card .card-desc {
margin: 0 0 18px; font-size: 13px; line-height: 1.6; color: #6E685C; font-style: italic;
}
.lp .card .note-box { background: #F2ECE1; border-radius: 8px; padding: 10px 12px; margin: 0 0 18px; }
.lp .card .note-label {
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
color: #8A6E3F; margin-bottom: 3px;
}
.lp .card .note-text { font-size: 12px; line-height: 1.5; color: #5C564A; }
/* Pagina senza nessuna promozione attiva: fra un'edizione e la successiva. */
.lp .hero.chiusa { padding-bottom: 72px; }
.lp .hero.chiusa h1 { font-size: 34px; }
.lp .hero.chiusa p.desc { margin-bottom: 28px; }
/* Barra di anteprima: visibile solo a chi è loggato e ha chiesto una data (?anteprima=…).
Deve essere impossibile scambiarla per la pagina vera. */
.lp .anteprima {
background: #8A6E3F; color: #FFFFFF; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
padding: 10px 16px; text-align: center;
}
/* Anteprima chiesta e non applicata: colore diverso, perché il contenuto sotto è quello di
oggi e non quello della data richiesta. */
.lp .anteprima.ignorata { background: #8B2E2E; }
.lp .anteprima.ignorata a { color: #FFFFFF; }
/* FAQ */
.lp .faq-wrap { padding: 72px 64px; }
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
.lp .faq-toggle {
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
.lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; }
@media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
.lp .cards { grid-template-columns: 1fr; }
.lp .countdown .cd-boxes { gap: 6px; }
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
.lp .countdown .cd-num { font-size: 22px; }
.lp .countdown .cd-unit { font-size: 9.5px; }
.lp .hero h1 { font-size: 32px; }
.lp .launch h1 { font-size: 28px; }
}
</style>
+36 -2
View File
@@ -7,13 +7,21 @@ import { performanceClassPricing } from '../../data/pricing';
import { serviceIcons } from '../../data/service-icons'; import { serviceIcons } from '../../data/service-icons';
import T from '../../components/content/T.astro'; import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro'; import TImg from '../../components/content/TImg.astro';
import CalendarioModal from '../../components/CalendarioModal.astro';
import { calendariDaMostrare, PAGINE_CON_CALENDARIO } from '../../data/calendari';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
export const prerender = false; export const prerender = false;
const service = getService(Astro.params.slug!); const service = getService(Astro.params.slug!);
if (!service) return new Response(null, { status: 404 }); // `page: false` = servizio senza scheda (oggi la Performance Class, che non si eroga più):
// la voce esiste ancora nei dati per la card in griglia, ma la pagina non c'è.
if (!service || service.page === false) return new Response(null, { status: 404 });
const slug = service.slug; const slug = service.slug;
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false); // Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
// peggio di nessun link.
const conCalendario = PAGINE_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false && p.page !== false);
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const heroImg = imgOf(service.slug); const heroImg = imgOf(service.slug);
@@ -38,6 +46,15 @@ const tones = ['tan', 'taupe', 'dark'];
{heroImg && ( {heroImg && (
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" /> <TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
)} )}
{service.extra && (
<div class="prog__extra">
<T tag={`service.${slug}.extra.title`} as="h2" />
<T tag={`service.${slug}.extra.body`} as="p" class="prog__desc" />
<ul class="prog__features">
{service.extra.items.map((_, i) => <T tag={`service.${slug}.extra.item.${i + 1}`} as="li" />)}
</ul>
</div>
)}
<div class="prog__faq"> <div class="prog__faq">
{service.faq.map((_, i) => ( {service.faq.map((_, i) => (
<details open={i === 0}> <details open={i === 0}>
@@ -59,6 +76,16 @@ const tones = ['tan', 'taupe', 'dark'];
<T tag={`service.${o.slug}.title`} /> <T tag={`service.${o.slug}.title`} />
</a> </a>
))} ))}
{conCalendario && (
<button type="button" class="prog__other prog__other--calendario" data-apri-calendario>
<span class="prog__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true">
<path d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4M8 14h3M8 17h3M14 14h2" />
</svg>
</span>
{t('calendario.link-label')}
</button>
)}
</aside> </aside>
</div> </div>
</section> </section>
@@ -95,12 +122,16 @@ const tones = ['tan', 'taupe', 'dark'];
</div> </div>
</section> </section>
)} )}
{conCalendario && <CalendarioModal />}
</Base> </Base>
<style> <style>
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; } .prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
.prog :global(.prog__desc) { white-space: pre-line; } .prog :global(.prog__desc) { white-space: pre-line; }
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; } .prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
.prog__extra { padding-top: 30px; margin-bottom: 10px; }
.prog__extra h2 { letter-spacing: .1em; margin-bottom: .5em; }
.prog__features li::marker { color: var(--c-accent); } .prog__features li::marker { color: var(--c-accent); }
.prog__img { width: 100%; height: auto; margin-bottom: 30px; } .prog__img { width: 100%; height: auto; margin-bottom: 30px; }
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; } .prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
@@ -109,6 +140,9 @@ const tones = ['tan', 'taupe', 'dark'];
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; } .prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; } .prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
.prog__other:hover { color: var(--c-accent-dark); } .prog__other:hover { color: var(--c-accent-dark); }
/* La voce del calendario apre un popup, quindi è un bottone e non un link: qui gli si
toglie l'aspetto da bottone perché nella colonna sia indistinguibile dalle altre voci. */
.prog__other--calendario { background: none; border: 0; padding: 0; cursor: pointer; width: 100%; text-align: left; }
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; } .prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; } .price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); } .price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
+26 -6
View File
@@ -5,13 +5,23 @@ import type { ImageMetadata } from 'astro';
import { services } from '../../data/services'; import { services } from '../../data/services';
import T from '../../components/content/T.astro'; import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro'; import TImg from '../../components/content/TImg.astro';
import CalendarioModal from '../../components/CalendarioModal.astro';
import { calendariDaMostrare, CARD_CON_CALENDARIO } from '../../data/calendari';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const gridServices = services.filter((p) => p.grid !== false); const gridServices = services.filter((p) => p.grid !== false);
// Questi servizi rimandano alla pagina Training invece che alla scheda di dettaglio // Foto e "Per saperne di più" della stessa card portano allo stesso posto, e lo fanno per
const TRAINING_LINK = new Set(['one-to-one', 'performance-class']); // costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance
const moreHref = (slug: string) => (TRAINING_LINK.has(slug) ? '/training' : `/services/${slug}`); // Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la
// destinazione dipendeva da dove capitava il dito.
// Alcune card non portano alla propria scheda ma a una vista di /training (One to One e
// Class, deciso dal cliente il 17/08/2026): la destinazione la dichiara il servizio.
const hrefOf = (p: (typeof services)[number]) => p.href ?? `/services/${p.slug}`;
// Il calendario delle class si apre anche da qui, dalla card Class: chi guarda i servizi
// vuole sapere quando si allena prima ancora di aprire la scheda. Compare solo se c'è
// davvero una griglia da mostrare, come nelle pagine delle class.
const conCalendario = calendariDaMostrare(new Date()).length > 0;
export const prerender = false; export const prerender = false;
--- ---
<Base title={t('services.meta.title')} description={t('services.meta.description')}> <Base title={t('services.meta.title')} description={t('services.meta.description')}>
@@ -25,7 +35,7 @@ export const prerender = false;
{gridServices.map((p, i) => ( {gridServices.map((p, i) => (
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}> <article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
{imgOf(p.slug) && ( {imgOf(p.slug) && (
<a class="plist__photo" href={`/services/${p.slug}`}> <a class="plist__photo" href={hrefOf(p)}>
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" /> <TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
</a> </a>
)} )}
@@ -33,13 +43,17 @@ export const prerender = false;
<T tag={`service.${p.slug}.title`} as="h3" /> <T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" /> <T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
<T tag={`service.${p.slug}.excerpt`} as="p" /> <T tag={`service.${p.slug}.excerpt`} as="p" />
<a class="plist__more" href={moreHref(p.slug)}>{t('services.card.more-label')}</a> {conCalendario && CARD_CON_CALENDARIO.includes(p.slug) && (
<button type="button" class="plist__calendario" data-apri-calendario>{t('calendario.link-label')}</button>
)}
<a class="plist__more" href={hrefOf(p)}>{t('services.card.more-label')}</a>
</div> </div>
</article> </article>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{conCalendario && <CalendarioModal />}
</Base> </Base>
<style> <style>
@@ -51,9 +65,15 @@ export const prerender = false;
.plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; } .plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
.plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; } .plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; }
.plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); } .plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
.plist__body > p:not(.plist__sub) { font-size: .9rem; } .plist__body > p:not(.plist__sub) { font-size: .9rem; white-space: pre-line; }
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; } .plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; }
.plist__more:hover { color: var(--c-accent-dark); } .plist__more:hover { color: var(--c-accent-dark); }
/* Il calendario sta sopra "Per saperne di più" (posizione chiesta dal cliente) e apre un
popup, quindi è un bottone: qui gli si toglie l'aspetto da bottone perché nella card
stia accanto all'altra voce senza rubarle l'occhio. */
.plist__calendario { background: none; border: 0; padding: 0; margin-top: auto; cursor: pointer; text-align: left; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent-dark); }
.plist__calendario:hover { color: var(--c-heading); }
.plist__calendario + .plist__more { margin-top: 10px; }
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } } @media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } } @media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
</style> </style>
@@ -1,28 +1,37 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import TrainingCards from '../components/home/TrainingCards.astro'; import TrainingCards from '../../components/home/TrainingCards.astro';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { trainings } from '../data/trainings'; import type { ImageMetadata } from 'astro';
import T from '../components/content/T.astro'; import { VISTE_TRAINING, trainingDaMostrare } from '../../data/trainings';
import { t } from '../lib/content'; import T from '../../components/content/T.astro';
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true }); import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
export const prerender = false; export const prerender = false;
// `/training` mostra tutto; `/training/one-to-one` e `/training/class` il solo sottoinsieme
// da cui si arriva cliccando la card corrispondente in /services. Un indirizzo inventato è
// 404 e non l'elenco intero: un filtro che non c'è deve vedersi, non passare per buono.
const vista = Astro.params.vista;
const daMostrare = trainingDaMostrare(vista);
if (!daMostrare) return new Response(null, { status: 404 });
const filtrata = Boolean(vista);
--- ---
<Base title={t('training.meta.title')} description={t('training.meta.description')}> <Base title={t('training.meta.title')} description={t('training.meta.description')}>
<PageHero title={t('training.hero.title')} /> <PageHero title={t('training.hero.title')} />
<section class="section" style="padding-bottom:0"> <section class="section" style="padding-bottom:0">
<div class="section-heading"> <div class="section-heading">
<T tag="training.intro.subtitle" as="p" /> {filtrata ? <p>{VISTE_TRAINING[vista!].titolo}</p> : <T tag="training.intro.subtitle" as="p" />}
</div> </div>
</section> </section>
<TrainingCards /> <TrainingCards items={daMostrare} />
<section class="section"> <section class="section">
<div class="container"> <div class="container">
{trainings.map((tr, i) => ( {daMostrare.map((tr, i) => (
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}> <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />} {imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}> <div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
@@ -32,6 +41,11 @@ export const prerender = false;
</div> </div>
</div> </div>
))} ))}
{/* Da una vista filtrata si esce: senza questo link chi è entrato da una card di
/services non ha modo di vedere gli altri training se non dal menu. */}
{filtrata && (
<p class="tblock__tutti"><a class="btn btn--light" href="/training">{t('training.tutti.cta-label')}</a></p>
)}
</div> </div>
</section> </section>
</Base> </Base>
@@ -40,5 +54,6 @@ export const prerender = false;
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); } .tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
.tblock--reverse img { order: 2; } .tblock--reverse img { order: 2; }
.tblock h2 { letter-spacing: .1em; } .tblock h2 { letter-spacing: .1em; }
.tblock__tutti { text-align: center; margin-top: 20px; }
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } } @media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
</style> </style>
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
const d = (iso: string) => new Date(iso);
describe('calendariDaMostrare', () => {
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
// Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere
// com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre.
it('prima dell\'inizio: entrambi, marcati futuri', () => {
expect(idDi('2026-08-17T10:00:00+02:00'))
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
});
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
expect(idDi('2026-09-05T10:00:00+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
});
// Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più.
it('finito il primo periodo, il primo orario sparisce', () => {
expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
});
it('confini al secondo, in ora italiana', () => {
expect(idDi('2026-09-18T23:59:59+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => {
const rovescio = [...calendari].reverse();
expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id))
.toEqual(['settembre-1-18', 'settembre-21-in-poi']);
});
});
describe('giornate fuori griglia', () => {
it('la presentazione e la chiusura si vedono prima di arrivarci', () => {
const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id);
expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
});
// Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è
// già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo.
it('ogni avviso sparisce da solo quando è passato', () => {
expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id))
.toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id))
.toEqual(['chiusura-20-settembre']);
expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]);
});
it('ogni avviso ha una scadenza leggibile e un testo', () => {
for (const a of avvisi) {
expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false);
if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from));
expect(a.quando.length).toBeGreaterThan(0);
expect(a.testo.length).toBeGreaterThan(0);
}
});
});
describe('coerenza dei calendari trascritti', () => {
for (const c of calendari) {
describe(c.id, () => {
it('la finestra è leggibile e ha un verso', () => {
expect(Number.isNaN(Date.parse(c.from))).toBe(false);
if (c.to !== null) {
expect(Number.isNaN(Date.parse(c.to))).toBe(false);
expect(Date.parse(c.to)).toBeGreaterThan(Date.parse(c.from));
}
});
it('le fasce orarie sono uniche e in ordine crescente', () => {
const ore = c.righe.map((r) => r.ora);
expect(new Set(ore).size, 'fascia ripetuta').toBe(ore.length);
const minuti = ore.map((o) => {
const [h, m] = o.split('.');
return Number(h) * 60 + Number(m);
});
expect(minuti.every((v, i) => i === 0 || v > minuti[i - 1]), `fasce fuori ordine: ${ore.join(' ')}`).toBe(true);
});
it('ogni cella sta su un giorno esistente e porta una disciplina nota', () => {
for (const r of c.righe) {
for (const [giorno, disciplina] of Object.entries(r.celle)) {
expect(GIORNI, `${r.ora}: giorno ${giorno} inesistente`).toContain(giorno as Giorno);
expect(DISCIPLINE, `${r.ora} ${giorno}: disciplina ${disciplina} sconosciuta`).toContain(disciplina!);
}
}
});
// Una lezione in un giorno di chiusura è la contraddizione che nessuno nota leggendo la
// griglia: la colonna è barrata e la cella ci finisce dentro lo stesso.
it('nessuna lezione cade in un giorno di chiusura', () => {
for (const r of c.righe) {
for (const g of c.chiusi) {
expect(r.celle[g], `${r.ora}: lezione di ${g}, che è chiuso`).toBeUndefined();
}
}
});
it('le note dei giorni si riferiscono a giorni aperti', () => {
for (const g of Object.keys(c.noteGiorno ?? {}) as Giorno[]) {
expect(GIORNI).toContain(g);
expect(c.chiusi, `nota su ${g}, che è dichiarato chiuso`).not.toContain(g);
}
});
});
}
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { avvisi, calendari } from '../src/data/calendari';
import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
describe('PDF del calendario', () => {
for (const cal of calendari) {
it(`${cal.id}: produce un PDF valido`, async () => {
const pdf = await generaCalendariPdf([cal]);
// Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
// browser, che mostrerebbe una pagina bianca invece di un errore.
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.subarray(-8).toString()).toContain('%%EOF');
expect(pdf.length).toBeGreaterThan(2000);
});
}
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
// /Type /Pages, che dichiara quante pagine contiene.
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
for (const cal of calendari) {
const pdf = await generaCalendariPdf([cal], avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull();
expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1);
}
});
it('il nome del file distingue i due periodi', () => {
const nomi = calendari.map((c) => nomeFilePdf([c]));
expect(new Set(nomi).size).toBe(nomi.length);
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
// Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci
// sia quello e basta.
expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf');
});
// Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e
// spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora.
it('con più orari fa una pagina per ciascuno', async () => {
const pdf = await generaCalendariPdf(calendari, avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio).not.toBeNull();
expect(Number(conteggio![1])).toBe(calendari.length);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica';
describe('anagrafica pseudonimizzata', () => {
it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' });
const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as
{ nome: string; cognome: string };
expect(inIdentity.nome).toBe('Mario');
const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string };
expect(inLongevity.sesso).toBe('M');
// in longevity non deve esistere nessuna colonna che contenga il nome
const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all());
expect(dump).not.toContain('Mario');
expect(dump).not.toContain('Rossi');
expect(dump).not.toContain('1988-03-04');
});
it('assegna codici progressivi e non riusa i vecchi', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
expect(a).toMatch(/^ISL-\d{4}$/);
expect(b).not.toBe(a);
});
it('ritrova il codice a partire dall utente del sito', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 });
expect(codicePerUtente(id, 42)).toBe(code);
expect(codicePerUtente(id, 99)).toBeNull();
});
it('calcola l eta alla data della sessione', () => {
expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38);
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato
});
it('un codice cancellato dall anagrafica non torna mai disponibile: le misure del vecchio non si attribuiscono al nuovo', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
// Il secondo cliente viene cancellato dall'anagrafica (disdetta, richiesta di
// cancellazione, errore): le sue misure restano in longevity, pseudonimizzate.
id.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(b);
lg.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES (?, '2026-08-01', 'checkup')`).run(b);
const c = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F' });
// Il terzo cliente riceve un codice nuovo, mai il codice riciclato del secondo.
expect(c).not.toBe(b);
expect(c).not.toBe(a);
// Le sessioni del vecchio cliente (b) restano leggibili sotto il suo codice...
const sessioniDiB = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(b);
expect(sessioniDiB.length).toBe(1);
// ...e non risultano in nessun modo attribuite al nuovo cliente (c).
const sessioniDiC = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(c);
expect(sessioniDiC.length).toBe(0);
});
it('se la scrittura su longevity fallisce, quella gia fatta su identity viene annullata', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const primaDelTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
// 'Z' non è un sesso valido (CHECK IN ('M','F') su longevity.soggetti): la scrittura
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
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', () => {
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
for (const f of files) {
const path = join(srcDir, f);
// Salta node_modules e dist
if (f.includes('node_modules') || f.includes('dist')) continue;
// Accetta solo .ts e .astro
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
// Esclude i moduli autorizzati
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
const src = readFileSync(path, 'utf8');
if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) {
colpevoli.push(f);
}
}
expect(colpevoli).toEqual([]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db';
describe('schema longevity', () => {
it('crea le tabelle previste dalla spec', () => {
const db = createLongevityDb(':memory:');
const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.map((r) => r.name);
expect(nomi).toEqual(
expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score'])
);
});
it('rifiuta un sesso non previsto', () => {
const db = createLongevityDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run()
).toThrow();
});
it('rifiuta una misura con test_id non nel registro', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte)
VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')`
).run()
).toThrow();
});
it('uno score insufficiente puo non avere valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')`
).run();
const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string };
expect(row.valore).toBeNull();
expect(row.stato).toBe('insufficiente');
});
it('la tabella rifiuta uno score insufficiente CON un valore scritto', () => {
// Il tipo Punteggio protegge il codice, ma l'interfaccia legge dalla tabella,
// dove il tipo non c'e' piu': senza questo vincolo si potrebbe scrivere un
// numero dichiarato "insufficiente" e chi legge crud dalla tabella lo prenderebbe.
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', 42, 0.2, 'insufficiente', 'v1.0')`
).run()
).toThrow();
});
it('la tabella rifiuta uno score ok SENZA valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 1, 'ok', 'v1.0')`
).run()
).toThrow();
});
it('identity tiene la persona, longevity non la conosce', () => {
const id = createIdentityDb(':memory:');
const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name);
expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita']));
const lg = createLongevityDb(':memory:');
const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name));
expect(tutte).not.toContain('nome');
expect(tutte).not.toContain('cognome');
expect(tutte).not.toContain('data_nascita');
});
it('la soglia di copertura e 0.40', () => {
expect(COPERTURA_MINIMA).toBe(0.4);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export';
function dbConDati() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21', eta: 35,
risposte: { q_ore_sonno: 7, q_riposato: 8 },
liberi: { farmaci: 'nessuno' },
});
return db;
}
describe('export per le statistiche', () => {
it('esporta una riga per misura con codice, non con nome', () => {
const db = dbConDati();
const out = esportaMisure(db);
expect(out.intestazioni).toEqual(
['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range']
);
expect(out.righe.length).toBe(2);
expect(out.righe[0][0]).toBe('ISL-0001');
});
it('l export non contiene campi identificativi', () => {
const db = dbConDati();
const testo = JSON.stringify(esportaMisure(db));
for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) {
expect(testo).not.toContain(vietato);
}
});
it('i campi liberi restano fuori dall export statistico', () => {
const db = dbConDati();
expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno');
});
it('produce un CSV con intestazione e una riga per misura', () => {
const db = dbConDati();
const righe = esportaCsv(db).trim().split('\n');
expect(righe.length).toBe(3); // intestazione + 2 misure
expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range');
});
it('ogni valore sta sotto l intestazione giusta, colonna per colonna (asserzione posizionale)', () => {
// Non un indice scritto a mano: l'indice si cerca per NOME nell'intestazione prodotta.
// Se un domani la SELECT in export.ts viene riordinata senza riallineare INTESTAZIONI,
// il valore letto a quell'indice smette di corrispondere e l'asserzione fallisce -
// invece di restare verde con l'età etichettata come sesso.
const db = dbConDati();
const { intestazioni, righe } = esportaMisure(db);
const idx = (col: string) => {
const i = intestazioni.indexOf(col);
if (i < 0) throw new Error(`intestazione mancante: ${col}`);
return i;
};
const rigaSonno = righe.find((r) => r[idx('test_id')] === 'q_ore_sonno')!;
expect(rigaSonno).toBeDefined();
expect(rigaSonno[idx('client_code')]).toBe('ISL-0001');
expect(rigaSonno[idx('sesso')]).toBe('F');
expect(rigaSonno[idx('eta')]).toBe(35);
expect(rigaSonno[idx('data')]).toBe('2026-08-21');
expect(rigaSonno[idx('tipo_sessione')]).toBe('questionario');
expect(rigaSonno[idx('quest_version')]).toBe('v1.0');
expect(rigaSonno[idx('valore')]).toBe(7);
// salvaCompilazione non passa 'unita' per le risposte del questionario: resta null.
expect(rigaSonno[idx('unita')]).toBeNull();
expect(rigaSonno[idx('fonte')]).toBe('questionario');
expect(rigaSonno[idx('fuori_range')]).toBe(0);
const rigaRiposato = righe.find((r) => r[idx('test_id')] === 'q_riposato')!;
expect(rigaRiposato).toBeDefined();
expect(rigaRiposato[idx('client_code')]).toBe('ISL-0001');
expect(rigaRiposato[idx('sesso')]).toBe('F');
expect(rigaRiposato[idx('eta')]).toBe(35);
expect(rigaRiposato[idx('valore')]).toBe(8);
expect(rigaRiposato[idx('unita')]).toBeNull();
});
});
+87
View File
@@ -0,0 +1,87 @@
// Il difetto più serio di questo ramo era "una funzione di autorizzazione corretta che
// nessuno chiama": isProtectedPath e canAccessAdminPath erano giuste, ma finché niente le
// collegava dentro src/middleware.ts le pagine sanitarie restavano pubbliche. Nessun altro
// test in tutto il repo importa src/middleware.ts: qui lo si carica davvero (vedi l'alias
// 'astro:middleware' in vitest.config.ts, necessario perché quel modulo è virtuale e
// risolto solo dentro la pipeline vite di Astro) e si esercita la funzione vera, non una
// sua reimplementazione.
import { describe, it, expect, beforeAll } from 'vitest';
// getDb() (usato da src/middleware.ts) apre 'data/insanitylab.db' su disco per default:
// DB_PATH va puntato a :memory: PRIMA che qualcosa lo chiami (il singleton è pigro).
process.env.DB_PATH = ':memory:';
type FakeContext = {
url: URL;
cookies: { get: (name: string) => { value: string } | undefined };
redirect: (path: string, status?: number) => Response;
locals: Record<string, unknown>;
};
function contesto(pathname: string, cookieValue?: string): FakeContext {
return {
url: new URL(`http://localhost${pathname}`),
cookies: { get: () => (cookieValue !== undefined ? { value: cookieValue } : undefined) },
redirect: (path, status) => new Response(null, { status: status ?? 302, headers: { Location: path } }),
locals: {},
};
}
function next() {
let chiamata = false;
const fn = async () => { chiamata = true; return new Response('PAGINA-SERVITA'); };
return { fn, fuChiamata: () => chiamata };
}
describe('src/middleware.ts — protezione reale delle rotte longevity', () => {
let onRequest: (context: FakeContext, next: () => Promise<Response>) => Response | Promise<Response>;
beforeAll(async () => {
const mod = await import('../../src/middleware.ts');
// FakeContext è un doppio minimale di APIContext (solo cio' che onRequest usa
// davvero: url, cookies, redirect, locals), non un APIContext completo: il cast
// diretto viene rifiutato dal typecheck perche' i due tipi non si sovrappongono
// abbastanza. Si passa da 'unknown' perche' e' un cast intenzionale, non un bug.
onRequest = mod.onRequest as unknown as typeof onRequest;
});
it('una rotta longevity senza sessione (nessun cookie) redirige al login e non serve la pagina', async () => {
const ctx = contesto('/longevity/io');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect(res).toBeInstanceOf(Response);
expect([301, 302, 303, 307, 308]).toContain((res as Response).status);
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
});
it('una rotta longevity con un token di sessione inesistente redirige comunque al login', async () => {
const ctx = contesto('/longevity/gestionale', 'token-che-non-esiste-in-nessuna-sessione');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
});
it('una API longevity senza sessione risponde 401, non serve la richiesta', async () => {
const ctx = contesto('/api/longevity/qualcosa');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect((res as Response).status).toBe(401);
const body = await (res as Response).json();
expect(body.error).toBeTruthy();
});
it('una rotta pubblica non passa da nessun controllo: next() viene chiamata', async () => {
const ctx = contesto('/blog');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(true);
expect(await (res as Response).text()).toBe('PAGINA-SERVITA');
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('registrazione delle misure', () => {
it('scrive le misure di una sessione', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const esito = registraMisure(db, s, 'questionario', [
{ test_id: 'q_ore_sonno', valore_num: 7.5 },
{ test_id: 'q_riposato', valore_num: 8 },
]);
expect(esito.scritte).toBe(2);
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number };
expect(n.n).toBe(2);
});
it('rifiuta un test sconosciuto e non scrive niente del lotto', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' });
expect(() =>
registraMisure(db, s, 'manuale', [
{ test_id: 'q_ore_sonno', valore_num: 7 },
{ test_id: 'test_inventato', valore_num: 1 },
])
).toThrow(/test_inventato/);
const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
expect(n.n).toBe(0);
});
it('un valore fuori dal range atteso si scrive e si marca', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' });
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
expect(esito.fuoriRange).toEqual(['q_ore_sonno']);
const row = db.prepare(`SELECT valore_num, fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { valore_num: number; fuori_range: number };
expect(row.fuori_range).toBe(1);
expect(row.valore_num).toBe(26);
});
it('la sessione porta la versione del questionario', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string };
expect(row.quest_version).toBe('v1.0');
});
it('il client_code della misura viene dalla sessione, non da chi chiama', () => {
const db = dbPronto();
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' });
registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]);
const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string };
expect(row.client_code).toBe('ISL-0002');
});
});
+99
View File
@@ -0,0 +1,99 @@
// tests/longevity/motore-cascata.test.ts
import { describe, it, expect } from 'vitest';
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
describe('aggregazione e rinormalizzazione', () => {
it('con tutti i dati fa la media pesata', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
});
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
expect(r.stato).toBe('ok');
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
});
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.2, 2);
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
expect((r as { valore?: number }).valore).toBeUndefined();
});
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
expect(pocoSotto.stato).toBe('insufficiente');
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
});
it('senza nessun dato e insufficiente con copertura zero', () => {
const r = aggrega([{ punteggio: null, peso: 1 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBe(0);
});
// Bug trovato in revisione: il confronto con la soglia usava la copertura
// GIA' arrotondata a due decimali, non quella grezza. Una copertura grezza
// di 0.396 arrotonda a 0.40 - uguale alla soglia - e con l'arrotondamento
// fatto PRIMA del confronto passava per 'ok'. Verificato anche contro
// l'oracolo Python: aggregate([WeightedScore(90, 0.396), WeightedScore(None,
// 0.604)]) -> (90.0, 0.4, 'insufficiente').
it('una copertura grezza appena sotto la soglia resta insufficiente anche se arrotonda a 0.40', () => {
const r = aggrega([{ punteggio: 90, peso: 0.396 }, { punteggio: null, peso: 0.604 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.40, 2); // il valore esposto arrotonda a 0.40, ma lo stato resta insufficiente
});
// Simmetrico: una copertura grezza appena SOPRA la soglia (0.404, che
// arrotonda anch'essa a 0.40) deve restare 'ok' - il fix non deve aver
// spostato la soglia dall'altra parte. Oracolo: aggregate([WeightedScore(90,
// 0.404), WeightedScore(None, 0.596)]) -> (90.0, 0.4, 'ok').
it('una copertura grezza appena sopra la soglia resta ok: il fix non ha spostato la soglia', () => {
const r = aggrega([{ punteggio: 90, peso: 0.404 }, { punteggio: null, peso: 0.596 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') expect(r.copertura).toBeCloseTo(0.40, 2);
});
});
describe('assi, macro e Fitness Age', () => {
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
it('un asse si calcola sui suoi sotto-domini', () => {
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
expect(r.stato).toBe('ok');
// 70*.25 + 60*.2 + 65*.2 + 80*.2 + 50*.15 = 66.0 (verificato anche a mano, non 66.25 come
// nel brief: la costante li' e' un refuso, corretto qui dopo averlo verificato col conto)
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.0, 1);
});
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
const assi = {
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
B: { stato: 'insufficiente', copertura: 0.1 } as const,
};
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
expect(r.stato).toBe('ok');
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
});
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
// 40 - (75 - 50) * 0.4 = 30
expect(r.fitnessAge).toBeCloseTo(30, 1);
});
it('senza dati sufficienti la Fitness Age non esiste', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
expect(r.composito.stato).toBe('insufficiente');
expect(r.fitnessAge).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione } from '../../src/lib/longevity/motore';
/**
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
* Dati sintetici: nessuna persona reale.
*/
describe('un profilo con una sola area misurata', () => {
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
expect(insufficienti).toBe(7);
expect(r.fitnessAge).toBeNull();
});
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
for (const [nome, asse] of Object.entries(r.assi)) {
if (asse.stato === 'insufficiente') {
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
}
}
});
/**
* Il caso misto: quello che la prima dashboard disegnera' davvero, non "tutto
* insufficiente". Realizza per intero la premessa del describe qui sopra la
* composizione corporea davvero misurata dalla Wellness Tower aggiungendo le
* due voci al registro (arrivano col piano degli import; qui bastano due righe,
* non serve aspettarle) e registrando le misure end-to-end.
*/
it('un asse valido (Composizione Corporea) e sei insufficienti, end-to-end', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0003','M')`).run();
db.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')`
).run(JSON.stringify({ best: 10, worst: 40 }));
db.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
VALUES ('wt_muscolo', 'Muscolo % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'muscolo', '2026-01-01')`
).run(JSON.stringify({ best: 50, worst: 30 }));
const s = apriSessione(db, { client_code: 'ISL-0003', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'wellness_tower', [
{ test_id: 'wt_grasso', valore_num: 15 },
{ test_id: 'wt_muscolo', valore_num: 45 },
]);
const r = calcolaSessione(db, s);
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
const insufficienti = Object.entries(r.assi)
.filter(([nome, a]) => nome !== 'Composizione Corporea' && a.stato === 'insufficiente');
expect(insufficienti.length).toBe(6);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
describe('curve del questionario, confrontate con l oracolo', () => {
it('la campana combacia su tutto il dominio', () => {
const casi = casiDi('score_bell_curve');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, low, pl, ph, high] = c.args as number[];
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
}
});
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
const casi = casiDi('score_decreasing');
expect(casi.length).toBeGreaterThan(40);
for (const c of casi) {
const [v, best, worst] = c.args as number[];
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
}
});
it('la crescente con plateau combacia', () => {
const casi = casiDi('score_increasing_plateau');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, worst, plateau] = c.args as number[];
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
}
});
it('la diretta per dieci combacia', () => {
const casi = casiDi('score_direct_x10');
expect(casi.length).toBeGreaterThan(10);
for (const c of casi) {
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
}
});
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
it('la diretta invertita e il complemento della diretta', () => {
for (const v of [0, 2.5, 5, 7.5, 10]) {
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
}
expect(curvaDirettaX10Invertita(0)).toBe(100);
expect(curvaDirettaX10Invertita(10)).toBe(0);
});
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
});
// Il ramo crescente non era esercitato da nessun test: l'ancoraggio (0, 20)
// — invece di (0, 100) — poteva essere invertito o sbagliato senza che
// niente diventasse rosso. Parametri veri da src/lib/longevity/registro.ts.
it('i gradini crescenti riproducono il prototipo: q_attivita e q_luce', () => {
// q_attivita: nessun zeroVal, i gradini partono già da (0, 20).
const stepsAttivita: [number, number][] = [[0, 20], [2, 50], [4, 80], [999, 100]];
expect(curvaGradini(0, stepsAttivita, undefined, false)).toBe(20);
expect(curvaGradini(2, stepsAttivita, undefined, false)).toBe(50);
expect(curvaGradini(4, stepsAttivita, undefined, false)).toBe(80);
expect(curvaGradini(999, stepsAttivita, undefined, false)).toBe(100);
expect(curvaGradini(1, stepsAttivita, undefined, false)).toBe(35); // interpolato fra (0,20) e (2,50)
expect(curvaGradini(3, stepsAttivita, undefined, false)).toBe(65); // interpolato fra (2,50) e (4,80)
// q_luce: zeroVal 20, gradini che partono da 0.5 — l'ancoraggio (0, 20)
// serve davvero per interpolare fra 0 (escluso, coperto da zeroVal) e 0.5.
const stepsLuce: [number, number][] = [[0.5, 60], [1, 90], [999, 100]];
expect(curvaGradini(0.5, stepsLuce, 20, false)).toBe(60);
expect(curvaGradini(1, stepsLuce, 20, false)).toBe(90);
expect(curvaGradini(999, stepsLuce, 20, false)).toBe(100);
expect(curvaGradini(0.75, stepsLuce, 20, false)).toBe(75); // interpolato fra (0.5,60) e (1,90)
expect(curvaGradini(0.25, stepsLuce, 20, false)).toBe(40); // interpolato fra l'ancoraggio (0,20) e (0.5,60)
});
// Non basta controllare che a zero il risultato sia il valore atteso: se
// zeroVal COINCIDE con l'ancoraggio di default (100 per il decrescente,
// 20 per il crescente, come in q_schermi e q_luce) il test passa anche
// cancellando dal codice la riga che gestisce lo zero. Qui zeroVal è
// deliberatamente diverso dall'ancoraggio, in entrambe le direzioni: se
// la riga sparisse, l'interpolazione a v=0 darebbe l'ancoraggio (100 o 20),
// non questi valori, e il test diventerebbe rosso.
it('zeroVal vince sui gradini anche quando NON coincide con l ancoraggio', () => {
// q_schermi, il caso reale: qui zeroVal (100) coincide con l'ancoraggio
// decrescente (100), quindi da solo NON è discriminante — resta utile
// come controllo del valore vero, non come prova dello zero speciale.
const stepsSchermi: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
expect(curvaGradini(0, stepsSchermi, 100, true)).toBe(100);
expect(curvaGradini(15, stepsSchermi, 100, true)).toBe(85);
// Discriminante, decrescente: ancoraggio di default sarebbe 100,
// zeroVal è 42. Senza la riga dello zero speciale risulterebbe 100.
const stepsDecrescenteDiscriminante: [number, number][] = [[10, 80], [30, 50], [999, 10]];
expect(curvaGradini(0, stepsDecrescenteDiscriminante, 42, true)).toBe(42);
// Discriminante, crescente: ancoraggio di default sarebbe 20,
// zeroVal è 55. Senza la riga dello zero speciale risulterebbe 20.
const stepsCrescenteDiscriminante: [number, number][] = [[5, 40], [20, 70], [999, 100]];
expect(curvaGradini(0, stepsCrescenteDiscriminante, 55, false)).toBe(55);
});
it('clamp e lerp si comportano come nell oracolo', () => {
expect(clamp(150)).toBe(100);
expect(clamp(-5)).toBe(0);
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
});
// round(x, 1) di Python arrotonda al pari sui pareggi ESATTI (banker's
// rounding), non sempre in su. Senza questi casi un arrotondamento naive
// (Math.round(x*10)/10, che su 86.25 darebbe 86.3) sarebbe indistinguibile
// dal corretto in tutti gli altri test: qui il pareggio e' deliberato.
it('arrotonda1 arrotonda al pari sui pareggi esatti, come round() di Python', () => {
expect(arrotonda1(86.25)).toBe(86.2); // 862 e' pari: resta 86.2, non sale a 86.3
expect(arrotonda1(86.75)).toBe(86.8); // 867 e' dispari: sale a 86.8, non resta 86.7
expect(arrotonda1(86.24)).toBe(86.2); // non un pareggio: arrotondamento normale, per basso
expect(arrotonda1(86.26)).toBe(86.3); // non un pareggio: arrotondamento normale, per alto
});
// 86.35 NON e' un pareggio vero: il double che gli sta dietro vale
// 86.34999999999999431..., quindi Python arrotonda (senza ambiguita') a
// 86.3. Una prima versione di arrotonda1, che moltiplicava per 10 e
// testava una tolleranza, ci cadeva: 86.35 * 10 arrotonda ESATTAMENTE a
// 863.5 in virgola mobile (un pareggio creato dalla moltiplicazione, non
// presente nel valore originale) e restituiva 86.4. Questo test blocca
// proprio quella regressione.
it('arrotonda1 non inventa un pareggio dove il double non ce l ha (86.35 -> 86.3, non un pareggio)', () => {
expect(arrotonda1(86.35)).toBe(86.3);
expect(arrotonda1(-86.35)).toBe(-86.3);
});
// Generalizzazione a `decimali` cifre (usata da cascata.ts per la copertura,
// a 2 decimali): stesso criterio dei test sopra, un gradino piu' in la'.
// 0.125 e 0.375 sono pareggi ESATTI a due decimali (double rappresentabili
// in binario senza resto: 1/8 e 3/8), verificati anche a mano con
// round(0.125, 2) e round(0.375, 2) in Python.
it('arrotonda1(x, 2) arrotonda al pari sui pareggi esatti a due decimali', () => {
expect(arrotonda1(0.125, 2)).toBe(0.12); // 12 e' pari: resta 0.12, non sale a 0.13
expect(arrotonda1(0.375, 2)).toBe(0.38); // 37 e' dispari: sale a 0.38, non resta 0.37
expect(arrotonda1(0.124, 2)).toBe(0.12); // non un pareggio: arrotondamento normale, per basso
expect(arrotonda1(0.126, 2)).toBe(0.13); // non un pareggio: arrotondamento normale, per alto
});
// 0.045 e' l'analogo a due decimali di 86.35: il double che gli sta dietro
// vale 0.044999999999999998..., quindi Python (e l'oracolo) arrotondano
// SENZA ambiguita' a 0.04. Ma 0.045 * 100 arrotonda ESATTAMENTE a 4.5 in
// virgola mobile - un pareggio creato dalla moltiplicazione, non presente
// nel valore originale - e un arrotondamento naive (Math.round(x*100)/100,
// la stessa `arrotonda2` locale rimossa da cascata.ts) ci sarebbe caduto,
// restituendo 0.05 invece di 0.04.
it('arrotonda1(x, 2) non inventa un pareggio dove il double non ce l ha (0.045 -> 0.04, non un pareggio)', () => {
expect(arrotonda1(0.045, 2)).toBe(0.04);
expect(arrotonda1(-0.045, 2)).toBe(-0.04);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as F from '../../src/lib/longevity/motore/test-fisici';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
const COPPIE: [string, (...a: never[]) => number][] = [
['score_fat_percent', F.scoreGrassoPercento as never],
['score_muscle_percent', F.scoreMuscoloPercento as never],
['score_whr', F.scoreWhr as never],
['score_vo2max', F.scoreVo2max as never],
['score_spo2', F.scoreSpo2 as never],
['score_blood_pressure', F.scorePressione as never],
['score_hrr', F.scoreRecuperoCardiaco as never],
['score_handgrip', F.scoreHandgrip as never],
['score_pushup', F.scorePushup as never],
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
['score_sit_to_stand_1min', F.scoreSitToStand as never],
['score_plank', F.scorePlank as never],
['score_back_scratch', F.scoreBackScratch as never],
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
['score_flamingo', F.scoreFlamingo as never],
['score_sit_and_reach', F.scoreSitAndReach as never],
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
['vo2max_from_mutt', F.vo2maxDaMutt as never],
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
];
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
for (const [nomePython, fnTs] of COPPIE) {
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
const casi = RIF.casi.filter((c) => c.fn === nomePython);
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
const divergenti: string[] = [];
for (const c of casi) {
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
if (Math.abs(ottenuto - c.atteso) > 0.05) {
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
}
}
expect(divergenti.slice(0, 5).join('\n')).toBe('');
});
}
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle DEFINITE NEL CODICE', () => {
// Prima versione di questo test: passava alla funzione le tabelle lette dagli
// argomenti del riferimento (c.args), non le costanti TIERS_* definite nel
// codice — le sei tabelle non erano confrontate contro l'oracolo da nessuno.
// Qui si usano SOLO le costanti del modulo: se una venisse alterata, nessun
// caso combacerebbe piu' con gli argomenti del riferimento e il test cade.
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
expect(casi.length).toBeGreaterThan(50);
const TABELLE: [string, [number, number][], [number, number][]][] = [
['bench', F.TIERS_BENCH_M, F.TIERS_BENCH_F],
['squat', F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
['row', F.TIERS_ROW_M, F.TIERS_ROW_F],
];
const perTabella: Record<string, number> = { bench: 0, squat: 0, row: 0 };
for (const c of casi) {
const [carico, peso, rip, tiersMRif, , sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
// Identifica QUALE tabella del codice questo caso sta esercitando confrontando
// gli argomenti del riferimento con le costanti vere — non il contrario.
const trovata = TABELLE.find(([, tm]) => JSON.stringify(tm) === JSON.stringify(tiersMRif));
expect(trovata, `nessuna tabella del codice combacia con gli argomenti del caso: carico ${carico} ${sesso}`).toBeDefined();
const [nome, tiersM, tiersF] = trovata!;
perTabella[nome]++;
const ottenuto = F.scoreSollevamentoSuPeso(carico, peso, rip, tiersM, tiersF, sesso);
expect(ottenuto, `${nome} carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
}
// Tutte e tre le tabelle sono state esercitate davvero, non solo dichiarate.
expect(perTabella.bench).toBeGreaterThan(0);
expect(perTabella.squat).toBeGreaterThan(0);
expect(perTabella.row).toBeGreaterThan(0);
});
it('le due funzioni escluse dal porting non esistono nel modulo', () => {
// Agilita: il cliente l'ha esclusa dallo score il 13/08 (scale non comparabili fra protocolli).
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
// score_generic_range_local: definita in fondo all'oracolo ma non chiamata da nessun peso.
expect((F as Record<string, unknown>).scoreGenericRangeLocal).toBeUndefined();
});
});
+305
View File
@@ -0,0 +1,305 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaAsse } from '../../src/lib/longevity/motore/cascata';
import { applicaCurva, calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
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;
}
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
describe('il motore legge le curve dal registro', () => {
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
});
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 0)).toBe(100);
expect(applicaCurva(v as never, 7)).toBe(100);
expect(applicaCurva(v as never, 14)).toBe(0);
});
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
.run(JSON.stringify({ best: 0, worst: 7 }));
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
});
it('un test disattivato non entra nel calcolo', () => {
// NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si
// aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del
// sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema
// Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 +
// questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza
// 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno
// ancora una voce nel registro): siccome un asse insufficiente non porta
// 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la
// suite lo ha confermato (verificato eseguendo il test: fallisce con le due
// stringhe uguali). Corretto qui usando 'q_attivita': NON e' l'unica voce
// del sotto-dominio 'questionario_lifestyle' (ne ha sei: q_attivita,
// q_alimentazione, q_sigarette, q_alcol_life, q_luce, q_schermi), ma in
// QUESTO fixture e' l'unica delle sei a cui si risponde. Disattivarla
// svuota davvero il sotto-dominio (nessun'altra voce lifestyle compilata a
// sostituirla), la copertura dell'asse "Stile di Vita & Sonno" (peso 1.00
// su quel solo sotto-dominio) cade da 1.00 a 0 e lo stato passa da 'ok' a
// 'insufficiente' — una differenza vera e osservabile. Se in futuro il
// fixture compilasse anche un'altra risposta lifestyle, questo test
// andrebbe aggiornato: la disattivazione non svuoterebbe piu' il
// sotto-dominio da sola.
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: {
q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0,
q_attivita: 5,
},
});
const prima = calcolaSessione(db, 1);
expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok');
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run();
const dopo = calcolaSessione(db, 1);
expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
});
it('una curva con i parametri mancanti nel registro non calcola un punteggio: restituisce null, mai 100', () => {
// Riproduce il difetto trovato in revisione: registro modificabile senza migrazioni,
// una riga con params vuoti o incompleti non deve mai dare il punteggio pieno.
const db = dbPronto();
// bell (q_ore_sonno): params svuotati del tutto.
const sonno = { ...voce(db, 'q_ore_sonno'), params: {} };
expect(applicaCurva(sonno as never, 3)).toBeNull(); // 3 ore di sonno: mai 100
// lin_dec (q_alcol_life): manca 'worst'.
const alcol = { ...voce(db, 'q_alcol_life'), params: { best: 7 } };
expect(applicaCurva(alcol as never, 40)).toBeNull(); // 40 unita/settimana: mai 100
// decstep (q_sigarette): steps mancanti.
const sigarette = { ...voce(db, 'q_sigarette'), params: {} };
expect(applicaCurva(sigarette as never, 40)).toBeNull(); // 40 sigarette/giorno: mai 100
});
it('una curva coi parametri completi continua a funzionare (il controllo non e troppo severo)', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
expect(applicaCurva(v as never, 8)).toBe(100);
});
it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => {
// q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle',
// che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore
// dell'asse coincide col valore del sotto-dominio, quindi la media si legge diretta.
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
// q_attivita curva incstep, steps [[0,20],[2,50],[4,80],[999,100]]: valore 3 ->
// lerp fra (2,50) e (4,80) a meta' strada -> punteggio 65.
// q_alimentazione curva x10: valore 6 -> punteggio 60.
// media = (65+60)/2 = 62.5: diversa dalla somma (125) e da ciascun addendo (65, 60).
risposte: { q_attivita: 3, q_alimentazione: 6 },
});
const r = calcolaSessione(db, 1);
const asse = r.assi['Stile di Vita & Sonno'];
expect(asse.stato).toBe('ok');
if (asse.stato === 'ok') expect(asse.valore).toBe(62.5);
});
});
describe('calcolo e congelamento di una sessione', () => {
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
});
const r = calcolaSessione(db, 1);
expect(Object.keys(r.assi).length).toBe(7);
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
});
it('congela i punteggi con le DUE versioni', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 7 },
});
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
expect(righe.length).toBeGreaterThan(0);
for (const r of righe) {
expect(r.model_version).toBe(MODEL_VERSION);
expect(r.quest_version).toBe('v1.0');
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
}
});
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
const db = dbPronto();
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
expect(pesi.handgrip).toBe(0.25);
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
});
it('senza eta la Fitness Age e null, non un numero negativo dichiarato valido', () => {
// Bug trovato in revisione: eta_alla_data e' nullable nello schema (l'apertura di
// una sessione e il salvataggio di un questionario accettano l'eta opzionale), ma
// il motore la leggeva con un ripiego a zero. Qui costruiamo un composito Fitness
// Age davvero 'ok' (copertura esattamente 0.40: cardio 0.30 + composizione 0.10),
// cosi' il difetto e' osservabile: col ripiego a zero l'eta anagrafica diventa 0
// e la formula "eta - (composito - 50) * 0.4" esce negativa ma con stato 'ok'.
// Due voci fisiche aggiunte al registro qui (arriveranno davvero col piano degli
// import): servono solo a rendere il composito calcolabile senza aspettarle.
const db = dbPronto();
db.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
VALUES ('wt_vo2max', 'VO2max stimato', 'num', 'lin_dec', ?, 'Cardio-Respiratorio', 'vo2max', '2026-01-01')`
).run(JSON.stringify({ best: 40, worst: 20 }));
db.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')`
).run(JSON.stringify({ best: 10, worst: 40 }));
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup' }); // nessuna eta
registraMisure(db, s, 'wellness_tower', [
{ test_id: 'wt_vo2max', valore_num: 40 }, // curva a 100
{ test_id: 'wt_grasso', valore_num: 10 }, // curva a 100
]);
const r = calcolaSessione(db, s);
expect(r.assi['Cardio-Respiratorio'].stato).toBe('ok');
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
expect(r.fitnessAgeCopertura).toBe(0.4); // composito 'ok': la soglia e' inclusiva
expect(r.fitnessAge).toBeNull(); // ma senza eta anagrafica, la Fitness Age non esiste
});
it('una misura fuori range non entra nello score (§9 della spec)', () => {
// Caso letterale della spec: una risposta 0-10 di 999 e' fuori dal fondoscala
// dichiarato (come la capacita' vitale di 5148 mL su un fondoscala 2000-3000),
// si scrive e si marca, ma non deve produrre un punteggio.
// q_alimentazione e' l'UNICA voce del sotto-dominio 'questionario_lifestyle'
// (peso 1.00, unico elemento dell'asse "Stile di Vita & Sonno"): se la misura
// fuori range entrasse nel calcolo l'asse risulterebbe 'ok' con valore 100
// (curvaDirettaX10(999) clampato); esclusa, il sotto-dominio resta scoperto e
// l'asse e' 'insufficiente'. E' la differenza osservabile che prova il filtro.
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'questionario', eta_alla_data: 35 });
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_alimentazione', valore_num: 999 }]);
expect(esito.fuoriRange).toEqual(['q_alimentazione']);
const r = calcolaSessione(db, s);
expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
const riga = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_alimentazione'`).get() as { fuori_range: number };
expect(riga.fuori_range).toBe(1);
});
it('scrive nella tabella score un valore vero e la copertura vera, non solo lo stato', () => {
// Sabotaggi che questo test deve intercettare: tutti i valori scritti a null, o
// tutte le coperture forzate a 1. Serve un caso con copertura NON piena (0.5, non
// 1.0) perche' un "sempre 1" non si distinguerebbe da un caso gia' pieno.
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
// q_energia_media (x10) e' l'unica voce misurata del sotto-dominio
// 'questionario_energia_stress' (peso 0.50 nell'asse, l'altro 0.50 e' hrv,
// non ancora nel registro): copertura 0.50, non 1.
risposte: { q_energia_media: 8 },
});
const risultato = calcolaSessione(db, 1);
salvaScore(db, 1, risultato);
const riga = db.prepare(
`SELECT valore, copertura, stato FROM score WHERE tipo = 'asse' AND elemento = ?`
).get('Energia & Regolazione Stress') as { valore: number; copertura: number; stato: string };
expect(riga.stato).toBe('ok');
expect(riga.valore).toBe(80); // curvaDirettaX10(8) = 80, unico contributo
expect(riga.copertura).toBe(0.5); // 0.50 di peso disponibile su 1.00
});
});
describe('leggiScore legge dalla tabella, tipato come Punteggio', () => {
it('restituisce assi/macro/fitness_age come Punteggio: nessun valore leggibile da un insufficiente', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_attivita: 3, q_alimentazione: 6 },
});
const risultato = calcolaSessione(db, 1);
salvaScore(db, 1, risultato);
const letto = leggiScore(db, 1);
expect(letto.assi['Stile di Vita & Sonno'].stato).toBe('ok');
if (letto.assi['Stile di Vita & Sonno'].stato === 'ok') {
expect(letto.assi['Stile di Vita & Sonno'].valore).toBe(62.5);
}
expect(letto.assi['Forza & Struttura'].stato).toBe('insufficiente');
expect((letto.assi['Forza & Struttura'] as { valore?: number }).valore).toBeUndefined();
});
it('due calcoli sulla stessa sessione non si sovrascrivono: leggiScore prende l ultimo', () => {
// Decisione presa: salvaScore non ha vincolo di unicita', la storia si tiene.
// leggiScore deve dichiarare esplicitamente nella query il criterio dell'ultimo
// calcolo (non l'ordine naturale delle righe).
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_attivita: 3, q_alimentazione: 6 }, // -> 62.5
});
salvaScore(db, 1, calcolaSessione(db, 1));
// Aggiunge una risposta lifestyle: cambia la media del sotto-dominio, ricalcola
// e salva una SECONDA volta sulla stessa sessione.
registraMisure(db, 1, 'questionario', [{ test_id: 'q_sigarette', valore_num: 0 }]); // curva decstep, v=0 -> 100
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(
`SELECT COUNT(*) n FROM score WHERE sessione_id = 1 AND tipo = 'asse' AND elemento = 'Stile di Vita & Sonno'`
).get() as { n: number };
expect(righe.n).toBe(2); // la storia si tiene: due righe, non una sovrascritta
const letto = leggiScore(db, 1);
const asse = letto.assi['Stile di Vita & Sonno'];
expect(asse.stato).toBe('ok');
// media di tre voci ora (65+60+100)/3 = 75, non piu' 62.5 del primo calcolo
if (asse.stato === 'ok') expect(asse.valore).toBe(75);
});
});
describe('i pesi sono legati alla versione del modello, non fusi insieme', () => {
it('togliendo il filtro sulla versione dalla lettura dei pesi, due versioni diverse darebbero lo stesso punteggio: qui devono differire', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
// Seconda versione del modello, con pesi DIVERSI per lo stesso asse.
const ins = db.prepare(
`INSERT INTO pesi (model_version, livello, contenitore, elemento, peso) VALUES (?, 'asse', 'Forza & Struttura', ?, ?)`
);
ins.run('v2.0', 'handgrip', 0.9);
ins.run('v2.0', 'core', 0.1);
const punteggi = { handgrip: 100, spinta: 0, trazione: 0, arti_inferiori: 0, core: 0 };
const v1 = calcolaAsse(pesiDi(db, 'v1.0', 'asse', 'Forza & Struttura'), punteggi);
const v2 = calcolaAsse(pesiDi(db, 'v2.0', 'asse', 'Forza & Struttura'), punteggi);
expect(v1.stato).toBe('ok');
expect(v2.stato).toBe('ok');
if (v1.stato === 'ok' && v2.stato === 'ok') {
expect(v1.valore).toBe(25); // 100*0.25 (v1.0)
expect(v2.valore).toBe(90); // 100*0.9 (v2.0)
expect(v1.valore).not.toBe(v2.valore);
}
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione, 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('compilazione del questionario', () => {
it('salva le risposte punteggiate come misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 },
});
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`)
.get(s) as { n: number };
expect(n.n).toBe(3);
});
it('marchia la compilazione con la versione del questionario', () => {
const db = dbPronto();
const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as
{ tipo: string; quest_version: string };
expect(row.tipo).toBe('questionario');
expect(row.quest_version).toBe(QUEST_VERSION);
});
it('i campi liberi vanno in profilo_note, non fra le misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6 },
liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' },
});
const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`)
.all(s) as { campo_id: string; testo: string }[];
expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']);
const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[];
expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']);
});
it('ogni compilazione e un record nuovo, non un aggiornamento', () => {
const db = dbPronto();
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } });
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number };
expect(n.n).toBe(2);
});
it('una risposta con id sconosciuto fa fallire la compilazione intera', () => {
const db = dbPronto();
expect(() =>
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6, q_domanda_nuova: 3 },
})
).toThrow(/q_domanda_nuova/);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(0);
});
it('fallimento a metà non lascia traccia: sessione, misure e note sono atomiche', () => {
const db = dbPronto();
// Passa due campi liberi: il primo valido (farmaci), il secondo con null (obiettivi).
// Il primo insert su profilo_note avviene (per farmaci: 'nessuno'), poi fallisce su
// obiettivi quando tenta di eseguire testo.trim() su un null, lanciando un TypeError.
// Il rollback della transazione deve annullare anche il primo insert già avvenuto.
expect(() =>
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6 },
liberi: { farmaci: 'nessuno', obiettivi: null as any }, // null causa TypeError su .trim()
})
).toThrow();
// Verifica che la transazione sia stata rollback: niente sessione, niente misure, niente note.
// Se il rollback non funzionasse, almeno il record di farmaci resterebbe in profilo_note.
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 note = db.prepare(`SELECT COUNT(*) n FROM profilo_note`).get() as { n: number };
expect(sessioni.n).toBe(0);
expect(misure.n).toBe(0);
expect(note.n).toBe(0);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro';
describe('registro dei test', () => {
it('carica i 20 campi punteggiati del questionario', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number };
expect(q.n).toBe(20);
});
it('alcol_life porta la soglia 7-14 voluta da Nicola', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`)
.get() as { curva: string; params: string };
expect(row.curva).toBe('lin_dec');
expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 });
});
it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`)
.get() as { curva: string };
expect(row.curva).toBe('x10_inv');
});
it('un test disattivato non e piu attivo dopo la sua data', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run();
const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id);
expect(attivi).not.toContain('q_sigarette');
expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette');
});
it('esisteTest distingue un test vero da uno inventato', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
expect(esisteTest(db, 'q_ore_sonno')).toBe(true);
expect(esisteTest(db, 'q_inventato')).toBe(false);
});
it('i pesi di ogni asse sommano a 1', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi
WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore`
).all() as { contenitore: string; tot: number }[];
expect(righe.length).toBe(7);
for (const r of righe) expect(r.tot).toBe(1);
});
it('i pesi sono legati alla versione del modello', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
seedPesi(db, 'v2.0');
const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number };
expect(n.n).toBe(2);
});
it('il plank pesa su due assi: va visto, non nascosto', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')`
).all() as { contenitore: string }[];
expect(righe.map((r) => r.contenitore).sort())
.toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']);
});
});
+22
View File
@@ -0,0 +1,22 @@
# L'oracolo del motore di calcolo
`isl_scoring_engine.py` è il motore di punteggio **scritto dal cliente** (In-Sanity Lab),
ricevuto il 2026-08-21. Non è codice nostro e non va modificato: sta qui perché è
l'**oracolo** contro cui verifichiamo il porting in TypeScript.
Il porting delle curve è la parte più delicata della piattaforma: un coefficiente
trascritto male produce un punteggio sanitario sbagliato che sembra plausibile e che
nessun test funzionale intercetterebbe, perché diventerebbe il valore "atteso" di tutto
ciò che viene dopo. La fedeltà non è garantita dalla trascrizione, ma dal confronto
numerico: `genera-riferimento.py` esegue questo motore su una griglia di casi e scrive
`riferimento.json`, che il test TypeScript rilegge per confrontare i due risultati.
Serve `python3` (solo libreria standard: il motore importa unicamente `dataclasses` e
`typing`).
## Rigenerare il riferimento
python3 tests/longevity/riferimento/genera-riferimento.py
Va rifatto solo se il cliente manda una versione nuova del motore. In quel caso il
confronto dirà da sé quali curve sono cambiate.
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Esegue il motore del cliente su una griglia fitta di ingressi e scrive i risultati
in riferimento.json. È il metro contro cui si misura il porting in TypeScript:
non serve a provare che l'oracolo è giusto, ma che il nostro porting gli è fedele.
Uso: python3 tests/longevity/riferimento/genera-riferimento.py
"""
import json
import pathlib
import sys
QUI = pathlib.Path(__file__).parent
sys.path.insert(0, str(QUI))
import isl_scoring_engine as m
casi = []
def prova(fn_nome, *args):
"""Esegue una funzione dell'oracolo e registra ingressi e uscita."""
fn = getattr(m, fn_nome)
casi.append({"fn": fn_nome, "args": list(args), "atteso": fn(*args)})
def griglia(inizio, fine, passo):
"""Valori da inizio a fine compreso, con arrotondamento pulito."""
n, v = [], inizio
while v <= fine + 1e-9:
n.append(round(v, 4))
v += passo
return n
# --- curve generiche del questionario: tutto il dominio, non un campione ---
for v in griglia(0, 14, 0.5):
prova("score_bell_curve", v, 4, 7, 9, 12) # q_ore_sonno
prova("score_decreasing", v, 0, 7) # q_caffeina e gemelle
prova("score_decreasing", v, 7, 14) # q_alcol_life: la soglia voluta
for v in griglia(0, 10, 0.5):
prova("score_direct_x10", v)
for v in griglia(0, 7, 0.5):
prova("score_increasing_plateau", v, 0, 4)
prova("score_increasing_plateau", v, 2, 6) # worst != 0: l'offset e' l'unico parametro a rischio refuso
# --- composizione corporea: attorno ai confini delle bande ---
for sesso in ("M", "F"):
for v in griglia(0, 45, 0.5):
prova("score_fat_percent", v, sesso)
for v in griglia(20, 60, 0.5):
prova("score_muscle_percent", v, sesso)
for v in griglia(0.5, 1.3, 0.01):
prova("score_whr", v, sesso)
# --- cardio ---
for v in griglia(85, 100, 1):
prova("score_spo2", v)
for sbp in griglia(90, 200, 5):
for dbp in griglia(50, 120, 5):
prova("score_blood_pressure", sbp, dbp)
for v in griglia(0, 40, 1):
prova("score_hrr", v)
for sesso in ("M", "F"):
for eta in griglia(20, 75, 5):
for v in griglia(15, 70, 2.5):
prova("score_vo2max", v, eta, sesso)
for v in griglia(120, 200, 5):
prova("vo2max_from_step_test", v, "M")
prova("vo2max_from_step_test", v, "F")
for v in griglia(100, 300, 10):
prova("vo2max_from_mutt", v)
for w in griglia(50, 200, 10):
prova("vo2max_from_milfit", w, 75)
# caso base + variazione di un parametro alla volta: un punto solo non separa i 5 coefficienti
BASE_2KM = (15.5, 130, 40, 24) # tempo_min, hr, eta, bmi
prova("vo2max_from_2km_walk", *BASE_2KM)
for tempo in griglia(10, 25, 2.5):
prova("vo2max_from_2km_walk", tempo, BASE_2KM[1], BASE_2KM[2], BASE_2KM[3])
for hr in griglia(100, 160, 10):
prova("vo2max_from_2km_walk", BASE_2KM[0], hr, BASE_2KM[2], BASE_2KM[3])
for eta in griglia(20, 75, 5):
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], eta, BASE_2KM[3])
for bmi in griglia(18, 32, 2):
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], BASE_2KM[2], bmi)
# --- forza ---
for sesso in ("M", "F"):
for eta in griglia(25, 75, 5):
for v in griglia(10, 70, 2.5):
prova("score_handgrip", v, eta, sesso)
for v in griglia(0, 60, 2):
prova("score_pushup", v, eta, sesso)
for v in griglia(0, 250, 5):
prova("score_plank", v, sesso)
confini_plank = [79, 97, 122, 157, 201] if sesso == "M" else [35, 63, 84, 108, 142]
for v in confini_plank: # confini esatti delle bande: costano zero, tolgono ogni dubbio
prova("score_plank", v, sesso)
for v in griglia(0, 120, 5):
prova("score_flexed_arm_hang", v, sesso)
for v in griglia(0, 60, 2):
prova("score_sit_to_stand_1min", v, 40, sesso)
for v in griglia(-30, 30, 1):
prova("score_sit_and_reach", v, sesso)
for eta in griglia(25, 75, 5):
for v in griglia(-30, 20, 2):
prova("score_back_scratch", v, eta, sesso)
# --- sollevamenti sui rapporti col peso corporeo ---
for sesso in ("M", "F"):
for carico in griglia(20, 200, 10):
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_BENCH_M, m.TIERS_BENCH_F, sesso)
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_SQUAT_M, m.TIERS_SQUAT_F, sesso)
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_ROW_M, m.TIERS_ROW_F, sesso)
# --- stabilità ---
for v in griglia(0, 30, 1):
prova("score_flamingo", v)
for a in griglia(0, 180, 10):
prova("score_shoulder_mobility_wt", a, a)
uscita = QUI / "riferimento.json"
uscita.write_text(json.dumps(
{"generato_da": "isl_scoring_engine.py", "casi": casi},
indent=1, ensure_ascii=False,
))
print(f"{len(casi)} casi scritti in {uscita}")
@@ -0,0 +1,537 @@
"""
IN-SANITY LAB Motore di calcolo Longevity Score
===================================================
Implementa la cascata a 4 livelli definita nel foglio "Pesi e Formule" del
Registro Test:
1. Sotto-metrica grezza -> punteggio 0-100 (normalizzazione test-specifica)
2. Punteggi sotto-metrica -> punteggio Asse (media pesata, rinormalizzata
se mancano dati)
3. Punteggi Asse -> 3 Macro-score (Performance / Energy / Recovery)
4. Punteggi Asse -> Fitness Age
Regola di copertura (uguale a ogni livello): se un elemento manca, il suo
peso si ridistribuisce sugli elementi disponibili. Se la copertura di peso
disponibile scende sotto il 40%, l'elemento calcolato viene marcato
'insufficiente' invece che pieno.
Questo modulo NON include l'interfaccia (dashboard) - produce solo i numeri.
Si integra con:
- parse_wellness_tower.py (dati automatici Composizione/Cardio/Recupero)
- analyze_calibre.py (VO2max diretto, gia' esistente)
- inserimento manuale / futura web app (resto dei test)
Uso rapido: vedi la funzione demo() in fondo al file.
"""
from dataclasses import dataclass, field
from typing import Optional
MIN_COVERAGE = 0.40 # sotto questa soglia di peso disponibile, l'elemento e' 'insufficiente'
# =========================================================================
# LIVELLO 1 — Normalizzazione sotto-metrica -> 0-100
# =========================================================================
def clamp(x, lo=0, hi=100):
return max(lo, min(hi, x))
def _lerp(x, x0, x1, y0, y1):
if x1 == x0:
return y0
t = (x - x0) / (x1 - x0)
t = max(0, min(1, t))
return y0 + t * (y1 - y0)
# --- Composizione Corporea -------------------------------------------------
def score_fat_percent(fat_pct, sex):
"""Curva a campana ACE. Il picco e' su Atleti/Fitness, non sul grasso piu basso."""
m = sex.strip().lower().startswith("m")
bands = [
(0, 2, 40), (2, 5, 65), (5, 13, 100), (13, 17, 90), (17, 24, 65), (24, 35, 30), (35, 200, 10),
] if m else [
(0, 10, 40), (10, 13, 65), (13, 20, 100), (20, 24, 90), (24, 31, 65), (31, 40, 30), (40, 200, 10),
]
for lo, hi, val in bands:
if lo <= fat_pct < hi:
return val
return 50
def score_muscle_percent(muscle_pct, sex):
"""Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device)."""
lo, hi = (42.9, 52.4) if sex.strip().lower().startswith("m") else (37.8, 46.2)
if muscle_pct >= hi:
return 100
if muscle_pct <= lo - 10:
return 20
return round(_lerp(muscle_pct, lo - 10, hi, 20, 100), 1)
def score_whr(whr, sex):
threshold = 0.90 if sex.strip().lower().startswith("m") else 0.85
if whr <= threshold - 0.10:
return 100
if whr <= threshold:
return round(_lerp(whr, threshold - 0.10, threshold, 100, 60), 1)
return clamp(round(60 - (whr - threshold) * 300, 1))
# --- Cardio-Respiratorio ----------------------------------------------------
def vo2max_from_step_test(hr_recovery, sex):
"""Queen's College Step Test (McArdle et al. 1972)."""
if sex.strip().lower().startswith("m"):
return 111.33 - 0.42 * hr_recovery
return 65.81 - 0.1847 * hr_recovery
def vo2max_from_2km_walk(tempo_min, hr, eta, bmi):
"""UKK 2km Walking Test (Laukkanen/Oja)."""
return 116.2 - 2.98 * tempo_min - 0.11 * hr - 0.14 * eta - 0.39 * bmi
def vo2max_from_mutt(vam_m_min):
"""Formula ACSM su VAM raggiunta al tapis. Etichettare come 'teorico/stimato'."""
return 0.2 * vam_m_min + 3.5
def vo2max_from_milfit(watt, peso_kg):
"""Formula ACSM cicloergometro. Etichettare come 'teorico/stimato'. Valida 50-200W."""
return (10.8 * watt) / peso_kg + 7
def score_vo2max(vo2max, eta, sex):
"""ACSM/FRIEND Registry, bande approssimate per fascia d'eta e sesso."""
m = sex.strip().lower().startswith("m")
# soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
good_thresholds_m = {20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30}
good_thresholds_f = {20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23}
table = good_thresholds_m if m else good_thresholds_f
decade = min(70, max(20, (eta // 10) * 10))
good = table[decade]
poor = good * 0.65
superior = good * 1.35
if vo2max <= poor:
return clamp(round(_lerp(vo2max, 0, poor, 10, 40), 1))
if vo2max <= good:
return round(_lerp(vo2max, poor, good, 40, 70), 1)
return clamp(round(_lerp(vo2max, good, superior, 70, 100), 1))
def score_spo2(spo2_pct):
"""Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico."""
if spo2_pct >= 97:
return 100
if spo2_pct >= 95:
return 85
if spo2_pct >= 90:
return 50
return 15
# --- Recupero & Sistema Nervoso --------------------------------------------
def score_blood_pressure(sbp, dbp):
"""ESC/ESH 2018. Vale la categoria piu alta tra sistolica e diastolica."""
if sbp >= 180 or dbp >= 110:
return 5
if sbp >= 160 or dbp >= 100:
return 25
if sbp >= 140 or dbp >= 90:
return 45
if sbp >= 130 or dbp >= 85:
return 70
if sbp >= 120 or dbp >= 80:
return 90
return 100
def score_hrr(calo_bpm_1min):
"""Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999)."""
if calo_bpm_1min >= 12:
return clamp(round(_lerp(calo_bpm_1min, 12, 30, 70, 100), 1))
return clamp(round(_lerp(calo_bpm_1min, 0, 12, 20, 70), 1))
# --- Forza & Struttura -------------------------------------------------------
def score_handgrip(kg, eta, sex):
"""Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'eta."""
m = sex.strip().lower().startswith("m")
# ancore (eta, valore medio kg) approssimate dalla letteratura citata
anchors_m = [(25, 49.7), (40, 46), (60, 38), (75, 30)]
anchors_f = [(25, 30), (40, 28), (60, 24), (75, 18.7)]
anchors = anchors_m if m else anchors_f
eta_c = clamp(eta, 25, 75)
for i in range(len(anchors) - 1):
a_eta, a_val = anchors[i]
b_eta, b_val = anchors[i + 1]
if a_eta <= eta_c <= b_eta:
mean_val = _lerp(eta_c, a_eta, b_eta, a_val, b_val)
break
else:
mean_val = anchors[-1][1]
# media = punteggio 70; +/- 40% della media copre la banda 20-100
ratio = kg / mean_val if mean_val else 1
return clamp(round(_lerp(ratio, 0.5, 1.5, 20, 100), 1))
def score_pushup(reps, eta, sex):
"""ACSM/CSEP, bande approssimate per decade."""
m = sex.strip().lower().startswith("m")
base_good_m, base_good_f = 22, 15 # 35-39 anni, 'buono' minimo
decade_offset = max(0, (eta - 35) // 10) * 2.5 # calo ~2.5 rip/decade dopo i 35
good = (base_good_m if m else base_good_f) - decade_offset
superior = good * 1.6
poor = good * 0.5
if reps <= poor:
return clamp(round(_lerp(reps, 0, poor, 10, 40), 1))
if reps <= good:
return round(_lerp(reps, poor, good, 40, 70), 1)
return clamp(round(_lerp(reps, good, superior, 70, 100), 1))
def score_bw_ratio_lift(carico_kg, peso_corporeo_kg, rip, tiers_m, tiers_f, sex):
"""Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite)."""
rm1 = carico_kg * 36 / (37 - rip) # Brzycki
ratio = rm1 / peso_corporeo_kg
tiers = tiers_m if sex.strip().lower().startswith("m") else tiers_f
# tiers = [(soglia_ratio, punteggio), ...] crescente
prev_r, prev_s = 0, 10
for r, s in tiers:
if ratio <= r:
return round(_lerp(ratio, prev_r, r, prev_s, s), 1)
prev_r, prev_s = r, s
return 100
TIERS_BENCH_M = [(0.5, 30), (1.0, 60), (1.25, 80), (1.5, 100)]
TIERS_BENCH_F = [(0.3, 30), (0.6, 60), (0.75, 80), (1.0, 100)]
TIERS_SQUAT_M = [(0.75, 30), (1.5, 60), (1.75, 80), (2.0, 100)]
TIERS_SQUAT_F = [(0.5, 30), (1.1, 60), (1.3, 80), (1.5, 100)]
TIERS_ROW_M = [(0.45, 30), (0.70, 60), (0.95, 80), (1.20, 100)]
TIERS_ROW_F = [(0.30, 30), (0.45, 60), (0.60, 80), (0.80, 100)]
def score_flexed_arm_hang(sec, sex):
good, superior = (45, 70) if sex.strip().lower().startswith("m") else (25, 50)
if sec <= good * 0.4:
return clamp(round(_lerp(sec, 0, good * 0.4, 10, 40), 1))
if sec <= good:
return round(_lerp(sec, good * 0.4, good, 40, 70), 1)
return clamp(round(_lerp(sec, good, superior, 70, 100), 1))
def score_sit_to_stand_1min(reps, eta, sex, bmi=24):
"""Reference equation adulti 18-95 (Zalewski et al.-style)."""
sex_code = 0 if sex.strip().lower().startswith("m") else 1
predicted = 61.53 - 0.34 * eta - 3.57 * sex_code - 0.33 * bmi
ratio = reps / predicted if predicted else 1
return clamp(round(_lerp(ratio, 0.5, 1.3, 20, 100), 1))
def score_plank(sec, sex):
bands_m = [(79, 20), (97, 40), (122, 55), (157, 75), (201, 90)]
bands_f = [(35, 20), (63, 40), (84, 55), (108, 75), (142, 90)]
bands = bands_m if sex.strip().lower().startswith("m") else bands_f
prev_t, prev_s = 0, 10
for t, s in bands:
if sec <= t:
return round(_lerp(sec, prev_t, t, prev_s, s), 1)
prev_t, prev_s = t, s
return 100
# --- Stabilità & Mobilità ----------------------------------------------------
def score_back_scratch(cm, eta, sex):
"""Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato solido),
estrapolato linearmente (~4cm/5 anni) per le altre eta. Approssimazione da
dichiarare, non tabella completa."""
m = sex.strip().lower().startswith("m")
anchor_eta, anchor_cm = 62, (-8.6 if m else -1.8)
decline_per_year = 0.8 # cm peggioramento per anno di eta in piu
expected = anchor_cm - (eta - anchor_eta) * decline_per_year
# punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
diff = cm - expected
return clamp(round(_lerp(diff, -15, 15, 20, 100), 1))
def score_shoulder_mobility_wt(outreach_deg, buckling_deg):
"""Wellness Tower, riferimento 180 gradi (outreach/buckling)."""
avg = (outreach_deg + buckling_deg) / 2
return clamp(round(avg / 180 * 100, 1))
def score_flamingo(cadute):
"""Decrescente: meno cadute = meglio."""
if cadute <= 3:
return 100
if cadute <= 7:
return round(_lerp(cadute, 3, 7, 100, 80), 1)
if cadute <= 15:
return round(_lerp(cadute, 7, 15, 80, 50), 1)
return clamp(round(_lerp(cadute, 15, 30, 50, 10), 1))
def score_sit_and_reach(cm, sex):
median = 24 if sex.strip().lower().startswith("m") else 31
return clamp(round(_lerp(cm, median - 20, median + 10, 20, 100), 1))
def score_agility_ms(ms, eta, sex):
"""Blomkvist et al. 2017, decrescente (meno ms = meglio)."""
base = 250 if sex.strip().lower().startswith("m") else 265
decade_add = max(0, (eta - 30) // 10) * 35
expected = base + decade_add
ratio = expected / ms if ms else 1
return clamp(round(_lerp(ratio, 0.7, 1.3, 20, 100), 1))
# --- Questionario: curve generiche -----------------------------------------
def score_bell_curve(value, low, peak_low, peak_high, high):
"""Curva a campana: picco tra peak_low e peak_high, decresce ai lati."""
if peak_low <= value <= peak_high:
return 100
if value < peak_low:
return clamp(round(_lerp(value, low, peak_low, 10, 100), 1))
return clamp(round(_lerp(value, peak_high, high, 100, 10), 1))
def score_decreasing(value, best, worst):
return clamp(round(_lerp(value, best, worst, 100, 0), 1))
def score_increasing_plateau(value, worst, plateau_start):
return clamp(round(_lerp(value, worst, plateau_start, 20, 100), 1))
def score_direct_x10(value_0_10):
return clamp(round(value_0_10 * 10, 1))
# =========================================================================
# LIVELLO 2 — Sotto-metriche -> punteggio Asse
# =========================================================================
@dataclass
class WeightedScore:
score: Optional[float] # None se dato mancante
weight: float # peso nominale (dalla config)
def aggregate(items: list[WeightedScore]):
"""Rinormalizza sui pesi disponibili. Ritorna (punteggio, copertura, stato)."""
total_weight = sum(i.weight for i in items)
available = [i for i in items if i.score is not None]
available_weight = sum(i.weight for i in available)
coverage = available_weight / total_weight if total_weight else 0
if not available:
return None, 0.0, "insufficiente"
weighted_sum = sum(i.score * i.weight for i in available)
score = weighted_sum / available_weight
status = "insufficiente" if coverage < MIN_COVERAGE else "ok"
return round(score, 1), round(coverage, 2), status
# Config pesi sotto-dominio -> asse (dal foglio "Pesi e Formule")
AXIS_SUBDOMAIN_WEIGHTS = {
"Forza & Struttura": {
"handgrip": 0.25, "spinta": 0.20, "trazione": 0.20, "arti_inferiori": 0.20, "core": 0.15,
},
"Composizione Corporea": {
"grasso": 0.40, "muscolo": 0.35, "whr": 0.25,
},
"Cardio-Respiratorio": {
"vo2max": 0.60, "spirometria": 0.20, "wellness_tower_cardio": 0.20,
},
"Recupero & Sistema Nervoso": {
"hrv": 0.50, "pressione": 0.15, "hrr": 0.15, "questionario_sonno": 0.20,
},
"Energia & Regolazione Stress": {
"hrv": 0.50, "questionario_energia_stress": 0.50,
},
"Stabilità & Mobilità Funzionale": {
"flamingo": 0.35, "sit_and_reach": 0.25, "plank": 0.20, "back_scratch": 0.14, "wellness_tower_shoulder": 0.06,
},
"Stile di Vita & Sonno": {
"questionario_lifestyle": 1.00,
},
}
# Config pesi asse -> macro-score
MACRO_SCORE_WEIGHTS = {
"PERFORMANCE": {
"Forza & Struttura": 0.25, "Cardio-Respiratorio": 0.25, "Composizione Corporea": 0.15,
"Stabilità & Mobilità Funzionale": 0.20, "Recupero & Sistema Nervoso": 0.10,
"Energia & Regolazione Stress": 0.05, "Stile di Vita & Sonno": 0.0,
},
"ENERGY": {
"Energia & Regolazione Stress": 0.30, "Stile di Vita & Sonno": 0.20,
"Recupero & Sistema Nervoso": 0.25, "Cardio-Respiratorio": 0.15,
"Composizione Corporea": 0.10, "Forza & Struttura": 0.0, "Stabilità & Mobilità Funzionale": 0.0,
},
"RECOVERY": {
"Recupero & Sistema Nervoso": 0.45, "Energia & Regolazione Stress": 0.15,
"Stile di Vita & Sonno": 0.20, "Stabilità & Mobilità Funzionale": 0.15,
"Forza & Struttura": 0.05, "Cardio-Respiratorio": 0.0, "Composizione Corporea": 0.0,
},
}
# Config pesi Fitness Age (nota: Handgrip ed HRV entrano isolati, non tramite l'intero asse)
FITNESS_AGE_WEIGHTS = {
"cardio": 0.30, "handgrip_isolato": 0.20, "hrv_isolato": 0.20,
"forza_resto": 0.15, "composizione": 0.10, "stabilita": 0.05,
}
def compute_axis(axis_name, subdomain_scores: dict):
"""subdomain_scores: {nome_sottodominio: score_0_100 or None}"""
weights = AXIS_SUBDOMAIN_WEIGHTS[axis_name]
items = [WeightedScore(subdomain_scores.get(k), w) for k, w in weights.items()]
score, coverage, status = aggregate(items)
return {"score": score, "coverage": coverage, "status": status}
# =========================================================================
# LIVELLO 3 — 7 Assi -> 3 Macro-score
# =========================================================================
def compute_macro_scores(axis_scores: dict):
"""axis_scores: {nome_asse: {'score':..,'status':..}}"""
results = {}
for macro_name, weights in MACRO_SCORE_WEIGHTS.items():
items = [
WeightedScore(axis_scores[axis]["score"] if axis_scores[axis]["status"] != "insufficiente" else None, w)
for axis, w in weights.items() if w > 0
]
score, coverage, status = aggregate(items)
results[macro_name] = {"score": score, "coverage": coverage, "status": status}
return results
# =========================================================================
# LIVELLO 4 — Fitness Age
# =========================================================================
def compute_fitness_age(eta_anagrafica, cardio_score, handgrip_score, hrv_score,
forza_resto_score, composizione_score, stabilita_score):
items = [
WeightedScore(cardio_score, FITNESS_AGE_WEIGHTS["cardio"]),
WeightedScore(handgrip_score, FITNESS_AGE_WEIGHTS["handgrip_isolato"]),
WeightedScore(hrv_score, FITNESS_AGE_WEIGHTS["hrv_isolato"]),
WeightedScore(forza_resto_score, FITNESS_AGE_WEIGHTS["forza_resto"]),
WeightedScore(composizione_score, FITNESS_AGE_WEIGHTS["composizione"]),
WeightedScore(stabilita_score, FITNESS_AGE_WEIGHTS["stabilita"]),
]
composite, coverage, status = aggregate(items)
if composite is None:
return {"fitness_age": None, "coverage": coverage, "status": "insufficiente"}
fitness_age = eta_anagrafica - (composite - 50) * 0.4
return {"fitness_age": round(fitness_age, 1), "composite": composite, "coverage": coverage, "status": status}
# =========================================================================
# DEMO END-TO-END
# =========================================================================
def demo():
"""Esempio completo su un cliente fittizio (M, 38 anni, dati plausibili)."""
eta, sex, peso = 38, "M", 90.3
# --- Livello 1: normalizzazione sotto-metriche ---
forza = {
"handgrip": score_handgrip(kg=48, eta=eta, sex=sex),
"spinta": score_pushup(reps=24, eta=eta, sex=sex),
"trazione": score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex),
"arti_inferiori": score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex),
"core": score_plank(sec=110, sex=sex),
}
composizione = {
"grasso": score_fat_percent(12.8, sex),
"muscolo": score_muscle_percent(48.8, sex),
"whr": score_whr(0.71, sex),
}
vo2 = vo2max_from_mutt(vam_m_min=230)
cardio = {
"vo2max": score_vo2max(vo2, eta, sex),
"spirometria": 75, # placeholder: da calcolatore GLI dedicato
"wellness_tower_cardio": score_spo2(98),
}
recupero = {
"hrv": 68, # placeholder: score gia' normalizzato da ISL HRV Monitor
"pressione": score_blood_pressure(114, 81),
"hrr": score_hrr(calo_bpm_1min=15),
"questionario_sonno": 72, # placeholder aggregato risposte questionario
}
energia = {
"hrv": 65,
"questionario_energia_stress": 60,
}
stabilita = {
"flamingo": score_flamingo(cadute=5),
"sit_and_reach": score_sit_and_reach(cm=20, sex=sex),
"plank": score_plank(sec=110, sex=sex),
"back_scratch": score_back_scratch(cm=-5, eta=eta, sex=sex),
"wellness_tower_shoulder": score_shoulder_mobility_wt(180, 180),
}
stile_vita = {
"questionario_lifestyle": 70,
}
# --- Livello 2: sotto-metriche -> assi ---
axis_scores = {
"Forza & Struttura": compute_axis("Forza & Struttura", forza),
"Composizione Corporea": compute_axis("Composizione Corporea", composizione),
"Cardio-Respiratorio": compute_axis("Cardio-Respiratorio", cardio),
"Recupero & Sistema Nervoso": compute_axis("Recupero & Sistema Nervoso", recupero),
"Energia & Regolazione Stress": compute_axis("Energia & Regolazione Stress", energia),
"Stabilità & Mobilità Funzionale": compute_axis("Stabilità & Mobilità Funzionale", stabilita),
"Stile di Vita & Sonno": compute_axis("Stile di Vita & Sonno", stile_vita),
}
print("=== RADAR — 7 ASSI ===")
for name, res in axis_scores.items():
print(f" {name:35s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
# --- Livello 3: assi -> 3 macro-score ---
macro = compute_macro_scores(axis_scores)
print("\n=== MACRO-SCORE ===")
for name, res in macro.items():
print(f" {name:12s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
# --- Livello 4: Fitness Age ---
fa = compute_fitness_age(
eta_anagrafica=eta,
cardio_score=axis_scores["Cardio-Respiratorio"]["score"],
handgrip_score=forza["handgrip"],
hrv_score=recupero["hrv"],
forza_resto_score=axis_scores["Forza & Struttura"]["score"],
composizione_score=axis_scores["Composizione Corporea"]["score"],
stabilita_score=axis_scores["Stabilità & Mobilità Funzionale"]["score"],
)
print(f"\n=== FITNESS AGE ===")
print(f" Eta anagrafica: {eta} -> Fitness Age: {fa['fitness_age']} (composite {fa['composite']}, copertura {fa['coverage']*100:.0f}%)")
def score_generic_range_local(value, low, high):
if high == low:
return 50
pct = (value - low) / (high - low)
return clamp(round(pct * 100, 1))
if __name__ == "__main__":
demo()
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { canAccessAdminPath, isProtectedPath, landingFor, isRole, ROLES } from '../../src/lib/auth';
describe('accesso alle rotte longevity', () => {
it('il cliente entra nel proprio spazio', () => {
expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true);
});
it('il cliente NON entra nel gestionale', () => {
expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false);
});
it('il trainer entra in entrambi', () => {
expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true);
expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true);
});
it('il cliente non entra nelle altre piattaforme ne nel blog', () => {
expect(canAccessAdminPath('cliente', '/campus')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false);
});
it('il trainer non entra nelle altre piattaforme ne nel blog', () => {
expect(canAccessAdminPath('trainer', '/campus')).toBe(false);
expect(canAccessAdminPath('trainer', '/admin/content')).toBe(false);
expect(canAccessAdminPath('trainer', '/admin/posts')).toBe(false);
});
it('il ruolo piattaforme non eredita longevity', () => {
expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false);
});
it('admin passa sempre', () => {
expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true);
});
});
describe('isProtectedPath — quali rotte sono protette dal middleware', () => {
it('longevity è protetto', () => {
expect(isProtectedPath('/longevity/io')).toBe(true);
expect(isProtectedPath('/longevity/gestionale')).toBe(true);
expect(isProtectedPath('/api/longevity/qualcosa')).toBe(true);
});
it('i percorsi già esistenti restano protetti', () => {
expect(isProtectedPath('/admin')).toBe(true);
expect(isProtectedPath('/admin/content')).toBe(true);
expect(isProtectedPath('/api/admin/content')).toBe(true);
expect(isProtectedPath('/campus')).toBe(true);
expect(isProtectedPath('/piattaforme')).toBe(true);
});
it('il login e le pagine pubbliche non sono protetti', () => {
expect(isProtectedPath('/admin/login')).toBe(false);
expect(isProtectedPath('/blog')).toBe(false);
expect(isProtectedPath('/')).toBe(false);
});
});
describe('landingFor — indirizzo di atterraggio per ruolo', () => {
it('cliente atterra su longevity/io', () => {
expect(landingFor('cliente')).toBe('/longevity/io');
});
it('trainer atterra su longevity/gestionale', () => {
expect(landingFor('trainer')).toBe('/longevity/gestionale');
});
it('i ruoli già esistenti restano uguali', () => {
expect(landingFor('superuser')).toBe('/admin/content');
expect(landingFor('piattaforme')).toBe('/piattaforme');
expect(landingFor('admin')).toBe('/admin');
expect(landingFor('user')).toBe('/admin');
});
});
describe('isRole — validazione dei ruoli', () => {
it('cliente e trainer sono ruoli validi', () => {
expect(isRole('cliente')).toBe(true);
expect(isRole('trainer')).toBe(true);
});
it('i ruoli già esistenti restano validi', () => {
expect(isRole('admin')).toBe(true);
expect(isRole('superuser')).toBe(true);
expect(isRole('user')).toBe(true);
expect(isRole('piattaforme')).toBe(true);
});
it('campus e stringhe inventate non sono ruoli', () => {
expect(isRole('campus')).toBe(false);
expect(isRole('qualsiasi-cosa')).toBe(false);
});
});
describe('ROLES — fonte unica dei ruoli, usata dal pannello utenti', () => {
it('contiene tutti e sei i ruoli, cliente e trainer inclusi', () => {
expect(ROLES).toContain('cliente');
expect(ROLES).toContain('trainer');
expect(ROLES).toContain('admin');
expect(ROLES).toContain('superuser');
expect(ROLES).toContain('user');
expect(ROLES).toContain('piattaforme');
expect(ROLES.length).toBe(6);
});
it('isRole accetta esattamente i ruoli di ROLES, nessuno in piu o in meno', () => {
for (const r of ROLES) expect(isRole(r)).toBe(true);
expect(isRole('non-un-ruolo')).toBe(false);
});
});
describe('pannello utenti — la tendina dei ruoli non e piu scritta a mano', () => {
it('users.astro pesca i ruoli da ROLES, non da una lista propria che ne dimentica due', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8');
expect(src).toContain('ROLES');
// La vecchia lista a quattro (dimenticava cliente e trainer) non deve piu comparire.
expect(src).not.toContain("['user', 'superuser', 'piattaforme', 'admin']");
});
});
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import { createDb } from '../src/lib/db';
import { services, getService } from '../src/data/services';
import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings';
import { contentSeed } from '../src/data/content-seed';
import { site } from '../src/data/site';
import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari';
import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs';
// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
describe('Small Group è diventato Personalized Training Group', () => {
it('non resta traccia del vecchio nome nei dati né nel seed', () => {
const testi = [
...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]),
...trainings.flatMap((t) => [t.title, t.caption, t.description]),
...site.footerTraining.map((v) => v.label),
...contentSeed.map((e) => e.value),
];
expect(testi.filter((v) => /small group/i.test(v))).toEqual([]);
});
// Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link
// già girati su WhatsApp senza che nessuno se ne accorga.
it('lo slug della scheda non cambia', () => {
expect(getService('small-group')?.title).toBe('Personalized Training Group');
});
it('gli articoli restano corretti, e passarci due volte non cambia niente', () => {
const casi: [string, string][] = [
['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'],
['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'],
['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'],
['Small Groups', 'Personalized Training Group'],
['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'],
];
for (const [prima, dopo] of casi) {
expect(rinominaSmallGroup(prima)).toBe(dopo);
expect(rinominaSmallGroup(dopo)).toBe(dopo);
}
});
});
describe('lo script porta i testi nuovi in un database già scritto', () => {
it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => {
const db = createDb(':memory:');
const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab');
scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica');
scrivi.run('footer.services.4.label', 'text', 'Profilazione');
const cambi = applica(db);
const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab');
expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato');
expect(dopo('footer.services.4.label')).toBe('Check-up');
expect(cambi.length).toBeGreaterThanOrEqual(3);
expect(calcola(db)).toEqual([]);
});
// Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione.
it('con --dry-run non tocca niente', () => {
const db = createDb(':memory:');
db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)')
.run('promo.card.2.title', 'text', 'Small Group');
expect(applica(db, { dryRun: true }).length).toBe(1);
expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value)
.toBe('Small Group');
});
it('i testi riscritti a mano corrispondono a quelli del seed', () => {
const seed = new Map(contentSeed.map((e) => [e.tag, e.value]));
for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore);
});
});
describe('viste di /training', () => {
it('senza vista si vedono tutti i training', () => {
expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id));
});
it('One to One porta a One to One, Personalized Training Group e Rehab', () => {
expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']);
});
it('Class porta a Holistic e Reformer', () => {
expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']);
});
// Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto.
it('una vista inventata è 404, non l\'elenco intero', () => {
expect(trainingDaMostrare('inesistente')).toBeNull();
});
it('ogni vista nomina solo training che esistono', () => {
for (const v of Object.values(VISTE_TRAINING)) {
for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true);
}
});
});
describe('instradamento delle card di /services', () => {
const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; };
it('One to One e Class portano alle viste di training', () => {
expect(href('one-to-one')).toBe('/training/one-to-one');
expect(href('performance-class')).toBe('/training/class');
});
it('Check-up porta alla propria scheda', () => {
expect(href('profilazione')).toBe('/services/profilazione');
});
// La Performance Class non si eroga più: la card resta, la scheda che la spiegava no.
it('la Performance Class non ha più una scheda ma resta in griglia', () => {
expect(getService('performance-class')?.page).toBe(false);
expect(getService('performance-class')?.grid).not.toBe(false);
});
it('nessun link del sito manda a una scheda che non esiste', () => {
const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`));
const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation]
.map((v) => v.href)
.filter((h) => h.startsWith('/services/'));
for (const h of interni) expect(schede.has(h)).toBe(true);
});
});
describe('Check-up', () => {
const checkup = getService('profilazione')!;
it('il trafiletto della card racconta Base e Avanzato', () => {
expect(checkup.title).toBe('Check-up');
expect(checkup.subtitle).toBe('Base e avanzato');
expect(checkup.excerpt).toContain('Check-up Base');
expect(checkup.excerpt).toContain('Check-up Avanzato');
});
it('la scheda porta la sezione avanzata con i suoi cinque punti', () => {
expect(checkup.extra?.title).toBe('Check-up Avanzato');
expect(checkup.extra?.body).toContain('Wellness Tower');
expect(checkup.extra?.items).toHaveLength(5);
});
// Tolta il 19/08 su richiesta del cliente: restava in fondo alla scheda e diceva cose che
// la sezione Avanzato racconta meglio.
it('la domanda «Che cosa comprende?» non c\'è più', () => {
expect(checkup.faq).toHaveLength(1);
expect(checkup.faq.map((f) => f.q)).not.toContain('Che cosa comprende?');
});
it('i testi della sezione avanzata sono modificabili dal pannello', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
expect(tags.has('service.profilazione.extra.title')).toBe(true);
expect(tags.has('service.profilazione.extra.body')).toBe(true);
for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true);
});
});
describe('calendario delle class', () => {
// Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la
// card «Class» della griglia servizi, che ha slug `performance-class`.
it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => {
expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']);
});
it('in griglia il bottone sta sulla card Class', () => {
expect(CARD_CON_CALENDARIO).toEqual(['performance-class']);
});
it('ogni slug nominato esiste davvero', () => {
for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined();
});
});
describe('promo ed eventi', () => {
it('i testi della pagina di scelta e degli eventi sono nel seed', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label',
'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) {
expect(tags.has(tag)).toBe(true);
}
});
it('l\'inaugurazione dice la data e che il programma manca ancora', () => {
const testo = contentSeed.find((e) => e.tag === 'eventi.inaugurazione.body')!.value;
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.title')!.value).toContain('19 settembre 2026');
expect(testo).toContain('programma completo');
});
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
it('il menu punta alla pagina di scelta', () => {
expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { edizioni, edizioneAttiva, tagEdizione, type Edizione } from '../src/data/promo-edizioni';
import { seedByTag } from '../src/data/content-seed';
const d = (iso: string) => new Date(iso);
describe('edizioneAttiva', () => {
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
});
it('fuori da ogni finestra non c\'è edizione attiva', () => {
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))).toBeNull();
});
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
// italiana. Il server gira in UTC, quindi un confronto fatto sull'orologio locale della
// macchina anticiperebbe (o ritarderebbe) lo scambio di due ore.
it('scambia le edizioni al secondo giusto, in ora italiana', () => {
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))).toBeNull();
});
it('l\'ordine dell\'elenco non cambia il risultato', () => {
const rovescio = [...edizioni].reverse();
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
});
});
describe('coerenza delle edizioni configurate', () => {
const ordinate = [...edizioni].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
it('ogni finestra è valida e ha un verso', () => {
for (const e of edizioni) {
expect(Number.isNaN(Date.parse(e.from)), `${e.id}: from illeggibile`).toBe(false);
expect(Number.isNaN(Date.parse(e.to)), `${e.id}: to illeggibile`).toBe(false);
expect(Date.parse(e.to), `${e.id}: finisce prima di cominciare`).toBeGreaterThan(Date.parse(e.from));
}
});
// Due promozioni insieme sul sito è il difetto peggiore: prezzi diversi per la stessa cosa,
// e nessuno sa quale vale. Nessun buco, invece, evita il giorno in cui la pagina non offre
// niente senza che nessuno se ne accorga: se un buco è voluto va scritto qui, non subìto.
it('le finestre non si sovrappongono e non lasciano buchi', () => {
for (let i = 1; i < ordinate.length; i++) {
const prima = ordinate[i - 1];
const dopo = ordinate[i];
const stacco = Date.parse(dopo.from) - Date.parse(prima.to);
expect(stacco, `${prima.id} e ${dopo.id} si sovrappongono`).toBeGreaterThan(0);
expect(stacco, `fra ${prima.id} e ${dopo.id} c'è un buco`).toBeLessThanOrEqual(1000);
}
});
// Il countdown è la promessa fatta al visitatore. Se dice una data e lo scambio ne usa
// un'altra, il sito si contraddice da solo: o cambia mentre il contatore corre ancora, o
// resta lì a zero. Sono lo stesso numero e devono restare lo stesso numero.
it('il countdown scade quando finisce l\'edizione', () => {
for (const e of edizioni) {
const deadline = seedByTag.get(tagEdizione(e, 'countdown.deadline'))?.value;
expect(deadline, `${e.id}: manca il tag della deadline`).toBeTruthy();
expect(Date.parse(deadline!), `${e.id}: countdown e fine edizione non coincidono`)
.toBe(Date.parse(e.to));
}
});
});
describe('i testi di ogni edizione esistono', () => {
// Un tag che manca non rompe la pagina: `resolve` restituisce stringa vuota e logga un warn
// che nessuno legge. In vetrina diventa una card senza prezzo, o un titolo sparito. Meglio
// scoprirlo qui che dal cliente.
const richiesti = (e: Edizione): string[] => {
const base = ['hero.badge', 'hero.title1', 'hero.accent', 'hero.title2', 'hero.desc', 'hero.cta',
'buy-label', 'validity-note', 'faq.title',
'countdown.label', 'countdown.deadline',
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
for (const c of e.cards) {
base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`, `card.${c.id}.price-old`);
if (c.featured) base.push(`card.${c.id}.badge`);
if (c.unit) base.push(`card.${c.id}.unit`);
if (c.question) base.push(`card.${c.id}.question`);
if (c.desc) base.push(`card.${c.id}.desc`);
if (c.note) base.push(`card.${c.id}.note-label`, `card.${c.id}.note`);
for (let i = 1; i <= c.features; i++) base.push(`card.${c.id}.feature.${i}`);
}
for (const n of e.faq) base.push(`faq.${n}.q`, `faq.${n}.a`);
return base.map((s) => tagEdizione(e, s));
};
for (const e of edizioni) {
it(`${e.id}: tutti i tag sono nel seed`, () => {
const mancanti = richiesti(e).filter((tag) => !seedByTag.has(tag));
expect(mancanti, `tag mancanti: ${mancanti.join(', ')}`).toEqual([]);
});
}
it('lo stato "nessuna promozione" ha i suoi testi', () => {
for (const tag of ['promo.chiusa.title', 'promo.chiusa.desc', 'promo.chiusa.cta']) {
expect(seedByTag.has(tag), `manca ${tag}`).toBe(true);
}
});
});
+10
View File
@@ -2,4 +2,14 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { include: ['tests/**/*.test.ts'] }, test: { include: ['tests/**/*.test.ts'] },
resolve: {
alias: [
// src/middleware.ts importa da 'astro:middleware', un modulo virtuale che Astro
// risolve solo dentro la propria pipeline vite (dev/build), non sotto vitest puro.
// Stesso bersaglio che Astro stesso usa (node_modules/astro/dist/core/create-vite.js):
// defineMiddleware è identità (fn => fn), sequence è reale. Senza questo alias il
// middleware non è caricabile in isolamento e nessun test può importarlo davvero.
{ find: 'astro:middleware', replacement: 'astro/virtual-modules/middleware.js' },
],
},
}); });