Compare commits

..

15 Commits

Author SHA1 Message Date
Adriano Dal Pastro 13d8cdd137 Il sito lascia le piattaforme: separazione fusa in main
Fuso `feat/split-apps` (fermo dal 03/09 e mai portato in main, mentre il container
delle piattaforme girava gia' da li'): il sito e le piattaforme erano online in due
copie, con database diversi e ruoli divergenti — `nicola` era `admin` di la' e
`piattaforme` di qua, e la promozione di ieri valeva solo su una delle due.

Adesso `insanitylab.it/campus`, `/piattaforme` e `/login` fanno 404: le aree
riservate stanno solo su `piattaforme.tielogic.xyz`. Il pannello `/admin` resta.
Nel database del sito restano `admin` ed `editor`; `nicola` e `adriano` sono usciti.

Il lavoro di oggi e' passato intatto: edizione `settembre-2026` coi cinque
pacchetti, i 73 tag, il countdown al 19/09 e la correzione del sabato. Verificato
sull'immagine nuova prima di metterla, chiedendo la pagina a quattro date.

Tre cose che lo split del 03/09 aveva lasciato indietro, trovate eseguendo:

- **`Dockerfile` del sito copiava ancora `campus-content`**, spostato sotto
  `apps/platforms`: il build falliva. Nel sito non c'era piu' un solo riferimento a
  quella cartella - solo la riga del Dockerfile. Rimossa.
- **`migra-utenti.mjs --togli-dal-sito` era un no-op silenzioso**: il `continue`
  sugli utenti gia' presenti a destinazione saltava anche la cancellazione
  dall'origine. Siccome copiare e ripulire sono due passi separati apposta, l'opzione
  non funzionava proprio nel caso per cui esiste. Ora chi e' gia' di la' esce di qua
  lo stesso, e il riepilogo dice quanti ne ha tolti.
- **`.env` non e' in git**, quindi il merge non lo ha spostato sotto `apps/sito/`.
  L'`up` e' fallito dopo che il `down` aveva gia' tolto il container: **9,7 secondi
  di sito giu'**, misurati. La prova con `docker run` non lo aveva visto perche' non
  legge `env_file`. Il rimedio - `docker compose config` prima dello swap - e' scritto
  nel README del sito, che prima non diceva come si fa un deploy.

Sito 149 test verdi, piattaforme 202.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 10:18:00 +00:00
Adriano Dal Pastro 33091be802 Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts:
#	apps/sito/compose.yaml
2026-09-04 10:09:29 +00:00
Adriano Dal Pastro faf7647a5f La promo di settembre entra in produzione prima della mezzanotte
Gli Opening Bundles scadono stasera alle 23:59:59 e dopo di loro l'elenco delle
edizioni era vuoto: senza questo merge, dal 5 mattina la pagina avrebbe mostrato
"Nessuna promozione in corso" con le promo del cliente vive.

Merge autorizzato da Adriano il 04/09/2026 dopo aver visto l'anteprima.

Lo scambio a mezzanotte non richiede altri interventi: la pagina legge l'edizione
valida nell'istante della richiesta, e il nuovo container va in produzione adesso
con dentro entrambe le edizioni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 09:35:14 +00:00
Adriano Dal Pastro 2b71cbb49e L'inaugurazione e' di sabato, e la fascia morta fra 700 e 701 px
Due rilievi della revisione, uno dei quali fuori dalla promo.

**`eventi.astro`: "venerdi' 19 settembre 2026"**, ma il 19/09/2026 e' **sabato**
(`calendari.ts:136` lo diceva gia' giusto). E' la meta description della pagina
dell'inaugurazione: e' cio' che si legge nell'anteprima di un link su WhatsApp,
che e' il canale con cui questo cliente parla ai suoi. Una riga, ma pubblica e
falsa.

**`.cards-5` fra 700 e 701 px non era coperta da nessuna regola**: la fascia
intermedia aveva `and (min-width: 701px)`, e a larghezze frazionarie - uno zoom
del browser dà 700,5 px - non valeva ne' quella ne' il blocco <=700, quindi le
cinque card tornavano a sei colonne dentro ~570 px. Tolto il `min-width`: ci
pensa il blocco <=700, che viene dopo e a pari specificita' vince.

Tolta anche la riga ridondante `.cards-5 { 1fr }` nel blocco <=700: `.lp .cards`
copre gia' tutte le griglie a una colonna, ed e' lo stesso meccanismo per cui
`.cards-3` funziona senza una regola sua.

Restano non toccati, perche' verificati corretti: la virgola di "composizione
corporea, e analisi HRV" e' **nella creativa** (a fine riga, nel layout su tre
righe), e le code esplicative delle card 1 e 2 vengono dai testi di agosto
gia' approvati dal cliente, non dalle creative compresse per il formato social.

186 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:53:30 +00:00
Adriano Dal Pastro f914d0699a La promo di settembre subentra ai bundle il 5, cinque pacchetti
Gli Opening Bundles scadono il 04/09 alle 23:59:59 e dopo di loro non c'era
nessuna edizione: dal 5 il sito avrebbe mostrato "Nessuna promozione in corso"
mentre le promo erano vive. Adriano ha consegnato le sei creative il 03/09.

Edizione `settembre-2026` (5 -> 19 settembre, chiude il giorno
dell'inaugurazione): tornano i pacchetti da 5 incontri, con in piu' il
Check-up avanzato, che e' una valutazione singola e per questo dice
"1 valutazione" invece di "5 incontri". Prezzi 250/150/130/80/70.

Due cose che la struttura non prevedeva:

- **Nessun prezzo barrato.** Le creative danno il solo prezzo promo, e i
  listini pieni di agosto (300/220/140/100) sono numeri che nessuno ha
  riconfermato - riportarli accanto ai nuovi sarebbe stato inventarli.
  `price-old` diventa opzionale nella CardSpec (`priceOld`) invece di essere
  un tag vuoto: cosi' la spec dichiara che quella card non ne ha, e il test
  lo pretende solo dove c'e'. Agosto e i bundle sono marcati `priceOld: true`,
  quindi restano sotto controllo come prima.

- **Cinque card non stanno in una griglia da due o da tre** senza lasciare un
  buco in fondo. `.cards-5` va a sei colonne con span due, cosi' le ultime due
  si spostano al centro; a due card per riga sotto i 1000px, a una sotto i 700.

Testi ripresi dall'edizione di agosto, gia' approvata dal cliente, cambiando
solo cio' che le date rendevano falso: la FAQ delle class diceva "gli orari
saranno pubblicati entro Settembre" e adesso e' settembre, quindi rimanda al
calendario che esiste. La FAQ 7 sul Check-up e' nuova e va fatta leggere a
Nicola prima della pubblicazione.

186 test verdi, `astro check` invariato (i 3 errori della pagina promo sono
preesistenti e non toccati).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:43:50 +00:00
AdrianoDev a59b4e0c0e crea-fascicolo: aprire il fascicolo di chi ha gia' un account
"Anche un admin deve avere il suo referto" (Adriano). Il codice lo permetteva
gia': il referto si aggancia all'utente, non al ruolo. Mancava il modo di
creare la riga che lega utente e fascicolo senza passare da seed-demo, che
inventa anche la persona e le risposte.

Non indovina niente: --sesso e' obbligatorio perche' entra nelle curve dei
punteggi, la data di nascita si puo' omettere e allora l'eta' resta vuota -
meglio un campo vuoto di una data plausibile e falsa. Si ferma se quell'utente
ha gia' un fascicolo: due fascicoli per la stessa persona sono due storie
parallele, e nessuna e' quella giusta.

Il fascicolo nasce vuoto: il referto dice "non hai ancora compilato il
questionario" finche' la persona non lo compila, che e' il percorso vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 13:02:54 +00:00
AdrianoDev c8a6b318cb Dopo il login si atterra sulla scelta della piattaforma
Chiesto da Adriano: si entra e si sceglie dove andare, anche quando la scelta
e' una sola. Prima ogni ruolo saltava dritto nella sua sezione.

Non basta cambiare landingFor: /piattaforme era riservato ai ruoli admin e
piattaforme, quindi mandarci un cliente avrebbe prodotto un CICLO di redirect
- il middleware nega la pagina, rimanda a landingFor, landingFor rimanda dove
il middleware nega. L'indice diventa la casa comune (aperto a ogni ruolo),
mentre Stress Index, che sta sotto lo stesso percorso, resta riservato: la sua
regola viene prima, perche' vince la prima che combacia.

L'indice mostra a ognuno solo le sezioni che puo' aprire, quindi la scelta non
espone mai una porta chiusa.

Un test nuovo presidia l'invariante che rende impossibile il ciclo: la
destinazione di landingFor deve essere accessibile a TUTTI i ruoli.

Verificato in produzione: il cliente demo atterra sulla scelta e vede la sola
card Longevity, adriano vede Campus e Stress Index, e da li' il percorso
prosegue fino al referto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:53:32 +00:00
AdrianoDev 62b19b21d9 Il link del sito all'area riservata punta all'host che esiste
Era rimasto apps.insanitylab.it, che e' l'indirizzo definitivo ma non ha
ancora un record su Aruba: alla fusione il menu del sito avrebbe portato a un
errore di DNS, e nessun test se ne sarebbe accorto perche' il link esce dal
dominio e non lo serve nessuna rotta interna.

Ora punta a piattaforme.tielogic.xyz, con un test che lo presidia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:45:17 +00:00
AdrianoDev 89d2186e69 Il vecchio sottodominio esce anche dal compose del ramo
insanitylab.tielogic.xyz e' stato tolto dal DNS: una regola Traefik che lo
nomina fa fallire una sfida ACME a ogni tentativo, ed e' cio' che Let's
Encrypt limita. Resta il redirect di www.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:53 +00:00
AdrianoDev f5fac89c1b Via dal router il vecchio sottodominio, tolto dal DNS
Adriano ha rimosso il record insanitylab.tielogic.xyz il 03/09/2026. Una
regola Traefik che nomina un host inesistente non e' inerte: il router chiede
un certificato per i nomi della sua regola, e la sfida ACME su un nome che
non risolve fallisce a ogni tentativo - proprio cio' che Let's Encrypt limita.

Resta il redirect di www verso l'apex, che e' l'unico ancora vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:52 +00:00
AdrianoDev 15dbe6a397 Le aree riservate hanno il loro host: piattaforme.tielogic.xyz
Creato da Adriano sulla zona tielogic.xyz (Hostinger). Sostituisce il prestito
di insanitylab.tielogic.xyz, durato un'ora: quell'host e' tornato al suo 308
verso l'apex e il sito e' di nuovo esattamente com'era.

allowedDomains, router del container e router di redirect del sito cambiano
insieme: e' la terna che decide di chi e' un host, e sfasarla lascia un
indirizzo senza padrone o un login che risponde 403.

Verificato in HTTPS: certificato emesso per il nome nuovo, login e referto
funzionanti, insanitylab.it 200 e i due redirect al loro posto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev 665c598462 Il vecchio sottodominio torna al suo redirect
Adriano ha creato piattaforme.tielogic.xyz, che e' il nome giusto per le aree
riservate: il prestito di insanitylab.tielogic.xyz e' durato un'ora e finisce
qui. Il sito torna esattamente come stamattina - apex, www e vecchio
sottodominio - e le piattaforme stanno su un host loro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev a7b81e2ff4 Le aree riservate partono da insanitylab.tielogic.xyz
Deciso da Adriano il 03/09/2026: il dominio del cliente resta su Aruba e non
si tocca, le piattaforme partono da un sottodominio nostro. Scelto l'host che
risolveva gia' e aveva gia' il certificato - fino a oggi faceva 308 verso
l'apex del sito - invece di aspettare un record DNS nuovo.

Tre modifiche coordinate, che vanno insieme o l'host resta senza padrone:
- allowedDomains dell'app: aggiunto l'host temporaneo accanto a quello
  definitivo. Senza, dietro Traefik il CSRF di Astro respinge ogni POST con
  403 e il login sembra rifiutare le credenziali.
- router del container: Host(insanitylab.tielogic.xyz)
- compose del sito: quell'host esce dal router di redirect (www continua)

Quando il record A "apps" esistera' su Aruba si tengono doppi per un po',
invece di spostare di netto un indirizzo che qualcuno ha gia' salvato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:36:33 +00:00
AdrianoDev 4103376eb1 Il vecchio sottodominio esce dal redirect: serve alle aree riservate
insanitylab.tielogic.xyz faceva 308 verso l'apex dal 12/07. Dal 03/09/2026
ospita le aree riservate (Campus, Stress Index, Longevity) mentre si aspetta
il record A di apps.insanitylab.it su Aruba: il dominio del cliente resta
dov'e', le piattaforme partono da un sottodominio nostro.

www.insanitylab.it continua a reindirizzare come prima.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:34:59 +00:00
AdrianoDev 18b0877026 Longevity: il registro si semina da solo, e un cliente demo con un referto vero
seedRegistro/seedPesi esistevano dal 22/08 ed erano chiamati SOLO dai test: su
un database nuovo il registro restava vuoto, campiDelQuestionario non trovava
niente e la pagina del questionario si apriva SENZA DOMANDE. Non era un problema
della demo, era il primo avvio in produzione - lo stesso difetto gia' visto in
questo ramo (una funzione corretta che nessuno chiama), con la stessa
correzione: agganciarla dove non si puo' dimenticare, cioe' alla creazione del
database. Entrambe le semine sono INSERT OR REPLACE, quindi rilanciarle e' a
vuoto, e i punteggi gia' emessi non cambiano (sono congelati nella tabella
"score", che e' quello che il referto legge). Quattro test lo presidiano.

scripts/seed-demo.ts crea l'utente con ruolo "cliente", il fascicolo (passando
da creaCliente, l'unico varco fra identity e longevity) e compila il
questionario attraverso salvaDalForm - lo stesso percorso della pagina vera,
consenso compreso - cosi' i punteggi vengono calcolati e congelati come in
produzione. Si ferma se l'utente esiste gia': rifare la demo su dati esistenti
vuol dire duplicare o cancellare un fascicolo, e non e' una decisione da script.

Verificato girando l'app: Energy 74,5/100 con copertura 50%, Performance e
Recovery dichiarati insufficienti (5% e 35%) con l'elenco di cosa manca, Fitness
Age assente. E' la regola di Nicola all'opera - annullare invece di stimare - ed
e' lo stato vero del prodotto: nel registro ci sono solo le 20 domande del
questionario, i test fisici arrivano col gestionale trainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:27:48 +00:00
22 changed files with 580 additions and 35 deletions
+9 -1
View File
@@ -11,7 +11,15 @@ export default defineConfig({
// il login smette di funzionare, e l'errore sembra "credenziali sbagliate". // il login smette di funzionare, e l'errore sembra "credenziali sbagliate".
// Stessa configurazione del sito, con l'host di quest'app. // Stessa configurazione del sito, con l'host di quest'app.
security: { security: {
allowedDomains: [{ hostname: 'apps.insanitylab.it', protocol: 'https' }], allowedDomains: [
// L'host definitivo, quando il record A su Aruba ci sara'.
{ hostname: 'apps.insanitylab.it', protocol: 'https' },
// ⚠️ L'host in uso oggi (03/09/2026): il dominio del cliente resta su Aruba e le aree
// riservate stanno su un sottodominio nostro. Finche' e' in uso deve stare qui:
// senza, dietro Traefik il controllo CSRF di Astro respinge ogni POST con 403 e il
// login sembra rifiutare le credenziali.
{ hostname: 'piattaforme.tielogic.xyz', protocol: 'https' },
],
}, },
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
+7 -1
View File
@@ -23,7 +23,13 @@ services:
- /opt/docker/insanitylab-platforms/data:/app/data - /opt/docker/insanitylab-platforms/data:/app/data
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.insanitylab-platforms.rule=Host(`apps.insanitylab.it`) # L'host di casa, creato da Adriano il 03/09/2026 sulla zona tielogic.xyz (Hostinger).
# ⚠️ Per un'ora circa le piattaforme avevano preso in prestito `insanitylab.tielogic.xyz`:
# quel prestito e' finito, e poi Adriano ha tolto del tutto quel record dal DNS.
# Quando il record A `apps` esistera' su Aruba, qui si aggiunge
# `|| Host(`apps.insanitylab.it`)` e si tengono doppi per un po', invece di spostare
# di netto un indirizzo che nel frattempo qualcuno ha salvato.
- traefik.http.routers.insanitylab-platforms.rule=Host(`piattaforme.tielogic.xyz`)
- traefik.http.routers.insanitylab-platforms.tls=true - traefik.http.routers.insanitylab-platforms.tls=true
- traefik.http.routers.insanitylab-platforms.entrypoints=websecure - traefik.http.routers.insanitylab-platforms.entrypoints=websecure
- traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge
+1
View File
@@ -0,0 +1 @@
/opt/docker/insanitylab-apps/apps/platforms/node_modules
+20
View File
@@ -26,6 +26,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"marked": "^18.0.11", "marked": "^18.0.11",
"tsx": "^4.23.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
@@ -6245,6 +6246,25 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+4 -1
View File
@@ -10,7 +10,9 @@
"start": "node ./dist/server/entry.mjs", "start": "node ./dist/server/entry.mjs",
"test": "vitest run", "test": "vitest run",
"create-user": "node scripts/create-user.mjs", "create-user": "node scripts/create-user.mjs",
"sync-campus": "node scripts/sync-campus.mjs" "sync-campus": "node scripts/sync-campus.mjs",
"seed-demo": "tsx scripts/seed-demo.ts",
"crea-fascicolo": "tsx scripts/crea-fascicolo.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "11.0.1", "@astrojs/node": "11.0.1",
@@ -31,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",
"marked": "^18.0.11", "marked": "^18.0.11",
"tsx": "^4.23.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+75
View File
@@ -0,0 +1,75 @@
/**
* crea-fascicolo.ts — apre il fascicolo Longevity di una persona che ha già un account.
*
* A COSA SERVE: il referto si aggancia all'utente, non al ruolo (`codicePerUtenteLoggato`),
* quindi chiunque abbia un account — cliente, trainer o admin — può avere il proprio, ma
* solo se esiste la riga che lo lega al fascicolo. Finché non c'è il gestionale trainer,
* questo è l'unico modo di crearla senza inventarsi dati come fa seed-demo.
*
* COME SI USA:
* npm run crea-fascicolo -- --utente nicola --nome Nicola --cognome Antonelli --sesso M
* opzionali: --nascita 1980-01-31 --email x@y.it --telefono 333...
* percorsi: --utenti-db / --longevity-db / --identity-db, oppure DB_PATH /
* LONGEVITY_DB_PATH / IDENTITY_DB_PATH (gli stessi nomi del container).
*
* ⚠️ NON inventa niente. `--sesso` è obbligatorio perché entra nel calcolo dei punteggi
* (le curve dei test fisici sono distinte per sesso), e la data di nascita si può omettere
* ma allora l'età della sessione resta vuota: meglio un campo vuoto che una data
* plausibile e falsa.
* ⚠️ Si ferma se quell'utente ha già un fascicolo: due fascicoli per la stessa persona
* significano due storie parallele, e nessuna delle due è quella giusta.
* ⚠️ Il fascicolo nasce VUOTO: il referto dirà «non hai ancora compilato il questionario»
* finché la persona non lo compila da sé, che è il percorso vero.
*
* DIPENDE DA: tsx (devDependency).
*/
import { createLongevityDb, createIdentityDb } from '../src/lib/longevity/db';
import { creaCliente, codicePerUtente } from '../src/lib/longevity/anagrafica';
import { createDb } from '../src/lib/db';
const argv = process.argv.slice(2);
const opt = (nome: string, def?: string): string | undefined => {
const i = argv.indexOf(nome);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
};
const utente = opt('--utente');
const nome = opt('--nome');
const cognome = opt('--cognome');
const sesso = opt('--sesso');
if (!utente || !nome || !cognome || (sesso !== 'M' && sesso !== 'F')) {
console.error('Uso: npm run crea-fascicolo -- --utente <username> --nome <N> --cognome <C> --sesso <M|F>');
console.error(' opzionali: --nascita AAAA-MM-GG --email <mail> --telefono <n>');
process.exit(1);
}
const utentiDb = createDb(opt('--utenti-db', process.env.DB_PATH ?? 'data/piattaforme.db'));
const longevity = createLongevityDb(opt('--longevity-db', process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db'));
const identity = createIdentityDb(opt('--identity-db', process.env.IDENTITY_DB_PATH ?? 'data/identity.db'));
const riga = utentiDb.prepare('SELECT id, role FROM users WHERE username = ?').get(utente) as
{ id: number; role: string } | undefined;
if (!riga) {
console.error(`L'utente "${utente}" non esiste: crealo prima con npm run create-user.`);
process.exit(1);
}
const esistente = codicePerUtente(identity, riga.id);
if (esistente) {
console.error(`"${utente}" ha già il fascicolo ${esistente}: non ne apro un secondo.`);
process.exit(1);
}
const code = creaCliente(identity, longevity, {
nome, cognome, sesso,
data_nascita: opt('--nascita'),
email: opt('--email'),
telefono: opt('--telefono'),
user_id: riga.id,
});
console.log(`fascicolo ${code} aperto per ${nome} ${cognome}`);
console.log(` utente ${utente} (id ${riga.id}, ruolo ${riga.role})`);
console.log(` nascita ${opt('--nascita') ?? '— non dichiarata: l\'età delle sessioni resterà vuota'}`);
console.log('\nÈ vuoto: da /longevity/questionario la persona lo compila e il referto compare.');
+11 -3
View File
@@ -60,11 +60,19 @@ if (candidati.length === 0) {
const ins = dst.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)'); const ins = dst.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
const del = togli ? src.prepare('DELETE FROM users WHERE username = ?') : null; const del = togli ? src.prepare('DELETE FROM users WHERE username = ?') : null;
let copiati = 0, saltati = 0; let copiati = 0, saltati = 0, tolti = 0;
for (const u of candidati) { for (const u of candidati) {
if (gia.has(u.username)) { if (gia.has(u.username)) {
console.log(` = ${u.username.padEnd(16)} ${u.role.padEnd(12)} già presente a destinazione, lasciato com'è`); // Già a destinazione: non lo si tocca di là (una migrazione che sovrascrive non si può
// rilanciare). Ma `--togli-dal-sito` riguarda l'ORIGINE, e va fatto lo stesso: chi è
// arrivato in un giro precedente non ha meno diritto di uscire dal sito di chi arriva
// adesso. Fino al 04/09/2026 questo `continue` saltava anche la cancellazione, e
// l'opzione diventava un no-op silenzioso appena la copia era già avvenuta — che è il
// caso normale, visto che copiare e ripulire sono due passi separati apposta.
const esce = togli && applica;
console.log(` = ${u.username.padEnd(16)} ${u.role.padEnd(12)} già a destinazione, lasciato com'è${esce ? ' — tolto dal sito' : ''}`);
if (esce) { del.run(u.username); tolti++; }
saltati++; saltati++;
continue; continue;
} }
@@ -76,5 +84,5 @@ for (const u of candidati) {
} }
} }
console.log(`\n${applica ? `copiati ${copiati}` : `da copiare ${candidati.length - saltati}`}, saltati ${saltati}.`); console.log(`\n${applica ? `copiati ${copiati}` : `da copiare ${candidati.length - saltati}`}, saltati ${saltati}${togli && applica ? `, tolti dal sito ${tolti + copiati}` : ''}.`);
if (!applica) console.log('Niente è stato scritto.'); if (!applica) console.log('Niente è stato scritto.');
+122
View File
@@ -0,0 +1,122 @@
/**
* seed-demo.ts — un cliente dimostrativo con un referto vero da mostrare.
*
* COSA FA, in quest'ordine:
* 1. apre i tre database (li crea se mancano; il registro dei test si semina da sé,
* vedi src/lib/longevity/db.ts);
* 2. crea un utente con ruolo `cliente` in piattaforme.db;
* 3. crea il suo fascicolo — la riga con il nome in identity.db, quella senza nome in
* longevity.db — passando da `creaCliente`, l'unico varco fra le due;
* 4. compila il questionario per lui con risposte plausibili, attraverso `salvaDalForm`:
* cioè lo stesso percorso della pagina vera, consenso compreso. Da lì i punteggi
* vengono calcolati e congelati, ed è quello che il referto legge.
*
* COME SI USA:
* npm run seed-demo # utente "demo-cliente", password generata
* npm run seed-demo -- --utente nicola-demo --password sceltaDaTe
* npm run seed-demo -- --data 2026-09-03 # data della compilazione (default: oggi)
* percorsi: --utenti-db / --longevity-db / --identity-db, oppure le variabili
* DB_PATH / LONGEVITY_DB_PATH / IDENTITY_DB_PATH (stessi nomi del container).
*
* ⚠️ NON è idempotente per scelta: se l'utente esiste già lo dice e si ferma, senza
* toccare niente. Rifare la demo su dati esistenti significherebbe o duplicare un
* fascicolo o cancellarne uno, e nessuna delle due è una cosa che uno script deve
* decidere da solo.
*
* ⚠️ I dati sono INVENTATI e vanno detti tali a chi guarda: è una persona che non esiste.
* Il referto che ne esce non è un esempio clinico, è un esempio di interfaccia.
*
* ⚠️ Cosa mostrerà davvero il radar: il registro contiene oggi SOLO le 20 domande del
* questionario — i test fisici (handgrip, VO2max, plank, composizione corporea...) non
* hanno ancora una riga, arrivano col gestionale trainer. Quindi escono con un valore gli
* assi che il questionario copre almeno al 40%, e gli altri restano dichiarati
* insufficienti con l'elenco di cosa manca. Non è un difetto della demo: è lo stato del
* prodotto, ed è anche la regola di Nicola che si vede all'opera (annullare invece di
* stimare).
*
* DIPENDE DA: tsx (devDependency) — il codice del prodotto è TypeScript e questo script
* lo usa davvero invece di riscriverne una copia in SQL.
*/
import { randomBytes } from 'node:crypto';
import { createLongevityDb, createIdentityDb } from '../src/lib/longevity/db';
import { creaCliente, etaAllaData } from '../src/lib/longevity/anagrafica';
import { salvaDalForm, campiDelQuestionario } from '../src/lib/longevity/vista';
import { createDb } from '../src/lib/db';
import { createUser } from '../src/lib/auth';
const argv = process.argv.slice(2);
const opt = (nome: string, def: string): string => {
const i = argv.indexOf(nome);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
};
const utente = opt('--utente', 'demo-cliente');
const password = opt('--password', randomBytes(9).toString('base64url'));
const nome = opt('--nome', 'Giulia');
const cognome = opt('--cognome', 'Esempio');
const nascita = opt('--nascita', '1990-05-14');
const data = opt('--data', new Date().toISOString().slice(0, 10));
const utentiDb = createDb(opt('--utenti-db', process.env.DB_PATH ?? 'data/piattaforme.db'));
const longevity = createLongevityDb(opt('--longevity-db', process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db'));
const identity = createIdentityDb(opt('--identity-db', process.env.IDENTITY_DB_PATH ?? 'data/identity.db'));
const esistente = utentiDb.prepare('SELECT id FROM users WHERE username = ?').get(utente) as { id: number } | undefined;
if (esistente) {
console.error(`L'utente "${utente}" esiste già (id ${esistente.id}): non tocco niente.`);
console.error('Usa --utente <altro-nome> per una demo nuova.');
process.exit(1);
}
/**
* Le risposte della persona inventata: una in salute ma non perfetta. Un questionario
* con tutti i valori al massimo darebbe un referto tutto verde, che è il modo migliore
* per non far vedere niente — né le curve, né la soglia del 40%, né cosa manca.
*/
const RISPOSTE: Record<string, number> = {
// sonno
q_ore_sonno: 7, q_min_addorm: 20, q_risvegli: 1, q_riposato: 7,
q_sonnolenza_diurna: 2, q_caffeina: 3,
// energia e stress
q_energia_media: 7, q_calo_pomeridiano: 4, q_tensione: 3, q_sopraffatto: 4,
q_esaurimento: 1, q_controllo: 7, q_sicurezza_gestione: 8, q_pensieri_lavoro: 2,
// stile di vita
q_attivita: 3, q_alimentazione: 7, q_alcol_life: 4, q_sigarette: 0,
q_luce: 2, q_schermi: 30,
};
// Il registro è la fonte: se una domanda è stata aggiunta o rinominata, questo script deve
// dirlo invece di salvare un questionario a metà senza che si veda.
const campi = campiDelQuestionario(longevity, data);
const mancanti = campi.filter((c) => !(c.id in RISPOSTE)).map((c) => c.id);
const inventate = Object.keys(RISPOSTE).filter((id) => !campi.some((c) => c.id === id));
if (mancanti.length || inventate.length) {
if (mancanti.length) console.error(`⚠️ domande del registro senza risposta qui: ${mancanti.join(', ')}`);
if (inventate.length) console.error(`⚠️ risposte che il registro non conosce: ${inventate.join(', ')}`);
console.error('Allinea RISPOSTE al registro prima di rifare la demo.');
process.exit(1);
}
const userId = createUser(utentiDb, utente, password, 'cliente');
const code = creaCliente(identity, longevity, {
nome, cognome, sesso: 'F', data_nascita: nascita, user_id: userId,
});
const esito = salvaDalForm(longevity, {
client_code: code,
data,
eta: etaAllaData(nascita, data),
risposte: RISPOSTE,
consensoSanitario: 'sì',
});
if (!esito.ok) {
console.error(`Il questionario non è stato salvato: ${esito.errore}`);
process.exit(1);
}
console.log(`utente ${utente} (ruolo cliente, id ${userId})`);
console.log(`password ${password}`);
console.log(`fascicolo ${code}${nome} ${cognome}, ${etaAllaData(nascita, data)} anni`);
console.log(`sessione ${esito.sessioneId}, questionario del ${data}, ${campi.length} risposte`);
console.log('\nEntra da /login e atterri sul referto: /longevity/io');
+19 -7
View File
@@ -61,9 +61,15 @@ export function logout(db: Database.Database, token: string): void {
// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto: // /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
// qui arrivano solo utenti già autenticati su una di quelle rotte. // qui arrivano solo utenti già autenticati su una di quelle rotte.
const RULES: [RegExp, Role[]][] = [ const RULES: [RegExp, Role[]][] = [
// Le due piattaforme storiche: il Biohacking Campus (contenuti) e Stress Index. // ⚠️ L'INDICE /piattaforme e' la casa comune: lo apre chiunque sia loggato, perche' e'
// la pagina su cui si atterra dopo il login (deciso da Adriano il 03/09/2026). Mostra a
// ognuno solo le sezioni che puo' aprire davvero — il filtro e' in data/piattaforme.
// Stress Index sta SOTTO quel percorso ma resta riservato, quindi la sua regola viene
// prima: vince la prima che combacia, e la piu' specifica deve poter parlare per prima.
[/^\/piattaforme\/stress-index(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme\/?$/, [...ROLES]],
// Il Biohacking Campus (contenuti del corso).
[/^\/campus(\/|$)/, ['admin', 'piattaforme']], [/^\/campus(\/|$)/, ['admin', 'piattaforme']],
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
// Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer. // Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer.
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
@@ -130,12 +136,18 @@ export function isRole(v: unknown): v is Role {
} }
/** /**
* Dove atterra chi ha appena fatto il login. ⚠️ `/admin` non esiste in quest'app: il * Dove atterra chi ha appena fatto il login: **sempre l'indice delle piattaforme**, per
* fondo della scala è l'indice delle piattaforme, non un pannello. * tutti i ruoli (deciso da Adriano il 03/09/2026 — si entra e si sceglie dove andare,
* anche quando la scelta e' una sola).
*
* ⚠️ Questa funzione e' anche il "torna a casa" del middleware quando un ruolo apre una
* sezione che non gli spetta. Quindi la sua destinazione DEVE essere aperta a ogni ruolo,
* o si forma un ciclo di redirect: il middleware nega la pagina, rimanda a landingFor, e
* landingFor rimanda dove il middleware nega di nuovo. E' esattamente il guasto che il
* sito ha rischiato lo stesso giorno con gli account emigrati. Un test lo presidia
* girando su tutti i ruoli.
*/ */
export function landingFor(role: string): string { export function landingFor(_role: string): string {
if (role === 'cliente') return '/longevity/io';
if (role === 'trainer') return '/longevity/gestionale';
return '/piattaforme'; return '/piattaforme';
} }
+22 -1
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { seedRegistro, seedPesi, MODEL_VERSION } from './registro';
/** /**
* Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. * Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore.
@@ -127,8 +128,28 @@ function apri(p: string, schema: string): Database.Database {
return db; return db;
} }
/**
* ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database.
*
* Fino al 03/09/2026 `seedRegistro` e `seedPesi` esistevano, erano giusti, ed erano
* chiamati **solo dai test**: su un database nuovo il registro restava vuoto, quindi
* `campiDelQuestionario` non trovava niente e la pagina del questionario si apriva
* **senza domande**. Non era un difetto della demo: era il primo avvio in produzione.
* È lo stesso difetto già visto una volta in questo ramo — «una funzione corretta che
* nessuno chiama» — e la correzione è la stessa: agganciarla dove non si può dimenticare.
*
* Entrambe le semine sono `INSERT OR REPLACE`, quindi rilanciarle è a vuoto; e il
* registro è **dato di prodotto** (le domande e i pesi decisi con Nicola), non dato di
* un cliente: riallinearlo a ogni avvio è voluto, così una domanda modificata in
* `registro.ts` entra col rilascio invece che con una migrazione a mano.
* ⚠️ I punteggi già emessi non cambiano: sono congelati in `score` e il referto legge
* quelli, mai un ricalcolo (vedi `refertoDi`).
*/
export function createLongevityDb(path?: string): Database.Database { export function createLongevityDb(path?: string): Database.Database {
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
return db;
} }
export function createIdentityDb(path?: string): Database.Database { export function createIdentityDb(path?: string): Database.Database {
+15 -5
View File
@@ -34,13 +34,20 @@ describe('utenti e sessioni di quest\'app', () => {
for (const finto of ['campus', 'superuser', 'user', 'Admin', '', 'cliente ']) expect(isRole(finto)).toBe(false); for (const finto of ['campus', 'superuser', 'user', 'Admin', '', 'cliente ']) expect(isRole(finto)).toBe(false);
}); });
it('landingFor manda ognuno a casa propria, e nessuno su /admin', () => { it('dopo il login si atterra sulla scelta della piattaforma, qualunque sia il ruolo', () => {
expect(landingFor('cliente')).toBe('/longevity/io'); // Deciso da Adriano il 03/09/2026: si entra e si sceglie, anche quando la scelta e' una
expect(landingFor('trainer')).toBe('/longevity/gestionale'); // sola. Prima ogni ruolo saltava direttamente nella sua sezione.
expect(landingFor('piattaforme')).toBe('/piattaforme'); for (const ruolo of ROLES) expect(landingFor(ruolo)).toBe('/piattaforme');
expect(landingFor('admin')).toBe('/piattaforme');
for (const ruolo of ROLES) expect(landingFor(ruolo)).not.toMatch(/^\/admin/); for (const ruolo of ROLES) expect(landingFor(ruolo)).not.toMatch(/^\/admin/);
}); });
it('⚠️ la pagina di atterraggio è aperta a TUTTI i ruoli, o è un ciclo di redirect', () => {
// landingFor e' anche il "torna a casa" del middleware quando un ruolo apre una sezione
// che non gli spetta: se la sua destinazione fosse a sua volta negata, il middleware
// rimanderebbe li' all'infinito. E' il guasto che il sito ha rischiato lo stesso giorno
// con gli account emigrati, in un punto diverso del codice.
for (const ruolo of ROLES) expect(canAccessPath(ruolo, landingFor(ruolo))).toBe(true);
});
}); });
describe('canAccessPath — le tre sezioni', () => { describe('canAccessPath — le tre sezioni', () => {
@@ -65,6 +72,8 @@ describe('canAccessPath — le tre sezioni', () => {
expect(canAccessPath('cliente', '/api/longevity/gestionale/misure')).toBe(false); expect(canAccessPath('cliente', '/api/longevity/gestionale/misure')).toBe(false);
expect(canAccessPath('cliente', '/campus')).toBe(false); expect(canAccessPath('cliente', '/campus')).toBe(false);
expect(canAccessPath('cliente', '/piattaforme/stress-index')).toBe(false); expect(canAccessPath('cliente', '/piattaforme/stress-index')).toBe(false);
// ...ma l'indice sì: è la pagina di scelta, ed è la sua casa dopo il login.
expect(canAccessPath('cliente', '/piattaforme')).toBe(true);
}); });
it('trainer: entrambe le parti di Longevity, niente Campus né Stress Index', () => { it('trainer: entrambe le parti di Longevity, niente Campus né Stress Index', () => {
@@ -73,6 +82,7 @@ describe('canAccessPath — le tre sezioni', () => {
expect(canAccessPath('trainer', '/api/longevity/gestionale/misure')).toBe(true); expect(canAccessPath('trainer', '/api/longevity/gestionale/misure')).toBe(true);
expect(canAccessPath('trainer', '/campus')).toBe(false); expect(canAccessPath('trainer', '/campus')).toBe(false);
expect(canAccessPath('trainer', '/piattaforme/stress-index')).toBe(false); expect(canAccessPath('trainer', '/piattaforme/stress-index')).toBe(false);
expect(canAccessPath('trainer', '/piattaforme')).toBe(true);
}); });
it('admin passa sempre', () => { it('admin passa sempre', () => {
@@ -0,0 +1,51 @@
// Il registro dei test è dato di prodotto, non dato di un cliente: un database nuovo deve
// nascere con dentro le domande e i pesi. Fino al 03/09/2026 seedRegistro e seedPesi erano
// chiamati SOLO dai test, quindi ogni suite passava e il primo avvio vero apriva un
// questionario senza domande. Questo test guarda il database come lo trova l'applicazione.
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { campiDelQuestionario } from '../../src/lib/longevity/vista';
import { MODEL_VERSION, seedRegistro, seedPesi } from '../../src/lib/longevity/registro';
const OGGI = new Date().toISOString().slice(0, 10);
describe('un database longevity appena creato', () => {
it('ha il registro dei test già seminato, non vuoto', () => {
const db = createLongevityDb(':memory:');
const n = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
expect(n).toBeGreaterThan(0);
});
it('mostra le domande del questionario senza che nessuno semini a mano', () => {
const db = createLongevityDb(':memory:');
const campi = campiDelQuestionario(db, OGGI);
expect(campi.length).toBeGreaterThan(0);
// I tre sotto-domini che il questionario alimenta oggi: se un giorno il registro
// perdesse una famiglia di domande, il referto continuerebbe a "funzionare" con un
// asse in meno e nessuno se ne accorgerebbe.
const sottoDomini = new Set(campi.map((c) => c.sottoDominio));
expect(sottoDomini).toContain('questionario_sonno');
expect(sottoDomini).toContain('questionario_energia_stress');
expect(sottoDomini).toContain('questionario_lifestyle');
});
it('ha i pesi del modello corrente, per assi e macro', () => {
const db = createLongevityDb(':memory:');
const perLivello = db.prepare(
'SELECT livello, COUNT(*) AS n FROM pesi WHERE model_version = ? GROUP BY livello'
).all(MODEL_VERSION) as { livello: string; n: number }[];
const mappa = Object.fromEntries(perLivello.map((r) => [r.livello, r.n]));
expect(mappa.asse).toBeGreaterThan(0);
expect(mappa.macro).toBeGreaterThan(0);
expect(mappa.fitness_age).toBeGreaterThan(0);
});
it('rilanciare la creazione non duplica niente (INSERT OR REPLACE)', () => {
const db = createLongevityDb(':memory:');
const prima = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
// Stessa semina, stesso database: è ciò che accade a ogni riavvio del container.
seedRegistro(db); seedPesi(db, MODEL_VERSION);
const dopo = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n;
expect(dopo).toBe(prima);
});
});
-2
View File
@@ -14,8 +14,6 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY scripts ./scripts COPY scripts ./scripts
# Contenuti della sezione Campus (generati con `npm run sync-campus` e committati)
COPY campus-content ./campus-content
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=4321 \ PORT=4321 \
+23
View File
@@ -206,3 +206,26 @@ Gli articoli del blog non viaggiano con il codice: per caricarli in produzione,
## Licenza ## Licenza
Proprietà di InsanityLab. Proprietà di InsanityLab.
## Deploy (dopo la separazione del 03/09/2026)
Il progetto compose si chiama **`insanitylab`** (`name:` esplicito nel compose), non piu'
`src` come quando la cartella era `/opt/docker/insanitylab/src`.
```
cd /opt/docker/insanitylab/src/apps/sito
docker compose build # il sito resta in piedi: non tocca il container
docker compose up -d # sostituisce il container
```
⚠️ **`.env` non e' in git** (e non deve esserlo): resta dov'e' ed e' l'unico file che un
`git merge` non porta con se'. Il 04/09/2026 lo spostamento sotto `apps/sito/` ha lasciato
`.env` alla vecchia radice, l'`up` e' fallito **dopo** che il `down` aveva gia' tolto il
container, e il sito e' rimasto giu' **9,7 secondi**. Prima di ogni swap:
```
docker compose config >/dev/null # legge env_file e fallisce SUBITO se manca qualcosa
```
⚠️ Un `docker run` sull'immagine **non** e' una prova sufficiente del deploy: non legge
`env_file`, quindi passa anche quando il compose fallirebbe.
+6 -1
View File
@@ -32,7 +32,12 @@ services:
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321 - traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex # Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`) # ⚠️ `insanitylab.tielogic.xyz` tolto dal DNS da Adriano il 03/09/2026: il record non
# esiste piu'. Lasciarlo qui non e' inerte — il router chiede un certificato per i
# nomi della sua regola, e una sfida ACME su un nome che non risolve fallisce a ogni
# tentativo, che e' esattamente cio' che Let's Encrypt limita. Il vecchio dominio ha
# smesso di essere raggiungibile: da qui in poi vale solo il redirect di `www`.
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`)
- traefik.http.routers.insanitylab-redir.tls=true - traefik.http.routers.insanitylab-redir.tls=true
- traefik.http.routers.insanitylab-redir.entrypoints=websecure - traefik.http.routers.insanitylab-redir.entrypoints=websecure
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
+4 -1
View File
@@ -12,7 +12,10 @@ const path = Astro.url.pathname;
// ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo // ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo
// sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che // sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta. // esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
const AREA_RISERVATA = 'https://apps.insanitylab.it'; // ⚠️ L'indirizzo vero, oggi: apps.insanitylab.it NON esiste (manca il record su Aruba) e
// puntarci scriverebbe nel menu del sito un link morto. Quando quel record ci sara', qui
// si cambia una riga — ed e' l'unico posto del sito che nomina l'area riservata.
const AREA_RISERVATA = 'https://piattaforme.tielogic.xyz';
const areaHref = user ? landingFor(user.role) : '/admin'; const areaHref = user ? landingFor(user.role) : '/admin';
--- ---
<header class="hdr" id="site-header"> <header class="hdr" id="site-header">
+96
View File
@@ -503,6 +503,102 @@ export const contentSeed: SeedEntry[] = [
T('bundle.faq.4.q', 'Il pagamento è sicuro?'), 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.'), 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.'),
// --- Promo settembre (edizione `settembre-2026`, 5 → 19 settembre) ---
// Testi ripresi dall'edizione di agosto, che il cliente ha già approvato: cambia solo ciò che
// le date o le nuove creative rendono falso. I prezzi e le voci degli elenchi vengono dalle
// sei creative consegnate il 03/09.
T('settembre.hero.badge', 'insanity.lab · bologna savena'),
T('settembre.hero.title1', 'Allenati con metodo.'),
T('settembre.hero.accent', 'Scegli come'),
T('settembre.hero.title2', 'vuoi farlo.'),
H('settembre.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('settembre.hero.cta', 'Le nostre proposte', []),
T('settembre.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
T('settembre.launch.title', 'Il nostro metodo nasce per durare.'),
T('settembre.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('settembre.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte — e una valutazione singola, per chi vuole partire dai propri numeri."),
T('settembre.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
T('settembre.buy-label', 'Acquista', []),
T('settembre.card.1.eyebrow', 'Personal training', []),
T('settembre.card.1.title', 'One to One'),
T('settembre.card.1.price', '250 €', []),
T('settembre.card.1.unit', '5 incontri', []),
T('settembre.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.2.eyebrow', 'Personal training', []),
T('settembre.card.2.title', 'Personalized Training Group'),
T('settembre.card.2.price', '150 €', []),
T('settembre.card.2.unit', '5 incontri', []),
T('settembre.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.3.eyebrow', 'Pilates class', []),
T('settembre.card.3.title', 'Reformer Class'),
T('settembre.card.3.price', '130 €', []),
T('settembre.card.3.unit', '5 incontri', []),
T('settembre.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('settembre.card.3.feature.2', '40 minuti, massimo 5 utenti'),
T('settembre.card.3.feature.3', 'Lavoro a basso impatto sulle articolazioni'),
T('settembre.card.4.eyebrow', 'Holistic class', []),
T('settembre.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('settembre.card.4.price', '80 €', []),
T('settembre.card.4.unit', '5 incontri', []),
T('settembre.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('settembre.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('settembre.card.4.feature.3', 'Adatto a tutti i livelli'),
// Il Check-up avanzato non è un percorso: si compra una volta e si fa in un appuntamento.
// Per questo l'unità dice "1 valutazione" e non "5 incontri".
T('settembre.card.5.eyebrow', 'Valutazione', []),
T('settembre.card.5.title', 'Check-up avanzato'),
T('settembre.card.5.price', '70 €', []),
T('settembre.card.5.unit', '1 valutazione', []),
T('settembre.card.5.feature.1', 'Analisi completa di composizione corporea, e analisi HRV'),
T('settembre.card.5.feature.2', 'Spirometria e scansione posturale 3D'),
T('settembre.card.5.feature.3', 'Report dettagliato e consigli personalizzati'),
// La deadline è lo stesso istante in cui finisce l'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('settembre.countdown.label', 'La promo scade tra', []),
T('settembre.countdown.deadline', '2026-09-19T23:59:59+02:00', []),
T('settembre.countdown.unit.days', 'giorni', []),
T('settembre.countdown.unit.hours', 'ore', []),
T('settembre.countdown.unit.minutes', 'minuti', []),
T('settembre.countdown.unit.seconds', 'secondi', []),
T('settembre.validity-note', 'Promo valida dal 5 al 19 settembre 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
T('settembre.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('settembre.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
H('settembre.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('settembre.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
H('settembre.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.'),
// Ad agosto qui c'era scritto "gli orari saranno pubblicati entro Settembre": adesso è
// settembre e il calendario esiste, quindi la frase andava riscritta o diventava falsa.
T('settembre.faq.2.q', 'Come funziona il percorso Reformer Class o Holistic Class?'),
H('settembre.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 li trovi nel <span class="hl">calendario</span>, che puoi consultare e scaricare in PDF dalla pagina <span class="hl">Servizi</span> del sito. 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('settembre.faq.7.q', "Che cos'è il Check-up avanzato?"),
H('settembre.faq.7.a', 'È una <span class="hl">valutazione singola</span>, non un percorso a incontri: analisi completa della composizione corporea e analisi HRV, spirometria e scansione posturale 3D. Al termine ricevi un report dettagliato con i consigli che ne derivano. Puoi farlo da solo, oppure come punto di partenza prima di scegliere un percorso. Per fissare l\'appuntamento scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span>.'),
T('settembre.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('settembre.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 Class</span> o <span class="hl">Pilates Mat, Mobility, Yoga</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno. Il <span class="hl">Check-up avanzato</span> è a sé: è una valutazione singola e non un pacchetto di incontri.'),
T('settembre.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('settembre.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('settembre.faq.5.q', 'Il pagamento è sicuro?'),
H('settembre.faq.5.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.'),
T('settembre.faq.6.q', 'Posso chiedere un rimborso?'),
H('settembre.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.'),
]; ];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+35 -7
View File
@@ -20,6 +20,9 @@ export interface CardSpec {
featured?: boolean; featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */ /** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean; unit?: boolean;
/** Prezzo pieno barrato accanto a quello promo. Senza, la card mostra il solo prezzo di
* vendita: un listino barrato non dichiarato dal cliente sarebbe un numero inventato. */
priceOld?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */ /** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean; question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */ /** Paragrafo descrittivo sotto l'elenco. */
@@ -52,10 +55,10 @@ export const edizioni: Edizione[] = [
to: '2026-08-19T23:59:59+02:00', to: '2026-08-19T23:59:59+02:00',
launch: true, launch: true,
cards: [ cards: [
{ id: 1, features: 4, unit: true }, { id: 1, features: 4, unit: true, priceOld: true },
{ id: 2, features: 4, unit: true, featured: true }, { id: 2, features: 4, unit: true, priceOld: true, featured: true },
{ id: 3, features: 3, unit: true }, { id: 3, features: 3, unit: true, priceOld: true },
{ id: 4, features: 3, unit: true }, { id: 4, features: 3, unit: true, priceOld: true },
], ],
faq: [0, 1, 2, 3, 4, 5, 6], faq: [0, 1, 2, 3, 4, 5, 6],
}, },
@@ -67,14 +70,39 @@ export const edizioni: Edizione[] = [
from: '2026-08-20T00:00:00+02:00', from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00', to: '2026-09-04T23:59:59+02:00',
cards: [ cards: [
{ id: 1, features: 3, question: true, desc: true, note: true }, { id: 1, features: 3, question: true, desc: true, note: true, priceOld: true },
{ id: 2, features: 2, question: true, desc: true, note: true }, { id: 2, features: 2, question: true, desc: true, note: true, priceOld: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli // 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. // dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true }, { id: 3, features: 3, question: true, desc: true, note: true, priceOld: true },
], ],
faq: [0, 1, 2, 3, 4], faq: [0, 1, 2, 3, 4],
}, },
{
// Promo di settembre: subentra ai bundle il 5 e chiude il 19, il giorno dell'inaugurazione.
// Torna la forma dei pacchetti da 5 incontri, con in più il Check-up avanzato, che è una
// valutazione singola e non un percorso — per questo ha "1 valutazione" al posto di
// "5 incontri".
// Nessun prezzo barrato: le creative del cliente danno il solo prezzo promo, e un listino
// pieno ripreso dall'edizione di agosto sarebbe un numero che nessuno ha confermato.
id: 'settembre-2026',
prefix: 'settembre',
from: '2026-09-05T00:00:00+02:00',
to: '2026-09-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
// Nessuna card "in evidenza": cinque proposte per esigenze diverse, non cinque livelli
// dello stesso prodotto.
{ id: 5, features: 3, unit: true },
],
// La 7 è la domanda nuova sul Check-up: va letta dopo le altre due "come funziona", non
// in fondo dopo il rimborso, quindi l'ordine di comparsa non segue la numerazione.
faq: [0, 1, 2, 7, 3, 4, 5, 6],
},
]; ];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */ /** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
@@ -38,7 +38,7 @@ const pomeriggio: Gruppo[] = [
--- ---
<Base <Base
title="In-Sanity Hour — 19 settembre 2026" title="In-Sanity Hour — 19 settembre 2026"
description="L'inaugurazione di InsanityLab: venerdì 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti." description="L'inaugurazione di InsanityLab: sabato 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
> >
<section class="ev-hero"> <section class="ev-hero">
<div class="ev-hero__inner" data-reveal> <div class="ev-hero__inner" data-reveal>
+27 -1
View File
@@ -114,7 +114,7 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />} {c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
<div class="price-row"> <div class="price-row">
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" /> <T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
<T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" /> {c.priceOld && <T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />}
</div> </div>
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />} {c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
<ul> <ul>
@@ -296,6 +296,16 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); } .lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
.lp .cards-3 .card { display: flex; flex-direction: column; } .lp .cards-3 .card { display: flex; flex-direction: column; }
.lp .cards-3 .card .buy { margin-top: auto; } .lp .cards-3 .card .buy { margin-top: auto; }
/* CARD DELLE EDIZIONI A CINQUE (promo settembre). Cinque non si divide in una griglia a due
o a tre colonne senza lasciare un buco in fondo: la griglia è a sei colonne e ogni card ne
occupa due, così le ultime due si spostano al centro invece di restare allineate a sinistra
con un vuoto accanto. */
.lp .cards-5 { grid-template-columns: repeat(6, 1fr); }
.lp .cards-5 .card { grid-column: span 2; display: flex; flex-direction: column; }
.lp .cards-5 .card .buy { margin-top: auto; }
.lp .cards-5 .card:nth-child(4) { grid-column: 2 / span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 4 / span 2; }
.lp .card .question { .lp .card .question {
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
} }
@@ -343,9 +353,25 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
.lp .faq-block.is-open .faq-a { display: block; } .lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; } .lp .faq-a .hl { color: #8A6E3F; }
/* Sotto i 1000px tre colonne diventano troppo strette per un prezzo e un elenco: le cinque
card passano a due per riga (griglia a quattro, span due), con la quinta al centro.
Nessun `min-width` a chiudere la fascia in basso: a viewport frazionari (uno zoom del
browser dà larghezze come 700,5px) fra 700 e 701 non varrebbe nessuna delle due regole e
le card tornerebbero a sei colonne. Ci pensa il blocco ≤700px, che viene dopo e a pari
specificità vince. */
@media (max-width: 1000px) {
.lp .cards-5 { grid-template-columns: repeat(4, 1fr); }
.lp .cards-5 .card:nth-child(4) { grid-column: span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 2 / span 2; }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; } .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 .cards { grid-template-columns: 1fr; }
/* A una colonna gli span e le collocazioni delle ultime due card lascerebbero righe vuote. */
.lp .cards-5 .card,
.lp .cards-5 .card:nth-child(4),
.lp .cards-5 .card:nth-child(5) { grid-column: auto; }
.lp .countdown .cd-boxes { gap: 6px; } .lp .countdown .cd-boxes { gap: 6px; }
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; } .lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
.lp .countdown .cd-num { font-size: 22px; } .lp .countdown .cd-num { font-size: 22px; }
@@ -0,0 +1,24 @@
// Il sito nomina l'area riservata in UN posto solo (l'header). Dopo la separazione quel
// link esce dal dominio, quindi non lo protegge piu' nessuna rotta interna: se punta a un
// host che non esiste, il menu del sito porta a un errore di DNS e nessun test se ne
// accorge. Qui si verifica che sia l'host in uso, non quello previsto per il futuro.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const header = readFileSync(join(process.cwd(), 'src/components/Header.astro'), 'utf8');
describe("il link all'area riservata", () => {
it('punta a un host che esiste davvero oggi', () => {
expect(header).toContain('https://piattaforme.tielogic.xyz');
// apps.insanitylab.it e' l'indirizzo definitivo, ma il record A su Aruba non c'e'
// ancora: finche' non c'e', qui sarebbe un link morto dentro al menu del sito.
expect(header).not.toContain('https://apps.insanitylab.it');
});
it('non prova piu a costruire un menu delle piattaforme dentro al sito', () => {
// Le tre piattaforme non vivono piu' qui: un menu che le elenca sarebbe un elenco di
// rotte inesistenti, servite dal sito come 404.
expect(header).not.toContain("data/piattaforme");
});
});
+8 -3
View File
@@ -8,11 +8,12 @@ describe('edizioneAttiva', () => {
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => { 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-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura'); expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))?.id).toBe('settembre-2026');
}); });
it('fuori da ogni finestra non c\'è edizione attiva', () => { it('fuori da ogni finestra non c\'è edizione attiva', () => {
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-09-25T12:00:00+02:00'))).toBeNull();
}); });
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora // Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
@@ -22,13 +23,16 @@ describe('edizioneAttiva', () => {
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026'); 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-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-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))).toBeNull(); expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))?.id).toBe('settembre-2026');
expect(edizioneAttiva(d('2026-09-19T23:59:59+02:00'))?.id).toBe('settembre-2026');
expect(edizioneAttiva(d('2026-09-20T00:00:00+02:00'))).toBeNull();
}); });
it('l\'ordine dell\'elenco non cambia il risultato', () => { it('l\'ordine dell\'elenco non cambia il risultato', () => {
const rovescio = [...edizioni].reverse(); const rovescio = [...edizioni].reverse();
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026'); 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'); expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'), rovescio)?.id).toBe('settembre-2026');
}); });
}); });
@@ -80,7 +84,8 @@ describe('i testi di ogni edizione esistono', () => {
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds']; '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'); if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
for (const c of e.cards) { 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`); base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`);
if (c.priceOld) base.push(`card.${c.id}.price-old`);
if (c.featured) base.push(`card.${c.id}.badge`); if (c.featured) base.push(`card.${c.id}.badge`);
if (c.unit) base.push(`card.${c.id}.unit`); if (c.unit) base.push(`card.${c.id}.unit`);
if (c.question) base.push(`card.${c.id}.question`); if (c.question) base.push(`card.${c.id}.question`);