Merge remote-tracking branch 'origin/feat/split-apps' into merge-split

# Conflicts:
#	apps/sito/compose.yaml
This commit is contained in:
Adriano Dal Pastro
2026-09-04 10:09:29 +00:00
583 changed files with 56050 additions and 257 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
data
uploads
.env
.git
docs
assets-src
tests
*.md
+17
View File
@@ -0,0 +1,17 @@
# SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
# Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
SMTP_HOST=smtps.aruba.it
SMTP_PORT=465
# Casella reale del dominio usata per l'invio autenticato (con la sua password Aruba)
SMTP_USER=postmaster@insanitylab.it
SMTP_PASS=la-password-della-casella
# Aruba impone che il mittente sia la casella autenticata: uguale a SMTP_USER
CONTACT_FROM=postmaster@insanitylab.it
# Dove arrivano i messaggi del form
CONTACT_TO=info@insanitylab.it
DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
GTM_ID=GTM-XXXXXXX
+25
View File
@@ -0,0 +1,25 @@
# Stage 1: build
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
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 \
PORT=4321 \
DB_PATH=/app/data/insanitylab.db \
UPLOADS_DIR=/app/uploads
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
+208
View File
@@ -0,0 +1,208 @@
# InsanityLab Website
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
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
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
- **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
- **Database**: SQLite via `better-sqlite3`
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
- **Email**: Nodemailer (SMTP)
- **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
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
- **Test**: Vitest
- **Linguaggio**: TypeScript
- **Deploy**: Docker (multistage, `node:22-slim`) + Traefik su VPS
## Funzionalità principali
- **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.
- **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, proprietà degli articoli per ruolo, filtro per tematica senza ricaricare la pagina e conteggio degli articoli per categoria.
- **Cestino degli articoli**: l'eliminazione sposta in `/admin/cestino`, da dove si ripristina o si cancella in via definitiva.
- **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.
## Installazione (sviluppo)
### Prerequisiti
- Node.js 18+ e npm
### Setup
```bash
npm install
cp .env.example .env # poi compila i valori reali
npm run create-user -- admin <password> admin
npm run dev # http://localhost:4321
```
Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti.
## Comandi
| Comando | Descrizione |
|---------|-------------|
| `npm run dev` | Server di sviluppo (localhost:4321) |
| `npm run build` | Build di produzione |
| `npm run preview` | Anteprima locale della build |
| `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Test con Vitest |
| `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 seed-blog` | Carica i venti articoli divulgativi tratti dal corso base del Campus (salta quelli già presenti) |
## Gestione contenuti
I testi e le immagini della vetrina sono **blocchi taggati** salvati nella tabella `content_blocks`. Ogni blocco ha un tag stabile (es. `home.hero.title-line-1`), un valore, eventuali stili predefiniti, un tipo (`text` / `html` / `image`) e un **GUID** univoco.
- **Pannello** `/admin/content`: i blocchi sono raggruppati per pagina, con ricerca per tag o contenuto, upload delle immagini e ripristino all'originale.
- **Modifica inline**: attivando "Mostra tag" dal menu utente, ogni blocco mostra un badge (nome tag + matita) che apre una finestra di modifica direttamente in pagina.
- **Componenti** `<T>` e `<TImg>`: rendono i blocchi con fallback automatico al seed quando un tag non è ancora nel database.
Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui contenuti già esistenti (rinomine, testi aggiornati) si applicano tramite **migrazioni idempotenti** in `src/lib/db.ts`, così da non sovrascrivere le modifiche fatte dal pannello.
> 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
Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
| Ruolo | Può fare |
|-------|----------|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), piattaforme |
| `superuser` | Pannello contenuti + blog |
| `user` | Solo i **propri** articoli del blog |
| `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, 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.
> 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.
## 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`.
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
```
insanitylab-website/
├── src/
│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus, /piattaforme)
│ ├── components/ # Componenti (home/, content/, stress-index/, Header, Footer, …)
│ ├── layouts/ # Layout Base, Admin, Campus, StressIndex
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, piattaforme, content-seed)
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env, stress-index/
│ ├── assets/img/ # Immagini ottimizzate da Astro
│ └── styles/ # global.css, stress-index.css
├── data/ # SQLite (volume in produzione)
├── uploads/ # Upload immagini (volume in produzione)
├── campus-content/ # Contenuti del Campus generati da sync-campus
├── scripts/ # create-user, sync-campus, seed-blog-biohacking, smoke test
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── compose.yaml # Stack Docker + label Traefik
├── Dockerfile # Build multi-stage
├── .env.example # Template variabili d'ambiente
└── README.md
```
## Variabili d'ambiente
Il file `.env` (non versionato) richiede:
| Variabile | Descrizione |
|-----------|-------------|
| `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
| `SMTP_PORT` | Porta SMTP (`465` con SSL) |
| `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
| `SMTP_PASS` | Password della casella |
| `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
| `CONTACT_TO` | Destinatario dei messaggi del form |
| `DB_PATH` | Percorso del database SQLite |
| `UPLOADS_DIR` | Cartella degli upload |
| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) |
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
## Deploy in produzione
Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento:
```bash
cd /opt/docker/insanitylab/src
sudo git -c safe.directory="$PWD" pull origin main # il repo è di root: serve sudo, con password
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 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 ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik).
- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati).
- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`).
> Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403.
## Problemi comuni
**`better-sqlite3` non compila su `npm install`** — servono i build tools:
- Linux: `sudo apt-get install build-essential python3`
- macOS: `xcode-select --install`
- Windows: Visual Studio Build Tools
**Modifiche allo `<style>` scoped di `[slug].astro` non compaiono in dev** — l'HMR di Astro 7 non sempre ricompila lo stile scoped delle route dinamiche: riavviare il dev server (`astro dev stop` + `npm run dev`).
**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
Proprietà di InsanityLab.
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({
site: 'https://insanitylab.it',
output: 'server',
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
// sottodominio tielogic.xyz redirigono all'apex a livello Traefik.
security: {
allowedDomains: [
{ hostname: 'insanitylab.it', protocol: 'https' },
{ hostname: 'www.insanitylab.it', protocol: 'https' },
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
],
},
adapter: node({ mode: 'standalone' }),
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
redirects: {
'/programs': { status: 301, destination: '/services' },
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
},
integrations: [react()],
});
+54
View File
@@ -0,0 +1,54 @@
# ⚠️ `name:` esplicito. Fino al 03/09/2026 il progetto compose si chiamava `src`, dedotto
# dalla cartella /opt/docker/insanitylab/src. Con lo spostamento sotto apps/sito la cartella
# cambia nome e il progetto dedotto diventerebbe `sito`: per docker sarebbe un progetto NUOVO,
# e il container `insanitylab` gia' in piedi risulterebbe di un altro — con il nome occupato.
# Al primo deploy dopo la separazione va fermato il progetto vecchio PRIMA di alzare questo:
# docker compose -p src -f <vecchio compose> down
# Poi da qui: docker compose up -d --build
name: insanitylab
services:
insanitylab:
build: .
image: insanitylab-website:latest
container_name: insanitylab
restart: always
env_file:
- .env
environment:
- HOST=0.0.0.0
- PORT=4321
- DB_PATH=/app/data/insanitylab.db
- UPLOADS_DIR=/app/uploads
volumes:
- /opt/docker/insanitylab/data:/app/data
- /opt/docker/insanitylab/uploads:/app/uploads
labels:
- traefik.enable=true
# Router principale: solo il dominio ufficiale canonico (apex insanitylab.it)
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.it`)
- traefik.http.routers.insanitylab.tls=true
- traefik.http.routers.insanitylab.entrypoints=websecure
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
# ⚠️ `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.entrypoints=websecure
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
- traefik.http.routers.insanitylab-redir.service=insanitylab
- traefik.http.routers.insanitylab-redir.middlewares=insanitylab-tocanon
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.regex=^https?://[^/]+/(.*)
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.replacement=https://insanitylab.it/$${1}
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.permanent=true
networks:
- traefik
networks:
traefik:
external: true
View File
+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
@@ -0,0 +1,405 @@
# Longevity — l'interfaccia: piano di implementazione
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** dare una faccia al fascicolo del cliente — il questionario che si compila in pagina e il referto che il cliente legge, col radar a sette assi.
**Architecture:** pagine Astro dentro il sito esistente, sotto `/longevity/`, con un layout dedicato e un foglio di stile prefissato. Il contenuto statico è Astro; React solo dove serve interattività vera — il questionario a blocchi e il radar. I dati arrivano dal motore già costruito, mai da query scritte nelle pagine.
**Tech Stack:** Astro, React, recharts (già in casa), better-sqlite3, vitest. Nessuna dipendenza nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
**Piani precedenti, già eseguiti:** `2026-08-21-longevity-strato-dati.md` e `2026-08-22-longevity-motore.md`. Lo strato dati e il motore esistono, sono testati, e nessuna pagina li usa ancora.
## Global Constraints
- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
- **Nessuna dipendenza npm nuova.**
- Test in `tests/longevity/`, eseguiti con `npm test`. Codice e commenti in italiano.
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
- ⚠️ **`npx tsc --noEmit` deve restare pulito.** In questo lavoro il tipo è il meccanismo di sicurezza: un typecheck rosso è un cancello che nessuno guarda più.
- **Nessun dato reale di persone** nelle fixture.
## Le due regole di prodotto che l'interfaccia non può violare
**1. Un punteggio insufficiente non si mostra come numero.** Il motore restituisce un tipo che nel ramo insufficiente **non ha il campo `valore`**: la pagina non ha da dove prenderlo. Ma la barriera finisce lì — la tabella dei punteggi è letta da una funzione tipata (`leggiScore`), e **le pagine devono usare quella**, mai una query propria. Una `SELECT valore FROM score` scritta in una pagina riapre il buco.
**2. Il colore non giudica il corpo.** Niente semaforo verde/giallo/rosso sui punteggi: il servizio è premium ma **non clinico**, e un rosso su «Composizione Corporea» detto a una persona è un giudizio, non un'informazione. I punteggi si esprimono con l'**intensità** del colore d'accento del sito. L'unico colore di segnale — il mattone `#b05a4e` già presente nel foglio di Stress Index — è riservato ai **valori fuori range**, che segnalano un problema **della misura**, non della persona.
## Il gesto che questa interfaccia deve avere
Il radar mostra **quello che sappiamo** come area piena, e dove la misura non basta lascia un **perimetro tratteggiato** con, sotto, cosa manca per completarlo. Non un buco, non un errore: un invito.
Regge tre cose insieme — la regola di prodotto («tratteggiato, mai un numero pieno fasullo»), la leva commerciale che il cliente voleva per il livello avanzato, e una posizione onesta: *non ti diciamo un numero che non sappiamo*.
⚠️ **Con i dati di oggi sei assi su sette saranno tratteggiati**, perché nel registro c'è solo il questionario e i test fisici arrivano col piano degli import. È atteso: chi guarda la prima dashboard non deve scambiarlo per un guasto.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `src/layouts/Longevity.astro` | layout della piattaforma: intestazione, niente header pubblico |
| `src/styles/longevity.css` | stile prefissato `.lg`, globale (deve raggiungere le isole React) |
| `src/pages/longevity/questionario.astro` | la pagina del questionario |
| `src/components/longevity/Questionario.tsx` | isola React: quattro blocchi, validazione, invio |
| `src/pages/api/longevity/questionario.ts` | endpoint che salva una compilazione |
| `src/pages/longevity/io.astro` | il referto del cliente |
| `src/components/longevity/Radar.tsx` | isola React: il radar a sette assi col tratteggio |
| `src/components/longevity/MacroScore.astro` | le tre carte dei macro-score |
| `src/lib/longevity/vista.ts` | ciò che serve alle pagine, letto dal motore |
---
### Task 1: Il guscio — layout, stile, e una rotta che risponde
**Files:**
- Create: `src/layouts/Longevity.astro`, `src/styles/longevity.css`, `src/pages/longevity/index.astro`
- Test: `tests/longevity/pagine.test.ts`
**Interfaces:**
- Consumes: `isProtectedPath`, `canAccessAdminPath` da `src/lib/auth.ts`
- Produces: il layout `Longevity.astro` con `title` e `crumbs`, e le classi `.lg-*`
**Il modello da seguire:** `src/layouts/StressIndex.astro` e `src/styles/stress-index.css`. Leggili prima di scrivere. Il foglio di stile va **globale e prefissato**, non in `<style>` scoped: le stesse classi servono ai componenti Astro e alle isole React, che lo scope di Astro non raggiunge — la ragione è scritta in testa al foglio di Stress Index.
**La differenza voluta rispetto a Stress Index.** Quella piattaforma ha una barra laterale con sette voci, giusta per chi ci **lavora** dentro. Qui i pubblici sono due e uno non è staff: il **cliente** entra tre o quattro volte l'anno per guardare il proprio referto, e una barra di navigazione è l'arredamento di uno strumento di lavoro. Quindi il layout prevede due forme: `variante="referto"` (una colonna che scorre, nessuna navigazione) e `variante="gestionale"` (barra laterale come Stress Index). Questo task realizza la prima; la seconda arriva col piano del gestionale.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/pagine.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
describe('il guscio della piattaforma', () => {
it('il layout esiste e non monta header e footer pubblici', () => {
const l = leggi('src/layouts/Longevity.astro');
expect(l).not.toMatch(/import\s+Header/);
expect(l).not.toMatch(/import\s+Footer/);
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
});
it('il foglio di stile e globale e prefissato, non scoped', () => {
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
const css = leggi('src/styles/longevity.css');
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
expect(classi.length).toBeGreaterThan(3);
expect(classi.every((c) => c.startsWith('lg'))).toBe(true);
});
it('usa i token del sito invece di inventare colori', () => {
const css = leggi('src/styles/longevity.css');
expect(css).toMatch(/var\(--c-/);
});
it('nessun semaforo sui punteggi: il colore non giudica il corpo', () => {
const css = leggi('src/styles/longevity.css').toLowerCase();
// il mattone del fuori range e ammesso; un verde "tutto bene" no
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
});
it('la rotta del cliente e sotto /longevity, dove le regole la proteggono', () => {
expect(existsSync(join(process.cwd(), 'src/pages/longevity/index.astro'))).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/pagine.test.ts`
Expected: FAIL — i file non esistono
- [ ] **Step 3: Write minimal implementation**
Il layout prende `title`, `crumbs` e `variante`, importa i font e i due fogli di stile (`global.css` e `longevity.css`), e dichiara `noindex`. Nessun `Header`/`Footer` pubblico: la navigazione è interna.
Il foglio `longevity.css` definisce, **tutte prefissate `lg`**: il guscio del referto (una colonna centrata, larghezza leggibile), l'intestazione, la carta di un punteggio, e i token locali. Le variabili locali riprendono quelle del sito (`--c-accent`, `--c-dark`, `--c-bg-alt`) e ne aggiungono due sole:
```css
--lg-tratteggio: #d8d2c8; /* il perimetro di cio che non sappiamo */
--lg-fuori-range: #b05a4e; /* SOLO per una misura sospetta, mai per un punteggio */
```
⚠️ Non definire un colore «buono» o «cattivo» per i punteggi: l'intensità si ottiene variando l'opacità dell'accento. È la regola di prodotto, e il test la controlla.
`src/pages/longevity/index.astro` reindirizza a `/longevity/io`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/pagine.test.ts` → PASS (5 test)
Poi: `npm test` per intero e `npx tsc --noEmit`.
- [ ] **Step 5: Commit**
```bash
git add src/layouts/Longevity.astro src/styles/longevity.css src/pages/longevity/index.astro tests/longevity/pagine.test.ts
git commit -m "longevity: il guscio della piattaforma, senza semaforo sui punteggi"
```
---
### Task 2: Il questionario in pagina
Sostituisce il prototipo del cliente, che salva nel browser e che il documento stesso dichiara da buttare in produzione.
**Files:**
- Create: `src/components/longevity/Questionario.tsx`, `src/pages/longevity/questionario.astro`, `src/pages/api/longevity/questionario.ts`
- Test: `tests/longevity/questionario-pagina.test.ts`
**Interfaces:**
- Consumes: `testAttivi` da `registro.ts`; `salvaCompilazione`, `QUEST_VERSION`, `CAMPI_LIBERI` da `questionario.ts`; `codicePerUtente` da `anagrafica.ts`
- Produces: `POST /api/longevity/questionario``{ sessioneId }` o errore
**Le domande vengono dal registro, non dal codice.** La pagina legge `testAttivi` e costruisce i campi da lì: etichetta, tipo, minimo e massimo. Aggiungere una domanda resta una modifica ai dati — è la promessa della spec, e qui va mantenuta.
⚠️ **Tre regole che l'endpoint deve rispettare, e sono di sostanza:**
1. **Il codice cliente si ricava dalla sessione, mai dalla richiesta.** Un codice che arriva dal browser va ignorato. È la regola §8 della spec, e questo è il primo endpoint su cui si può finalmente provare.
2. **Il consenso al trattamento dei dati sanitari non è «compilato», è «dato».** Il prototipo del cliente blocca il salvataggio solo se il campo è vuoto: rispondendo **«No»** i dati vengono salvati lo stesso. È l'esatto contrario di ciò per cui il campo esiste. Qui: se il consenso non è affermativo, **la compilazione non si salva**, e la risposta lo dice chiaramente.
3. **Il consenso va registrato, non solo controllato.** Un consenso è un fatto che un domani va **dimostrato**: chi, quando, a cosa. Salvalo fra le note di profilo con la sua data e la versione del questionario, **anche quando la risposta è no** — il rifiuto è precisamente ciò di cui bisogna poter provare il rispetto.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/questionario-pagina.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
describe('i campi del questionario vengono dal registro', () => {
it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
const db = dbPronto();
const campi = campiDelQuestionario(db, '2026-08-22');
expect(campi.length).toBe(20);
const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
expect(alcol.etichetta).toMatch(/alcoliche/i);
expect(alcol.max).toBe(30);
});
it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
const campi = campiDelQuestionario(db, '2026-08-22');
expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
expect(campi.length).toBe(19);
});
});
describe('il salvataggio, e il consenso', () => {
const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
it('salva una compilazione quando il consenso e dato', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'Si', liberi: {},
});
expect(esito.ok).toBe(true);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(1);
});
it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'No', liberi: {},
});
expect(esito.ok).toBe(false);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(0); // niente sessione, niente misure
});
it('NON salva se il consenso manca del tutto', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: '', liberi: {},
});
expect(esito.ok).toBe(false);
});
it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
const db = dbPronto();
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'No', liberi: {},
});
const note = db.prepare(
`SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
).all() as { campo_id: string; testo: string }[];
expect(note.length).toBeGreaterThan(0);
expect(note[0].testo).toMatch(/No/);
});
it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
// il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
expect(src).toMatch(/codicePerUtente/);
expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/questionario-pagina.test.ts`
Expected: FAIL — `src/lib/longevity/vista.ts` non esiste
- [ ] **Step 3: Write minimal implementation**
`vista.ts` espone `campiDelQuestionario(db, alla)` — che legge `testAttivi` e tiene i soli `test_id` che iniziano per `q_` — e `salvaDalForm(db, input)`, che controlla il consenso **prima** di scrivere qualsiasi cosa, registra il consenso fra le note col suo esito e la versione del questionario, e delega a `salvaCompilazione`.
L'isola React mostra i quattro blocchi uno per schermata, con avanti e indietro, e invia in fondo. L'endpoint ricava il codice cliente dalla sessione con `codicePerUtente` e passa a `salvaDalForm`.
⚠️ Il consenso **rifiutato va scritto comunque** fra le note: apri la sessione solo se il consenso è affermativo, ma la nota del consenso si scrive in entrambi i casi, legata al cliente.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test` — atteso 1 fallito pre-esistente, il resto verde. Poi `npx tsc --noEmit` pulito.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/vista.ts src/components/longevity/Questionario.tsx \
src/pages/longevity/questionario.astro src/pages/api/longevity/questionario.ts \
tests/longevity/questionario-pagina.test.ts
git commit -m "longevity: il questionario in pagina, e il consenso che si puo negare davvero"
```
---
### Task 3: Il radar, e come si mostra ciò che non si sa
**Files:**
- Create: `src/components/longevity/Radar.tsx`, `src/components/longevity/MacroScore.astro`, `src/pages/longevity/io.astro`
- Modify: `src/lib/longevity/vista.ts` — aggiungere `refertoDi`
- Test: `tests/longevity/referto.test.ts`
**Interfaces:**
- Consumes: `calcolaSessione`, `leggiScore` dal motore; `codicePerUtente` da `anagrafica.ts`
- Produces:
- `type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] }`
- `refertoDi(db, sessioneId): { assi: AsseVista[]; macro: {...}[]; fitnessAge: number | null }`
**Il punto del task.** Un asse senza dati sufficienti non porta un numero: porta **cosa manca per averlo**. `mancano` è l'elenco dei test del registro che alimenterebbero quell'asse e che quella sessione non ha — è ciò che trasforma un buco in un invito, ed è il gesto su cui si regge tutta l'interfaccia.
⚠️ **Le pagine non interrogano il database.** Tutto passa da `refertoDi`, che a sua volta usa `leggiScore`. Una `SELECT` scritta dentro una pagina riaprirebbe il buco che il tipo del motore chiude.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/referto.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
import { refertoDi } from '../../src/lib/longevity/vista';
function conCompilazione() {
const db = createLongevityDb(':memory:');
seedRegistro(db); seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
});
salvaScore(db, s, calcolaSessione(db, s));
return { db, s };
}
describe('il referto del cliente', () => {
it('restituisce i sette assi', () => {
const { db, s } = conCompilazione();
expect(refertoDi(db, s).assi.length).toBe(7);
});
it('un asse senza dati NON porta un numero', () => {
const { db, s } = conCompilazione();
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
expect(forza.valore).toBeNull();
});
it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
const { db, s } = conCompilazione();
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
expect(forza.mancano.length).toBeGreaterThan(0);
});
it('un asse coperto porta il suo numero', () => {
const { db, s } = conCompilazione();
const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
expect(stile.valore).not.toBeNull();
expect(stile.mancano).toEqual([]);
});
it('legge i punteggi congelati, non li ricalcola al volo', () => {
const { db, s } = conCompilazione();
// se rileggesse le misure invece degli score salvati, cambiare i pesi cambierebbe il referto
const prima = JSON.stringify(refertoDi(db, s));
db.prepare(`UPDATE pesi SET peso = 0.99 WHERE model_version = ? AND elemento = 'questionario_lifestyle'`).run(MODEL_VERSION);
expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
});
it('nessuna pagina interroga il database da sola', () => {
for (const f of ['src/pages/longevity/io.astro', 'src/pages/longevity/questionario.astro']) {
const src = readFileSync(join(process.cwd(), f), 'utf8');
expect(src, `${f} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/referto.test.ts`
Expected: FAIL — `refertoDi` non esiste
- [ ] **Step 3: Write minimal implementation**
`refertoDi` legge i punteggi congelati con `leggiScore` e, per ogni asse insufficiente, calcola `mancano`: i sotto-domini di quell'asse che non hanno nessuna misura nella sessione, tradotti nelle etichette leggibili del registro.
Il radar è un'isola React con `recharts`: due serie sovrapposte — una piena con i valori noti, una tratteggiata che chiude il perimetro dove il dato manca. Sotto, l'elenco di ciò che manca.
Le carte dei macro-score sono Astro. Un macro insufficiente mostra **un trattino**, non uno zero.
⚠️ I numeri usano `font-variant-numeric: tabular-nums`: in una pagina che confronta misure nel tempo, cifre che ballano sono un difetto di lettura.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test` — atteso 1 fallito pre-esistente. Poi `npx tsc --noEmit` pulito.
- [ ] **Step 5: Commit**
```bash
git add src/components/longevity/Radar.tsx src/components/longevity/MacroScore.astro \
src/pages/longevity/io.astro src/lib/longevity/vista.ts tests/longevity/referto.test.ts
git commit -m "longevity: il referto del cliente, e il radar che dice cosa non sa"
```
---
## Cosa esiste alla fine di questo piano
Un cliente entra, compila il questionario, e vede il proprio referto col radar. Il consenso ai dati sanitari si può **negare davvero**, e il rifiuto resta registrato.
**Non esiste ancora**: il gestionale del trainer, l'inserimento dei check-up, l'assorbimento delle sezioni HRV di Stress Index, la progressione nel tempo. Sono i piani successivi.
⚠️ **E non si deploya niente.** Il consenso al trattamento dei dati sanitari è una questione aperta che il cliente sta chiudendo con un consulente legale: si può costruire tutto, non si può accendere la raccolta su persone vere finché quella risposta non arriva.
@@ -0,0 +1,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.
@@ -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 |
@@ -0,0 +1,110 @@
# Sezione Campus — design
Data: 2026-07-25 · Branch: `feature/campus` · Stato: approvato (approccio A)
## Obiettivo
Integrare le guide di studio del corso Biohacking Campus (progetto esterno
`/data/Sorgenti/AI-OS/projects/personale/biohacking-campus`) come sezione
riservata del sito InsanityLab, visibile soltanto agli utenti autenticati con
un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito
(font, colori, CSS globale), non quello del generatore statico originale.
## Requisiti
1. **Nuovo ruolo `campus`**: vede soltanto la sezione `/campus`; nessun
accesso al pannello admin. `admin` accede a tutto. `superuser` e `user`
non accedono alla sezione campus.
2. **Generazione contenuti**: uno script legge le guide sorgente
(`*.studio.md`) dal progetto biohacking-campus e produce i contenuti nel
repo del sito. Rigenerabile a ogni aggiornamento del corso.
3. **Gestione utenti**: la pagina `/admin/users` esistente deve permettere di
creare/assegnare il ruolo `campus`.
4. **Navigazione dedicata**: dentro `/campus` non compaiono Header e Footer
del sito; navigazione propria (sidebar aree/capitoli) più un solo
richiamo "← Torna alla home" verso `/`.
5. **Protezione totale**: pagine e immagini (assets slide) mai servite a
utenti non autorizzati.
## Architettura
### Ruoli e accessi (`src/lib/auth.ts`, `src/middleware.ts`)
- `Role = 'admin' | 'superuser' | 'user' | 'campus'`.
- `RULES` esteso: `/campus/*``['campus', 'admin']` (admin passa sempre già
oggi per short-circuit).
- Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`,
`/api/admin`). Non autenticato → redirect a `/admin/login`.
- `landingFor('campus')``/campus`. La pagina di login rimandava sempre a
`/admin`: ora usa `landingFor` sul ruolo dell'utente appena autenticato,
così ogni ruolo atterra su una pagina che può davvero aprire.
- Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del
pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per
`campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti.
- `/admin/users`: aggiunta option `campus` nelle due select (nuovo utente,
cambio ruolo).
### Sync contenuti (`scripts/sync-campus.mjs`)
- Input: root del progetto biohacking-campus (arg CLI, default il path locale
di sviluppo).
- Porta la struttura dati del generatore Python (`CHAPTERS`, `AREAS`, regex
intestazioni) in JS. Legge i `*.studio.md` da `studio/`, `qa/`, `slide/`,
`accelerator/`, `lezioni-live/`, `speaker/`, `consulenze/`, `masterclass/`.
- Converte markdown → HTML con `marked` (nuova devDependency) e riscrive i
riferimenti alle immagini slide verso `/campus/assets/...`.
- Output in `campus-content/` (root repo, committato):
- `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo,
docente, prev/next). La catena prev/next segue l'ordine di navigazione
aree → capitoli → guide, non la numerazione dei capitoli: le aree li
raggruppano in un ordine diverso;
- `pages/<slug>.html` — frammento HTML del corpo di ogni guida;
- `assets/` — immagini slide copiate (~37 MB, commit una tantum).
- Idempotente: rigenera tutto e rimuove i file orfani.
### Rendering (`src/pages/campus/`, `src/layouts/Campus.astro`)
- `Campus.astro`: usa `global.css` e i font del sito; nessun `Header`/`Footer`;
sidebar con aree/capitoli/lezioni (voce corrente evidenziata) e link
"← Torna alla home"; responsive con menu mobile.
- Route SSR (runtime, non prerender — servono i file in `campus-content/`):
- `/campus` — portale con le card delle aree (da `nav.json`);
- `/campus/[...slug]` — pagina area (indice capitoli) o pagina guida
(frammento HTML + breadcrumb + prev/next). Slug non trovato → 404.
- `/campus/assets/[...path]` — endpoint che serve le immagini da
`campus-content/assets/` (protetto dal middleware come tutto `/campus`);
path traversal rifiutato; `Content-Type` da estensione; cache privata.
- Lettura file a runtime da `CAMPUS_DIR` (env, default `./campus-content`),
con cache in memoria di `nav.json`.
### Deploy (`Dockerfile`)
- Lo stage runtime copia solo `dist` e `scripts`; si aggiunge
`COPY --from=build /app/campus-content ./campus-content`.
- `/app/data` e `/app/uploads` sono volumi: `campus-content/` resta fuori da
entrambi, dentro l'immagine. Nessuna modifica a `compose.yaml`.
## Error handling
- `campus-content/` assente (sync mai eseguito): `/campus` risponde 404 con
messaggio esplicito nei log; il resto del sito non è impattato.
- Slug o asset inesistente → 404. Path traversal su assets → 400.
- Sync: file `.studio.md` con intestazione non riconosciuta → warning e skip,
exit code ≠ 0 solo per errori I/O.
## Testing
- `tests/auth-campus.test.ts`: matrice ruoli × percorsi (`campus` su
`/campus` sì, su `/admin/content` no; `user` su `/campus` no; ecc.),
`landingFor`, `isRole('campus')`.
- `tests/sync-campus.test.ts`: parsing intestazioni (lezione, Q&A, slide,
accelerator, consulenza, masterclass, speaker), generazione nav e
frammenti su fixture minima, riscrittura path immagini.
- Verifica manuale: login con utente `campus` (vede solo campus), `user`
(non vede campus), navigazione sidebar, immagini slide, mobile.
## Fuori scope
- Nessun tracciamento progressi/completamento lezioni.
- Nessuna ricerca full-text.
- Nessuna migrazione automatica degli utenti esistenti al nuovo ruolo.
+7999
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "insanitylab-website",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node --env-file=.env ./dist/server/entry.mjs",
"test": "vitest run",
"create-user": "node scripts/create-user.mjs",
"seed-blog": "node scripts/seed-blog-biohacking.mjs"
},
"dependencies": {
"@astrojs/node": "^11.0.1",
"@astrojs/react": "^6.0.2",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.0.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#3a2929"/>
<path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b8a98c" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Disallow: /admin
Disallow: /api
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
+42
View File
@@ -0,0 +1,42 @@
// Pipeline asset: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
// 1. extract-fig-images.sh: estrae hash-named images da .fig → assets-src/
// 2. apply-asset-map.mjs: copia da assets-src/ → destinazioni (src/assets/img, public/img)
// 3. optimize-assets.sh: converte PNG fotografici → JPG q85, ridimensiona ≤1400px
// 4. extract-logos.sh: estrae loghi insanitylab da FigmaDoc/Homepage.pdf → public/img/logo-{dark,light}.png
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
let n = 0, missing = 0;
// Sezione principale: copia da assets-src/
for (const [destDir, entries] of Object.entries(map)) {
if (destDir.startsWith('_') || destDir === 'fallbacks') continue;
mkdirSync(join(root, destDir), { recursive: true });
for (const [src, dest] of Object.entries(entries)) {
const from = join(root, 'assets-src', src);
if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
copyFileSync(from, join(root, destDir, dest));
n++;
}
}
// Sezione fallbacks: copia da path relativo alla root del repo (es. ../Dati/)
if (map.fallbacks) {
for (const [destDir, entries] of Object.entries(map.fallbacks)) {
mkdirSync(join(root, destDir), { recursive: true });
for (const [srcRelPath, dest] of Object.entries(entries)) {
const from = join(root, srcRelPath);
if (!existsSync(from)) { console.error(`MANCANTE (fallback): ${srcRelPath}`); missing++; continue; }
copyFileSync(from, join(root, destDir, dest));
n++;
}
}
}
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
process.exit(missing ? 1 : 0);
+37
View File
@@ -0,0 +1,37 @@
{
"_nota": "Le destinazioni .png fotografiche vengono convertite in .jpg da optimize-assets.sh; il nome finale su disco ha estensione .jpg",
"src/assets/img": {
"8b71f158c30a0a23cc5113bfe1cd9a6c4f19e4c4.jpg": "hero-1.jpg",
"0f8034394657985cc4cf35eb8441ae93a7ce5302.jpg": "info-performance.jpg",
"432d397a342b4e8312a3a13a4fea836b3e867a64.jpg": "info-balance.jpg",
"d7fcf0aa827ec50f660145c0d319049b9153fefb.jpg": "info-longevity.jpg",
"6d6025730c3d0b73c0052fa67b1bb111c8462a08.jpg": "feature-performance.jpg",
"16e611de84f51bf5d01486a5e74ba0f8249850be.jpg": "feature-balance.jpg",
"98f1f8d257dfeffbaf35507d033e5bb51e08895e.png": "feature-longevity.png",
"28e8157dc88a42cffdbcd028e4cb02a6eb3de337.png": "quote-bg.png",
"85808a0c9aeb461c70c059b013eab47faa4fc4f1.png": "training-one-to-one.png",
"bb632b8da5e9e4d4b05207f04a8883163caac53a.png": "training-small-groups.png",
"91aa82b5dedee8ad56c6db249c9947c7d6d9c89a.png": "training-performance-class.png",
"86c1363b22a8fb3bb7b2bf044e52856d474d927e.jpg": "training-fit-remote.jpg",
"0b6cf69438af21bbca934b0b27995ac4fa967451.png": "team-donata.png",
"1cc33c41368d47c30d949e991c5a442ff51d1a91.png": "team-nicola.png",
"a75ca105e362e3c3bc5cfe0bfc7c39e69645660d.png": "team-eleonora.png",
"e3c84f9989df8b160d185bd40b774e460a4f6896.png": "team-antonello.png",
"88cfcd5fd321b5ca6e4833e3980b792a95090545.jpg": "method-step-1.jpg",
"d21671bb08f4f2eadde09f91a46b568788a209d7.jpg": "method-step-2.jpg",
"a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg": "method-step-3.jpg",
"e50b6f7818b3742ddba0f4ad4a162a8aafc505e0.jpg": "method-step-4.jpg",
"bee22f37bba6f18d3defcd47762375e1c8ddd0f5.png": "partner-edenred.png",
"cf079250ea1db7f0055b76af21e04b1ed1870770.png": "partner-welbee.png",
"872c3679df24bd0d3fdca7c4cb9a24d1836309a0.png": "partner-howden.png",
"014a7708ad61b12c5765ad4a60539d2fa325f9aa.png": "partner-aon.png",
"8ffc5f7a2428b0d0eabd09f676ab98d3e5644f0d.png": "about-exterior.png",
"32ecd00a20321e58c700fbaf739e53ac3bf03876.png": "about-team-group.png",
"d4608b102eb67ac067e6585250b3c37963db1d34.png": "about-training-group.png",
"dafcc19362e3a76dd20e9a656499a8068a3a9fca.png": "about-trainer-1.png",
"9c50f1d3b3a0477e80e03c0849d3d4687610b461.png": "about-trainer-2.png",
"c51a49b9be7f1b1a31de6b2f5261c8c5b5fdab52.png": "about-trainer-3.png",
"3341a11b7153349a23c75b1fa2a9a62d48953330.png": "about-trainer-4.png"
},
"_loghi": "logo-dark.png e logo-light.png estratti da extract-logos.sh (FigmaDoc/Homepage.pdf), non da ../Dati"
}
+30
View File
@@ -0,0 +1,30 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , username, password, role = 'admin'] = process.argv;
if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1);
}
// Lista duplicata di proposito: questo file è JavaScript e non può importare da
// 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);
}
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);`);
db.prepare(
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
).run(username, bcrypt.hashSync(password, 12), role);
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
FIG="../FigmaDoc/Web Insanity.fig"
OUT="assets-src"
rm -rf "$OUT" && mkdir -p "$OUT"
unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
cd "$OUT"
for f in *; do
case "$(file --brief --mime-type "$f")" in
image/png) mv "$f" "$f.png" ;;
image/jpeg) mv "$f" "$f.jpg" ;;
image/webp) mv "$f" "$f.webp" ;;
*) rm -f "$f" ;;
esac
done
echo "Estratte: $(ls | wc -l) immagini in $OUT/"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# extract-logos.sh — estrae loghi insanitylab dal PDF Figma Homepage
# Pipeline: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
# Idempotente: stessi file ad ogni esecuzione, coordinate fisse.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PDF="$ROOT/../FigmaDoc/Homepage.pdf"
OUT="$ROOT/public/img"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -f "$PDF" ]]; then
echo "ERRORE: PDF non trovato: $PDF" >&2
exit 1
fi
mkdir -p "$OUT"
echo "Estrazione loghi da $PDF..."
# ── Logo DARK (header, su fondo bianco) ─────────────────────────────────────
# Zona: x=104, y=41, W=895, H=250 px a 300dpi (pt: x≈25, y≈10, W≈215, H≈60)
pdftoppm -png -r 300 -f 1 -l 1 \
-x 104 -y 41 -W 895 -H 250 \
"$PDF" "$WORK/hdr"
convert "$WORK/hdr-1.png" \
-fuzz 8% -transparent white \
-trim +repage \
-resize 600x \
-strip \
"$OUT/logo-dark.png"
# ── Logo LIGHT (footer, su fondo scuro #3A2929) ─────────────────────────────
# Zona: x=0, y=35375, W=1800, H=833 px a 300dpi (pt: x≈0, y≈8490, W≈432, H≈200)
# poi crop interno: x=130, y=60, W=850, H=660 per includere solo icona+testo
pdftoppm -png -r 300 -f 1 -l 1 \
-x 0 -y 35375 -W 1800 -H 833 \
"$PDF" "$WORK/ftr"
convert "$WORK/ftr-1.png" \
-crop 850x660+130+60 +repage \
-fuzz 12% -transparent 'srgb(58,41,41)' \
-trim +repage \
-resize 600x \
-strip \
"$OUT/logo-light.png"
echo "Logo dark: $OUT/logo-dark.png $(identify -format '%wx%h' "$OUT/logo-dark.png")"
echo "Logo light: $OUT/logo-light.png $(identify -format '%wx%h' "$OUT/logo-light.png")"
echo "Done."
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Genera la locandina dell'In-Sanity Hour in PDF vettoriale, con il QR code vero.
Perché non si parte da un sorgente grafico: l'unico file disponibile è un JPEG da
612x751 px, che stampato su A4 darebbe 70 dpi. La locandina però è fatta di tre soli
colori piatti, quindi si può ricalcare: ogni forma — testi, calligrafia, bicchieri —
diventa un tracciato vettoriale che si stampa nitido a qualsiasi formato, senza dover
reinstallare i font originali (che non abbiamo) né riprodurli a occhio.
Del JPEG resta solo la geometria. Il QR di prova viene coperto e ridisegnato con
l'URL vero della pagina evento, anch'esso vettoriale.
Uso: python3 scripts/locandina-evento.py
Serve: pip install --user potracer qrcode cairosvg
"""
from pathlib import Path
import cairosvg
import numpy as np
import potrace
import qrcode
from PIL import Image
URL_EVENTO = 'https://insanitylab.it/promo-eventi/eventi'
RADICE = Path(__file__).resolve().parent.parent
SORGENTE = RADICE.parent / 'Scaricati' / 'Locandina.jpeg'
USCITA_PDF = RADICE / 'public' / 'locandina-insanity-hour.pdf'
USCITA_PNG = RADICE / 'public' / 'img' / 'locandina-insanity-hour.png'
USCITA_TITOLO = RADICE / 'public' / 'img' / 'insanity-hour-titolo.png'
# Riquadro stampato dentro il JPEG, senza il bordo bianco dello screenshot.
RIQUADRO = (22, 13, 597, 732)
# Blocco "SAVE THE DATE / IN-SANITY / Hour" usato come immagine nella pagina web.
TITOLO_WEB = (23, 24, 596, 275)
# Quadrato del QR di prova da sostituire, in coordinate del JPEG originale.
QR_PROVA = (387, 632, 475, 720)
# Bordo superiore della fascia beige: la toppa sul QR non deve sconfinare nel marrone.
FASCIA_Y = 627
FONDO, BEIGE, CREMA = '#4F3126', '#D0B49E', '#F6F1E9'
CENTRI = [(79, 49, 38), (208, 180, 158), (246, 241, 233)]
UPSCALE = 4 # si traccia sull'immagine ingrandita: bordi piu' morbidi, curve piu' fedeli
MODULI_QUIET = 4 # margine muto richiesto dallo standard QR, in moduli
def classifica(immagine: Image.Image) -> np.ndarray:
"""Assegna a ogni pixel il piu' vicino dei tre colori piatti della locandina."""
grande = immagine.resize((immagine.width * UPSCALE, immagine.height * UPSCALE), Image.LANCZOS)
px = np.asarray(grande).astype(np.int16)
distanze = np.stack([((px - np.array(c)) ** 2).sum(axis=2) for c in CENTRI])
return np.argmin(distanze, axis=0)
def traccia(maschera: np.ndarray) -> str:
"""Contorni della maschera come path SVG. potracer prende per foreground i pixel
falsi, quindi la maschera va negata."""
percorso = potrace.Bitmap(~maschera).trace(turdsize=6, alphamax=1.0, opticurve=True, opttolerance=0.2)
d = []
for curva in percorso.curves:
avvio = curva.start_point
d.append(f'M{avvio.x / UPSCALE:.2f},{avvio.y / UPSCALE:.2f}')
for seg in curva.segments:
fine = seg.end_point
if seg.is_corner:
d.append(f'L{seg.c.x / UPSCALE:.2f},{seg.c.y / UPSCALE:.2f}L{fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}')
else:
d.append(
f'C{seg.c1.x / UPSCALE:.2f},{seg.c1.y / UPSCALE:.2f}'
f' {seg.c2.x / UPSCALE:.2f},{seg.c2.y / UPSCALE:.2f}'
f' {fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}'
)
d.append('Z')
return ''.join(d)
def qr_svg(x0: float, y0: float, x1: float, y1: float) -> str:
"""QR dell'URL evento, disegnato a rettangoli dentro il riquadro dato.
Il QR di prova sfiorava il bordo marrone: qui il modulo viene calcolato includendo
il margine muto, cosi' il codice resta leggibile anche stampato in piccolo.
"""
codice = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=1, border=0)
codice.add_data(URL_EVENTO)
codice.make(fit=True)
matrice = codice.get_matrix()
n = len(matrice)
lato = min(x1 - x0, y1 - y0)
modulo = lato / (n + 2 * MODULI_QUIET)
off_x = x0 + (x1 - x0 - lato) / 2 + MODULI_QUIET * modulo
off_y = y0 + (y1 - y0 - lato) / 2 + MODULI_QUIET * modulo
quadrati = [
f'<rect x="{off_x + c * modulo:.3f}" y="{off_y + r * modulo:.3f}" '
f'width="{modulo:.3f}" height="{modulo:.3f}"/>'
for r, riga in enumerate(matrice)
for c, pieno in enumerate(riga)
if pieno
]
return f'<g fill="{FONDO}" shape-rendering="crispEdges">{"".join(quadrati)}</g>', n
def main() -> None:
originale = Image.open(SORGENTE).convert('RGB')
locandina = originale.crop(RIQUADRO)
larghezza, altezza = locandina.size
etichette = classifica(locandina)
dx, dy = RIQUADRO[0], RIQUADRO[1]
qx0, qy0, qx1, qy1 = QR_PROVA[0] - dx, QR_PROVA[1] - dy, QR_PROVA[2] - dx, QR_PROVA[3] - dy
qr, moduli = qr_svg(qx0, qy0, qx1, qy1)
toppa_y = max(qy0 - 6, FASCIA_Y - dy + 1)
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {larghezza} {altezza}" width="{larghezza}" height="{altezza}">
<rect width="{larghezza}" height="{altezza}" fill="{FONDO}"/>
<path fill="{BEIGE}" d="{traccia(etichette == 1)}"/>
<path fill="{CREMA}" d="{traccia(etichette == 2)}"/>
<rect x="{qx0 - 6}" y="{toppa_y}" width="{qx1 - qx0 + 12}" height="{qy1 + 6 - toppa_y}" fill="{BEIGE}"/>
{qr}
</svg>'''
USCITA_PDF.parent.mkdir(parents=True, exist_ok=True)
# Proporzioni native (575x719). cairosvg legge i px a 96 dpi: per una base di 210 mm
# (la larghezza di un A4) la pagina va chiesta in px, non in punti.
px_per_mm = 96 / 25.4
scala = 210 * px_per_mm / larghezza
cairosvg.svg2pdf(bytestring=svg.encode(), write_to=str(USCITA_PDF),
output_width=larghezza * scala, output_height=altezza * scala)
cairosvg.svg2png(bytestring=svg.encode(), write_to=str(USCITA_PNG),
output_width=larghezza * 3, output_height=altezza * 3)
originale.crop(TITOLO_WEB).save(USCITA_TITOLO)
print(f'locandina {larghezza}x{altezza} px -> {USCITA_PDF.relative_to(RADICE)} '
f'({210:.0f}x{210 * altezza / larghezza:.0f} mm)')
print(f'anteprima {larghezza * 3}x{altezza * 3} -> {USCITA_PNG.relative_to(RADICE)}')
print(f'QR {moduli}x{moduli} moduli -> {URL_EVENTO}')
if __name__ == '__main__':
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Passo 3 della pipeline asset (dopo apply-asset-map.mjs).
# Converte PNG fotografici in src/assets/img/ → JPG q83 e ridimensiona a ≤1400x1600px.
# I PNG partner-* (loghi con trasparenza) restano PNG.
# Tutti i JPG esistenti vengono ri-codificati a q83 con vincolo 1400x1600>
# (larghezza ≤1400 px E altezza ≤1600 px, aspect ratio preservato).
# Idempotente se abbinato a apply-asset-map.mjs (che ripristina i sorgenti originali).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IMG_DIR="$ROOT/src/assets/img"
cd "$IMG_DIR"
# --- PNG fotografici → JPG q83 + resize ≤1400x1600 ---
shopt -s nullglob
declare -A just_converted
converted=0
for png in *.png; do
[[ "$png" == partner-* ]] && continue
jpg="${png%.png}.jpg"
convert "$png" -quality 83 -resize '1400x1600>' "$jpg"
rm "$png"
echo " PNG→JPG: $png$jpg"
just_converted["$jpg"]=1
(( converted++ )) || true
done
# --- Ri-codifica JPG esistenti a q83 + vincolo 1400x1600 ---
# Skip file appena convertiti (già a q83), elabora tutti gli altri.
reencoded=0
for jpg in *.jpg; do
[[ -n "${just_converted[$jpg]+x}" ]] && continue
mogrify -quality 83 -resize '1400x1600>' "$jpg"
echo " Ricodificato: $jpg"
(( reencoded++ )) || true
done
echo "Ottimizzazione completata: ${converted} PNG→JPG, ${reencoded} JPG ricodificati (q83, ≤1400x1600)."
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env node
/**
* seed-blog-biohacking.mjs — carica nel blog venti articoli divulgativi tratti dal corso
* base del Biohacking Campus: dieci tecnici a firma Nicola Antonelli e dieci educativi a
* firma Donata Dal Pastro.
*
* PERCHÉ UNO SCRIPT
* Gli articoli vivono nel database, che in produzione è un volume del VPS e non viene
* toccato dal deploy: creandoli a mano in locale resterebbero in locale. Questo script è
* versionato, quindi si esegue anche là.
*
* USO
* npm run seed-blog (usa data/insanitylab.db, o DB_PATH se impostata)
*
* IDEMPOTENTE
* Salta gli articoli il cui slug è già presente e non tocca quelli modificati a mano.
* Gli autori vengono creati solo se mancano, con una password casuale: per usare quegli
* account si passa dal pannello Utenti e si imposta una password.
*/
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
const DB_PATH = process.env.DB_PATH ?? 'data/insanitylab.db';
const TECNICI = 'Articoli tecnici & scientifici';
const EDUCATIVI = 'Contenuti educativi';
const NICOLA = 'Nicola Antonelli';
const DONATA = 'Donata Dal Pastro';
const p = (...paragrafi) => paragrafi.join('\n');
const articoli = [
// ---------------------------------------------------------------- tecnici
{
autore: NICOLA, categoria: TECNICI,
slug: 'sonno-cicli-non-ore',
title: 'Il sonno si misura in cicli, non in ore',
excerpt: 'Sette ore spezzate valgono meno di sei ore intere. Come funziona l\'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.',
body: p(
'<p>Quando parliamo di sonno, quasi tutti pensano a un numero: sette ore, otto ore. È una misura comoda ma imprecisa, perché il sonno non è un blocco uniforme. È una sequenza di cicli, ciascuno della durata media di novanta minuti, in cui si alternano fasi leggere, sonno profondo e fase REM.</p>',
'<p>Ogni fase ha un compito diverso. Nel sonno profondo il corpo ripara i tessuti e libera l\'ormone della crescita; nella fase REM il cervello riordina memoria ed emozioni. Interrompere un ciclo a metà significa svegliarsi nel mezzo di un lavoro non finito: è la ragione per cui a volte ci si alza più stanchi dopo otto ore che dopo sei e mezza.</p>',
'<h2>Quattro o cinque cicli, non un numero fisso</h2>',
'<p>Per un adulto l\'obiettivo ragionevole sono quattro o cinque cicli completi, cioè fra le sei e le sette ore e mezza. Contare i cicli invece delle ore cambia anche il modo di scegliere l\'orario della sveglia: meglio anticiparla di venti minuti per chiudere un ciclo, che rubarne dieci nel mezzo del successivo.</p>',
'<h2>I micro-risvegli non sono un problema</h2>',
'<p>Durante la notte ci si sveglia molte più volte di quanto si ricordi: un centinaio di micro-risvegli è considerato normale e non compromette la qualità del riposo. Sono la loro frequenza eccessiva e la difficoltà a riaddormentarsi a segnalare un sonno realmente disturbato, non il singolo risveglio notturno che tutti sperimentiamo.</p>',
'<h2>La notte si prepara di giorno</h2>',
'<p>Il punto meno intuitivo è che la qualità del sonno si decide molto prima di andare a letto. Il rilascio serale di melatonina segue un conto alla rovescia che parte dal momento in cui ci si sveglia e si prende la prima luce del giorno; il cortisolo, il suo antagonista, dovrebbe salire al mattino e scendere la sera. Luce forte a tarda ora, cene abbondanti e allenamenti intensi a ridosso della notte spostano questo equilibrio.</p>',
'<p>Per chi si allena la conseguenza è concreta: il lavoro fatto in palestra diventa adattamento durante la notte. Se il sonno è compromesso, lo stimolo c\'è ma la risposta arriva a metà.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'hrv-variabilita-cardiaca',
title: 'HRV: che cosa dice davvero la variabilità cardiaca',
excerpt: '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.',
body: p(
'<p>Sempre più orologi e anelli mostrano un valore chiamato HRV, variabilità della frequenza cardiaca. Viene spesso letto come una pagella quotidiana, ma misura qualcosa di più sottile: non quanti battiti fa il cuore in un minuto, bensì quanto varia l\'intervallo di tempo fra un battito e il successivo.</p>',
'<p>Un cuore sano non è un metronomo. Quella variazione riflette il dialogo continuo fra le due parti del sistema nervoso autonomo: il ramo simpatico, che ci attiva davanti a uno sforzo o a una scadenza, e il parasimpatico, che ci riporta al riposo e alla digestione.</p>',
'<h2>Il ritorno all\'equilibrio conta più dell\'attivazione</h2>',
'<p>La lettura più utile dell\'HRV non riguarda la capacità di rispondere allo stress, che quasi tutti hanno, ma quella di rientrare in equilibrio quando lo stimolo finisce. Essere in forma significa saper accendere e saper spegnere. Un valore stabilmente basso racconta spesso un sistema che resta acceso anche quando non serve.</p>',
'<h2>Il numero da solo non significa nulla</h2>',
'<p>L\'HRV ha una fortissima componente individuale: confrontare il proprio valore con quello di un\'altra persona non ha senso, perché dipende dall\'età, dalla genetica e dall\'algoritmo del dispositivo. Ha senso invece confrontarlo con se stessi nel tempo, guardando il trend su settimane e mesi.</p>',
'<h2>Come si legge in pratica</h2>',
'<ul>',
'<li>Un calo isolato dopo un allenamento intenso o una serata storta è fisiologico.</li>',
'<li>Un calo che si protrae per giorni, insieme a sonno peggiore e frequenza a riposo più alta, suggerisce di alleggerire il carico.</li>',
'<li>Alcol, cena tardi, allenamento serale e febbre incidono più di quanto si immagini.</li>',
'</ul>',
'<p>Il valore aggiunto non è il numero, ma il mettere in relazione più dati: sonno, carico di allenamento, HRV e frequenza a riposo raccontano insieme una storia che nessuno dei quattro racconta da solo.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'quante-proteine-servono',
title: 'Quante proteine servono davvero',
excerpt: '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.',
body: p(
'<p>Sulle proteine circolano più opinioni che dati. Un modo ordinato di ragionare è pensarle come una piramide a quattro livelli: alla base la quantità totale della giornata, sopra la qualità delle fonti, poi la distribuzione nei pasti e infine il timing rispetto all\'allenamento. Sistemare la base vale la gran parte del risultato; occuparsi del vertice ignorando le fondamenta è come montare le finestre prima dei muri.</p>',
'<h2>I numeri di riferimento</h2>',
'<p>Per una persona sedentaria il riferimento minimo è intorno a 0,8 grammi per chilo di peso corporeo al giorno. Per chi si allena con regolarità e vuole mantenere o costruire massa muscolare si sale a circa 1,5-1,6 grammi per chilo, con margini fino a 1,8-2 nelle fasi di carico o di dimagrimento.</p>',
'<p>Tradotto: una persona di 70 chili che va in palestra tre volte a settimana si colloca intorno ai 105-115 grammi al giorno, non ai 56 del riferimento minimo per chi non si muove.</p>',
'<h2>Il muscolo è un organo, non un ornamento</h2>',
'<p>La massa muscolare regola il metabolismo, protegge le articolazioni, partecipa all\'equilibrio ormonale e si correla alla capacità di restare autonomi con l\'età. È però un tessuto costoso da mantenere: in assenza di stimolo e di materiale l\'organismo lo smonta. La perdita progressiva di muscolo, la sarcopenia, comincia prima di quanto si creda, già a partire dai venticinque anni, e accelera senza allenamento contro resistenza.</p>',
'<h2>Conta il cronico, non il singolo giorno</h2>',
'<p>Una giornata sotto la quota non produce alcun danno. È il deficit ripetuto per mesi a portare al catabolismo. Vale anche il contrario: nessun singolo pasto proteico compensa settimane di apporto insufficiente.</p>',
'<p>Sulla distribuzione, l\'indicazione pratica è ripartire le proteine su tre o quattro pasti invece di concentrarle a cena, perché la sintesi proteica risponde meglio a stimoli ripetuti. Ma è una rifinitura: ha senso occuparsene quando la quantità totale è già a posto.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'creatina-cosa-dice-la-ricerca',
title: 'Creatina: che cosa dice la ricerca',
excerpt: 'È l\'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.',
body: p(
'<p>La creatina è probabilmente l\'integratore su cui esiste più letteratura scientifica, con decenni di studi alle spalle. Non è un ormone né una sostanza dopante: è un composto che l\'organismo produce e che assumiamo anche con la carne e il pesce, immagazzinato soprattutto nel muscolo.</p>',
'<h2>Come funziona</h2>',
'<p>Serve a rigenerare rapidamente l\'ATP, la molecola che fornisce energia nelle contrazioni brevi e intense. Avere i depositi pieni significa disporre di qualche ripetizione in più negli sforzi massimali, e quindi accumulare nel tempo un volume di lavoro maggiore. Il beneficio non è immediato: è il risultato di mesi di allenamento leggermente più produttivo.</p>',
'<h2>Quanta e quando</h2>',
'<p>Il dosaggio più studiato è di circa 3-5 grammi al giorno di creatina monoidrato, tutti i giorni, compresi quelli di riposo. Il momento della giornata è ininfluente: ciò che conta è la saturazione dei depositi, che si raggiunge in qualche settimana di assunzione costante. Le formulazioni più costose non hanno mostrato vantaggi rispetto al monoidrato.</p>',
'<h2>Effetti collaterali e falsi miti</h2>',
'<ul>',
'<li>Il piccolo aumento di peso iniziale è acqua trattenuta dentro il muscolo, non grasso.</li>',
'<li>Nelle persone con funzione renale normale non sono emersi danni ai reni alle dosi abituali.</li>',
'<li>Non provoca crampi né disidratazione, al contrario di quanto si diceva anni fa.</li>',
'</ul>',
'<p>Un aspetto meno noto riguarda il cervello, che utilizza anch\'esso creatina: alcune ricerche indicano effetti sulla fatica mentale e sulle prestazioni cognitive, in particolare in condizioni di privazione di sonno. Le prove qui sono più recenti e meno solide che sul versante muscolare.</p>',
'<p>Come sempre, un integratore lavora sul margine. Con un allenamento discontinuo e proteine insufficienti, la creatina non ha molto su cui agire.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'freddo-ormesi',
title: 'Il freddo e l\'ormesi: perché un piccolo stress fa bene',
excerpt: 'Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell\'organismo durante l\'esposizione al freddo e dove sta il confine con l\'esagerazione.',
body: p(
'<p>Alla base dell\'esposizione al freddo c\'è un principio generale della biologia: l\'ormesi. Uno stimolo che a dosi elevate sarebbe dannoso, somministrato a dosi contenute produce l\'effetto opposto e rende l\'organismo più resistente. Vale per l\'esercizio fisico, per il digiuno e appunto per il freddo.</p>',
'<h2>Che cosa succede nel corpo</h2>',
'<p>Il primo effetto riguarda i vasi sanguigni. I cicli di vasocostrizione e vasodilatazione allenano la muscolatura liscia che li avvolge, migliorando nel tempo la microcircolazione. È un lavoro che il sistema cardiovascolare non riceve in nessun altro modo.</p>',
'<p>Il secondo riguarda il tessuto adiposo bruno, ricco di mitocondri, che sotto una certa temperatura brucia energia per produrre calore invece di immagazzinarla. Il terzo è nervoso: l\'esposizione al freddo aumenta in modo marcato la noradrenalina, oltre a dopamina e serotonina, ed è la ragione della lucidità e del buonumore che molti riferiscono dopo.</p>',
'<h2>Il disagio è parte del meccanismo</h2>',
'<p>Il beneficio non nasce dal piacere ma dalla condizione di stress controllato. Una doccia tiepida non produce gli stessi adattamenti di un\'immersione realmente fredda. Allo stesso tempo, dose e durata contano: qui più non è meglio.</p>',
'<h2>Le cautele che vale la pena conoscere</h2>',
'<ul>',
'<li>L\'<em>afterdrop</em> è il calo della temperatura interna che avviene <strong>dopo</strong> l\'uscita dall\'acqua, non durante: è il momento più delicato, e va gestito riscaldandosi gradualmente.</li>',
'<li>Chi ha patologie cardiovascolari o pressorie dovrebbe parlarne prima con il proprio medico.</li>',
'<li>Il freddo subito dopo un allenamento di forza smorza parte della risposta infiammatoria che serve all\'adattamento muscolare: meglio distanziarlo di qualche ora.</li>',
'</ul>',
'<p>Vale la pena ricordare che l\'infiammazione acuta è un processo utile e necessario. L\'obiettivo non è spegnerla, ma contenere quella cronica di basso grado che accompagna sedentarietà, sonno scarso e stress prolungato.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'respirazione-anidride-carbonica',
title: 'Respirare meno per ossigenare meglio: il ruolo della CO2',
excerpt: '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.',
body: p(
'<p>Respiriamo più di ventimila volte al giorno senza pensarci. È proprio l\'automatismo a rendere poco intuitivo il meccanismo: quasi tutti immaginano che respirare di più significhi ossigenarsi meglio. In condizioni normali il sangue arterioso è già saturo di ossigeno quasi al massimo, e il fattore limitante è un altro.</p>',
'<h2>La CO2 non è solo uno scarto</h2>',
'<p>L\'anidride carbonica regola il rilascio dell\'ossigeno dall\'emoglobina ai tessuti. Con livelli di CO2 troppo bassi, l\'ossigeno resta legato ai globuli rossi e arriva meno dove serve. Iperventilare, cioè respirare più del necessario, produce quindi l\'effetto opposto a quello desiderato.</p>',
'<p>Da qui l\'utilità di allenare la tolleranza alla CO2 con respirazioni lente e ritenzioni brevi: non si tratta di prendere più aria, ma di usarne meglio quella che entra.</p>',
'<h2>Il respiro come interruttore del sistema nervoso</h2>',
'<p>Il respiro è l\'unica funzione autonoma che possiamo controllare volontariamente, e questo lo rende una leva diretta sul sistema nervoso. Accelerare il ritmo attiva il ramo simpatico, quello dell\'allerta; rallentare e soprattutto allungare l\'espirazione attiva il parasimpatico, quello del recupero. È il motivo per cui un\'espirazione più lunga dell\'inspirazione abbassa la frequenza cardiaca in pochi minuti.</p>',
'<h2>Perché dal naso</h2>',
'<p>La respirazione nasale filtra, riscalda e umidifica l\'aria, e produce ossido nitrico, che favorisce la vasodilatazione. Respirare dalla bocca a riposo, soprattutto di notte, è associato a sonno più frammentato e secchezza delle vie aeree.</p>',
'<p>Per chi si allena, la respirazione nasale nei lavori a bassa intensità è un allenamento in sé: all\'inizio costringe a rallentare, poi migliora l\'efficienza. Nelle intensità elevate la bocca resta necessaria, ed è del tutto normale.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'luce-orologio-biologico',
title: 'La luce che regola l\'orologio biologico',
excerpt: 'Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.',
body: p(
'<p>Ci siamo evoluti per milioni di anni sotto il sole, e i nostri processi biologici sono sincronizzati sul ciclo luce-buio. La luce non è un contorno ambientale: è il segnale principale che coordina l\'orologio interno.</p>',
'<h2>Il segnale del mattino</h2>',
'<p>Nella luce dell\'alba è presente una componente blu che, attraverso l\'occhio, raggiunge il nucleo soprachiasmatico, l\'orologio centrale situato nell\'ipotalamo. Da lì parte a cascata la giornata metabolica: risveglio, produzione di cortisolo, attivazione della digestione e della muscolatura. È anche il momento in cui viene impostato il conto alla rovescia per la melatonina serale.</p>',
'<p>In pratica: dieci o quindici minuti all\'aperto entro la prima ora dal risveglio hanno un effetto che nessuna lampada da interno riesce a replicare, perché l\'intensità luminosa esterna è di ordini di grandezza superiore anche in una giornata nuvolosa.</p>',
'<h2>Il problema della sera</h2>',
'<p>I LED di lampade, televisori e telefoni emettono lunghezze d\'onda simili a quelle della luce diurna. Usarli a tarda ora comunica al cervello che è ancora giorno, tenendo alto lo stato di allerta e ritardando la melatonina — che, oltre a regolare il sonno profondo, è uno dei più potenti antiossidanti prodotti dall\'organismo.</p>',
'<h2>Anche la pelle riceve luce</h2>',
'<p>Non sono solo gli occhi a raccogliere il segnale: la pelle contiene fotorecettori e partecipa alla regolazione dello stato di veglia. È una delle ragioni per cui l\'esposizione all\'aperto ha effetti che una luce artificiale puntata sugli occhi non riproduce.</p>',
'<p>Un ultimo punto, spesso frainteso: addormentarsi in pochi secondi non è segno di buon sonno. Più spesso indica un debito accumulato. Un addormentamento in dieci-venti minuti è considerato fisiologico.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'recupero-attivo',
title: 'Recupero attivo: perché fermarsi non basta',
excerpt: 'Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.',
body: p(
'<p>Il recupero viene quasi sempre inteso in negativo: non allenarsi, non fare nulla. È una lettura parziale. Il riposo passivo serve, ma la rigenerazione dei tessuti si accelera con il movimento, non con l\'immobilità.</p>',
'<h2>Il sistema linfatico non ha una pompa</h2>',
'<p>A differenza del sangue, che ha il cuore, la linfa si muove grazie alla contrazione muscolare e alla respirazione. Restare fermi rallenta il drenaggio dei cataboliti prodotti dall\'allenamento; una camminata, una pedalata leggera o una sessione di mobilità lo favoriscono. È la logica del recupero attivo.</p>',
'<h2>La fascia va tenuta fluida</h2>',
'<p>Il tessuto connettivo che avvolge muscoli e organi è ricco di acido ialuronico, una molecola sensibile alla temperatura che tende a irrigidirsi con il freddo e la sedentarietà. Movimento a bassa intensità, calore e lavoro sui tessuti aiutano a mantenerlo elastico, con effetti diretti sulla percezione di rigidità del giorno dopo.</p>',
'<h2>Carico e recupero sono due fasi della stessa cosa</h2>',
'<p>Il progresso non nasce dall\'allenamento ma dall\'adattamento all\'allenamento, che avviene dopo. Quando l\'energia spesa supera stabilmente quella rigenerata, il risultato non è più allenamento: è affaticamento cronico. Per questo il recupero andrebbe programmato nella settimana come si programma una seduta, non lasciato ai ritagli.</p>',
'<h2>Attenzione a non assistere troppo il corpo</h2>',
'<p>C\'è un limite alla logica dell\'aiuto esterno. Riempire l\'organismo di antiossidanti e integratori attorno all\'allenamento rischia di smorzare proprio le risposte che l\'allenamento dovrebbe stimolare. Il corpo ha bisogno di essere sollecitato e poi messo nelle condizioni di rispondere, non di essere protetto da ogni stimolo.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'vo2max-longevita',
title: 'VO2max: l\'indicatore che parla di longevità',
excerpt: 'Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.',
body: p(
'<p>Il VO2max misura il massimo volume di ossigeno che l\'organismo riesce a utilizzare nell\'unità di tempo. Detto in modo più diretto: quanta energia si è capaci di erogare in modo sostenuto. È un indicatore nato nel mondo della prestazione sportiva, ma oggi viene considerato uno dei parametri più informativi sullo stato di salute complessivo.</p>',
'<h2>Perché conta anche per chi non gareggia</h2>',
'<p>Un VO2max più alto significa un margine più ampio fra ciò che la vita quotidiana richiede e ciò che l\'organismo può erogare. Quel margine è ciò che permette di salire le scale con la spesa a sessant\'anni, e di reggere meglio una malattia o un intervento.</p>',
'<h2>Si allena, e non solo correndo</h2>',
'<p>Migliora con il lavoro cardiovascolare strutturato: molto volume a bassa intensità, dove si respira ancora dal naso e si riesce a parlare, più una quota limitata di lavori intensi. La combinazione conta più della singola seduta dura, e i risultati arrivano con cicli di carico e scarico, non con settimane tutte uguali.</p>',
'<h2>La palestra non è uno sport</h2>',
'<p>È un\'osservazione utile a smontare un pregiudizio frequente. La sala pesi non è una disciplina a sé che produce solo muscoli visibili: è il lavoro complementare di cui hanno bisogno il corridore, il tennista, il nuotatore e chiunque voglia arrivare lontano in salute. Forza e capacità aerobica non sono alternative, sono due gambe dello stesso percorso.</p>',
'<p>Gli orologi sportivi stimano il VO2max con formule indirette: il valore assoluto va preso con prudenza, la sua tendenza nel tempo è invece un\'informazione utile.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'leggere-i-dati-dei-dispositivi',
title: 'Leggere i dati di orologi e anelli senza farsi ingannare',
excerpt: 'Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.',
body: p(
'<p>Un dispositivo indossabile produce ogni giorno decine di valori: sonno, recupero, sforzo, prontezza, frequenza a riposo, variabilità cardiaca. Il rischio è passare dal non sapere nulla al sapere troppo, senza che questo migliori una sola decisione.</p>',
'<h2>Prima regola: guardare il trend, non il giorno</h2>',
'<p>Un valore isolato è rumore. Il punteggio di recupero basso del martedì non significa nulla da solo; la stessa metrica in calo per otto giorni consecutivi è un\'informazione. Ha senso ragionare su finestre di una settimana, un mese, un trimestre.</p>',
'<h2>Seconda regola: incrociare i parametri</h2>',
'<p>Il valore aggiunto sta nel mettere in relazione i dati fra loro. Una variabilità cardiaca crollata si spiega spesso con un allenamento serale, una cena tardiva o un bicchiere di troppo. Presa da sola diventa un allarme; letta insieme al resto diventa una spiegazione.</p>',
'<h2>Terza regola: sapere che cosa misura il proprio dispositivo</h2>',
'<p>Gli strumenti non sono equivalenti. Alcuni nascono orientati allo sport e pesano molto il carico di allenamento; altri nascono come strumenti per il sonno e restituiscono un quadro più orientato allo stile di vita. Misurano grandezze simili, ma le elaborano con algoritmi diversi: confrontare il proprio numero con quello di un amico che usa un altro dispositivo non ha alcun significato.</p>',
'<h2>E il corpo?</h2>',
'<p>Il dato non sostituisce la sensazione, la contestualizza. Se il dispositivo segnala recupero pieno ma ci si sente a pezzi, l\'informazione utile è la seconda. Gli strumenti servono a rendere visibili le tendenze che la percezione quotidiana fatica a cogliere, non a scavalcarla.</p>'
),
},
// -------------------------------------------------------------- educativi
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'dormire-meglio-da-stasera',
title: 'Sette abitudini per dormire meglio da stasera',
excerpt: 'Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.',
body: p(
'<p>Dormire bene è la cosa più economica che possiamo fare per la nostra salute, e spesso la più trascurata. Non servono acquisti: serve preparare la notte durante il giorno. Ecco sette abitudini, in ordine di facilità.</p>',
'<h2>1. Uscire la mattina</h2>',
'<p>Dieci minuti di luce naturale entro un\'ora dal risveglio danno il via all\'orologio interno e, di conseguenza, alla melatonina della sera. Anche con il cielo coperto.</p>',
'<h2>2. Andare a letto e alzarsi a orari simili</h2>',
'<p>La regolarità conta più della durata. Un orario stabile, weekend compresi, vale più di un\'ora in più presa a casaccio.</p>',
'<h2>3. Cenare prima e più leggeri</h2>',
'<p>Una digestione in corso alza la temperatura interna e disturba le prime ore di sonno, quelle più profonde. Meglio finire almeno due o tre ore prima di coricarsi.</p>',
'<h2>4. Abbassare le luci dopo cena</h2>',
'<p>Non serve vivere a lume di candela: basta spegnere il lampadario centrale e usare luci basse e calde. Il cervello legge la penombra come segnale di sera.</p>',
'<h2>5. Fermare il caffè presto</h2>',
'<p>La caffeina resta in circolo per molte ore: il caffè delle cinque del pomeriggio è ancora presente a mezzanotte. Per la maggior parte delle persone conviene chiudere all\'ora di pranzo.</p>',
'<h2>6. Tenere la camera fresca e buia</h2>',
'<p>La temperatura interna deve scendere perché il sonno profondo arrivi: una stanza attorno ai 18 gradi aiuta. Buio significa buio davvero, anche le lucine degli apparecchi.</p>',
'<h2>7. Costruire un piccolo rituale</h2>',
'<p>Sempre gli stessi gesti nella mezz\'ora prima di dormire — una doccia, qualche pagina, due minuti di respiro lento — insegnano al corpo che cosa sta per succedere.</p>',
'<p>Un consiglio pratico: non provarle tutte insieme. Se ne scelgono due, si tengono per dieci giorni e si valuta come ci si sente al risveglio.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'muoversi-ogni-giorno',
title: 'Muoversi ogni giorno: quanto basta davvero',
excerpt: 'Fra il divano e la maratona c\'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.',
body: p(
'<p>La domanda che arriva più spesso è: quanto devo allenarmi? La risposta onesta è che il primo obiettivo non è la quantità, è la continuità. Tre sedute costanti per un anno valgono infinitamente più di sei sedute per tre settimane.</p>',
'<h2>Le tre cose che non dovrebbero mancare</h2>',
'<ul>',
'<li><strong>Camminare tutti i giorni.</strong> È la base gratuita su cui si appoggia tutto il resto. Va benissimo distribuita: due tratte a piedi al posto dell\'auto valgono quanto un\'unica passeggiata.</li>',
'<li><strong>Lavorare contro resistenza due volte a settimana.</strong> Pesi, elastici, corpo libero. Serve a mantenere il muscolo, che dopo i venticinque anni si perde spontaneamente se non lo si usa.</li>',
'<li><strong>Alzare il fiato una volta a settimana.</strong> Una salita, una corsa breve, una lezione più intensa: qualcosa che faccia respirare a fondo.</li>',
'</ul>',
'<h2>"Non voglio andare in palestra, non voglio i muscoli"</h2>',
'<p>È un\'obiezione frequente e nasce da un equivoco: la sala pesi non è una disciplina sportiva, è un luogo pieno di attrezzi che serve a sostenere tutto il resto. Il corridore ci va per non infortunarsi, chi ha mal di schiena per rinforzare ciò che lo protegge, chi ha superato i sessanta per continuare a salire le scale con la spesa in mano.</p>',
'<h2>Partire da dove si è</h2>',
'<p>Chi arriva da mesi di fermo non ha bisogno di un programma da atleta, ha bisogno di un programma che riesca a rispettare. Due sedute a settimana, brevi, con esercizi semplici, sono un punto di partenza serio. Il carico si aggiunge dopo, quando l\'abitudine regge da sola.</p>',
'<p>Un ultimo pensiero: il corpo non distingue fra allenamento e vita. Portare la spesa, fare le scale, andare in bicicletta al lavoro contano davvero, e per molte persone sono la parte più grande del movimento settimanale.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'il-peso-non-e-solo-la-bilancia',
title: 'Il peso non si decide sulla bilancia',
excerpt: 'Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.',
body: p(
'<p>Quando si parla di perdere peso l\'attenzione va quasi sempre a un dettaglio: un alimento da eliminare, un integratore, una nuova dieta. Le cose che contano davvero sono poche e stanno in una gerarchia precisa.</p>',
'<h2>La piramide, dal basso verso l\'alto</h2>',
'<ol>',
'<li><strong>Il bilancio calorico.</strong> Per perdere grasso bisogna consumare più di quanto si introduce. Comunque lo si ottenga, senza questo non succede nulla.</li>',
'<li><strong>Il sonno.</strong> Dormire poco altera gli ormoni della fame e rende molto più difficile mantenere il resto.</li>',
'<li><strong>Le proteine.</strong> Saziano, proteggono il muscolo mentre si dimagrisce e riducono la perdita di massa magra.</li>',
'<li><strong>Il lavoro con i pesi.</strong> Dà all\'organismo la ragione per tenere il muscolo invece di smontarlo.</li>',
'<li><strong>Il cardio.</strong> Utile, ma all\'ultimo posto: aiuta il bilancio, non lo sostituisce.</li>',
'</ol>',
'<h2>Il weekend conta come i giorni feriali</h2>',
'<p>È l\'inciampo più comune: cinque giorni impeccabili e due giorni in cui, senza accorgersene, si recupera tutto il deficit costruito. Non serve rinunciare alla pizza; serve sapere che la settimana si giudica per intero.</p>',
'<h2>Il dimagrimento avviene di notte</h2>',
'<p>Gli stimoli si danno di giorno — mangiando in un certo modo, allenandosi — ma il lavoro metabolico si compie durante il riposo. Una parte importante del grasso smaltito lascia l\'organismo attraverso la respirazione notturna. È un altro motivo per cui chi dorme male fatica a vedere risultati anche facendo tutto il resto.</p>',
'<h2>Il corpo deve stare tranquillo</h2>',
'<p>Stress prolungato, notti corte, giornate senza pause: in queste condizioni l\'organismo tende a trattenere. Prima di aggiungere un\'altra restrizione alimentare, spesso conviene togliere qualcosa dal carico complessivo.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'proteine-a-tavola',
title: 'Proteine a tavola: come distribuirle nella giornata',
excerpt: 'Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l\'errore più comune: concentrare tutto a cena.',
body: p(
'<p>Le proteine sono il mattone con cui l\'organismo ripara e mantiene i tessuti, muscolo compreso. Molte persone ne assumono meno di quanto pensino, soprattutto a colazione e a pranzo.</p>',
'<h2>Un ordine di grandezza, non un calcolo</h2>',
'<p>Per chi si allena con regolarità, un riferimento pratico è circa un grammo e mezzo per chilo di peso al giorno: attorno ai cento grammi per una persona di settanta chili. Per chi ha una vita più sedentaria il fabbisogno è più basso, ma resta più alto di quanto la maggior parte delle persone arrivi a coprire.</p>',
'<h2>Che cosa significa in pratica</h2>',
'<p>Senza pesare nulla, aiuta ricordare gli ordini di grandezza: una porzione di carne o pesce da mano aperta fornisce circa 25-30 grammi di proteine, due uova circa 12, un vasetto di yogurt greco 15-20, una porzione di legumi 12-15.</p>',
'<h2>L\'errore più comune</h2>',
'<p>Colazione dolce, pranzo veloce, e tutte le proteine concentrate a cena. L\'organismo utilizza meglio stimoli ripetuti nell\'arco della giornata: distribuirle su tre o quattro pasti rende lo stesso totale più efficace, e per di più aiuta a non arrivare affamati alla sera.</p>',
'<ul>',
'<li>A colazione: yogurt, uova, ricotta, o una fonte proteica accanto ai carboidrati abituali.</li>',
'<li>A pranzo: che ci sia sempre una fonte riconoscibile, non solo un piatto di pasta.</li>',
'<li>A cena: la porzione consueta, senza doverla raddoppiare per compensare.</li>',
'</ul>',
'<h2>E le polveri?</h2>',
'<p>Sono un alimento comodo, non un prodotto magico: servono quando raggiungere la quota con il cibo è difficile per abitudini o orari. Chi già ci arriva mangiando non ne ha bisogno.</p>',
'<p>Un\'ultima cosa: quello che conta è la media delle settimane, non la giornata perfetta. Un giorno sotto la quota non rovina nulla.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'doccia-fredda-come-iniziare',
title: 'Doccia fredda: come iniziare senza esagerare',
excerpt: 'Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.',
body: p(
'<p>L\'esposizione al freddo è diventata popolare, spesso nella sua versione più estrema. La buona notizia è che per ottenere qualcosa non serve un bagno di ghiaccio: basta la doccia di casa, con una progressione ragionevole.</p>',
'<h2>Perché fa bene</h2>',
'<p>Il freddo allena i vasi sanguigni attraverso cicli di restringimento e dilatazione, migliorando la circolazione periferica. Aumenta inoltre i neurotrasmettitori legati ad attenzione e buonumore: è la ragione della sensazione di lucidità che resta per ore.</p>',
'<h2>Una progressione realistica</h2>',
'<ul>',
'<li><strong>Settimana 1.</strong> Doccia normale, poi trenta secondi di acqua fredda alla fine, partendo dai piedi e salendo.</li>',
'<li><strong>Settimana 2.</strong> Un minuto, respirando lentamente dal naso invece di trattenere il fiato.</li>',
'<li><strong>Settimana 3.</strong> Due minuti, includendo la nuca e le spalle.</li>',
'<li><strong>Settimana 4.</strong> Due o tre minuti, quattro o cinque volte a settimana.</li>',
'</ul>',
'<p>Il momento di disagio fa parte del meccanismo: se l\'acqua è appena tiepida non succede granché. Il respiro è lo strumento per gestirlo — quando accorcia e si fa affannoso, si rallenta l\'espirazione.</p>',
'<h2>Le avvertenze</h2>',
'<p>Chi ha problemi di cuore, di pressione o è in gravidanza dovrebbe parlarne prima con il proprio medico. Mai in acque libere da soli, e attenzione al riscaldamento dopo: la temperatura interna continua a scendere per qualche minuto anche una volta usciti, quindi si torna al caldo con gradualità, senza passare direttamente a un bagno bollente.</p>',
'<h2>Un dettaglio per chi si allena</h2>',
'<p>Subito dopo una seduta di pesi il freddo attenua parte della risposta che serve al muscolo per adattarsi. Se l\'obiettivo è la massa, meglio spostarlo al mattino o comunque a distanza di qualche ora dall\'allenamento.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'cinque-minuti-di-respiro',
title: 'Cinque minuti di respiro al giorno',
excerpt: 'Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.',
body: p(
'<p>Il respiro è l\'unica funzione automatica che possiamo guidare a piacere. Questo lo rende lo strumento più immediato che abbiamo per intervenire sul nostro stato: non richiede attrezzi, nessuno se ne accorge e funziona in pochi minuti.</p>',
'<h2>La regola di base</h2>',
'<p>Espirare più a lungo di quanto si inspira porta il corpo verso il rilassamento. Accelerare il respiro fa il contrario, attiva. Tutto qui: il resto sono variazioni su questo principio.</p>',
'<h2>L\'esercizio da cui partire</h2>',
'<p>Seduti comodi, schiena appoggiata, respirando dal naso:</p>',
'<ul>',
'<li>Inspira contando fino a quattro.</li>',
'<li>Espira lentamente contando fino a sei.</li>',
'<li>Continua per cinque minuti, senza forzare.</li>',
'</ul>',
'<p>Se contare distrae, basta rendere l\'espirazione visibilmente più lunga dell\'inspirazione. La mano appoggiata sulla pancia aiuta a capire se il respiro è basso, diaframmatico, oppure resta alto nel petto.</p>',
'<h2>Quando usarlo</h2>',
'<ul>',
'<li><strong>Prima di dormire</strong>, a luci basse: prepara il passaggio al riposo.</li>',
'<li><strong>Prima di una situazione che mette pressione</strong>: una riunione, un esame, una telefonata rimandata.</li>',
'<li><strong>Dopo l\'allenamento</strong>, per accompagnare il ritorno alla calma.</li>',
'<li><strong>Nei risvegli notturni</strong>, invece di guardare l\'orologio.</li>',
'</ul>',
'<h2>Due cose da sapere</h2>',
'<p>La prima: respirare di più non significa ossigenarsi meglio. Le respirazioni profonde e affannate a vuoto spesso peggiorano la sensazione di fame d\'aria. La seconda: di giorno, a riposo, conviene respirare dal naso — filtra, riscalda e umidifica l\'aria, e la respirazione risulta più efficiente.</p>',
'<p>Cinque minuti al giorno per due settimane sono sufficienti per accorgersi della differenza, soprattutto sulla qualità del riposo.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'luce-del-mattino',
title: 'La luce del mattino è la sveglia naturale',
excerpt: 'Dieci minuti all\'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.',
body: p(
'<p>C\'è un gesto che costa zero e migliora il sonno più di molti integratori: uscire all\'aperto poco dopo essersi alzati.</p>',
'<h2>Come funziona</h2>',
'<p>La luce del mattino contiene un segnale che raggiunge l\'orologio interno e gli comunica che la giornata è cominciata. Da quel momento parte una catena di attivazioni — energia, digestione, attenzione — e viene impostato il conto alla rovescia per la melatonina, l\'ormone che la sera concilierà il sonno. È per questo che la notte si prepara al mattino.</p>',
'<h2>Quanto e come</h2>',
'<p>Dieci o quindici minuti nella prima ora dal risveglio sono sufficienti. Non serve sole pieno: anche con il cielo coperto la luce esterna è molto più intensa di qualsiasi lampada di casa. Vale il caffè sul balcone, la prima parte del tragitto a piedi, il cane portato fuori senza fretta. Attraverso il vetro della finestra l\'effetto è molto ridotto.</p>',
'<h2>E la sera</h2>',
'<p>Le luci bianche e gli schermi raccontano al cervello che è ancora giorno. Non serve rinunciare alla televisione: bastano un paio di accorgimenti.</p>',
'<ul>',
'<li>Spegnere le luci centrali dopo cena e usare lampade basse e calde.</li>',
'<li>Attivare la modalità notturna su telefono e computer.</li>',
'<li>Tenere la camera completamente buia, comprese le spie degli apparecchi.</li>',
'</ul>',
'<h2>Per chi lavora su turni</h2>',
'<p>Chi ha orari che cambiano non può seguire il sole, ma può essere coerente: luce forte all\'inizio del proprio turno, penombra nelle ore precedenti al sonno, camera buia e fresca anche di giorno. La regolarità possibile vale più della regolarità perfetta.</p>',
'<p>Una nota che sorprende molti: addormentarsi in pochi secondi non è un buon segno. Di solito indica un debito di sonno accumulato.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'quando-fermarsi',
title: 'Come capire quando serve fermarsi',
excerpt: 'I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.',
body: p(
'<p>Chi si allena con costanza prima o poi incontra la stessa domanda: oggi vado o mi fermo? Rispondere solo con la forza di volontà porta lontano finché regge, poi presenta il conto.</p>',
'<h2>I segnali che vale la pena osservare</h2>',
'<ul>',
'<li>Sonno peggiorato pur andando a letto alla stessa ora.</li>',
'<li>Frequenza cardiaca a riposo più alta del solito per più giorni.</li>',
'<li>Irritabilità, motivazione in calo, voglia di saltare l\'allenamento che dura da una settimana.</li>',
'<li>Prestazioni in discesa nonostante l\'impegno non sia cambiato.</li>',
'<li>Piccoli fastidi articolari che compaiono e non se ne vanno.</li>',
'</ul>',
'<p>Un solo segnale isolato dice poco. Tre insieme, per più giorni, sono un\'indicazione chiara.</p>',
'<h2>Fermarsi non vuol dire stare fermi</h2>',
'<p>La pausa migliore raramente è il divano. Camminare, pedalare piano, fare mobilità o stretching aiuta la circolazione della linfa e accelera la rigenerazione più dell\'immobilità. È quello che si chiama recupero attivo: si toglie l\'intensità, non il movimento.</p>',
'<h2>Programmare lo scarico</h2>',
'<p>Nei percorsi ben costruiti la settimana leggera non è un imprevisto, è prevista: ogni tre o quattro settimane di carico se ne inserisce una più tranquilla. Chi la salta sistematicamente non ottiene più risultati, ne ottiene meno.</p>',
'<h2>Il carico è uno solo</h2>',
'<p>L\'organismo non tiene contabilità separate fra palestra, lavoro e vita privata. Una settimana pesante in ufficio, con poche ore di sonno, è già un carico: aggiungere sedute intense in quel periodo raramente è una buona idea. Riconoscerlo non è mancanza di disciplina, è saper leggere il quadro intero.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'il-piatto-della-longevita',
title: 'Il piatto della longevità',
excerpt: 'Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.',
body: p(
'<p>Le vecchie piramidi alimentari, con le loro frequenze settimanali, erano difficili da tradurre nella spesa del martedì. Un modello più semplice guarda direttamente al piatto che abbiamo davanti.</p>',
'<h2>Come si compone</h2>',
'<ul>',
'<li><strong>Metà piatto di verdura</strong>, meglio se di colori diversi nell\'arco della settimana.</li>',
'<li><strong>Un quarto di carboidrati</strong>, preferendo quelli integrali e poco lavorati.</li>',
'<li><strong>Un quarto di proteine</strong>: pesce, carne, uova, legumi, latticini.</li>',
'<li><strong>Una fonte di grassi buoni</strong>: olio extravergine, frutta secca, avocado.</li>',
'</ul>',
'<p>Non è una dieta e non prevede divieti. È uno schema che, applicato alla maggior parte dei pasti, tiene in ordine porzioni e varietà senza contare nulla.</p>',
'<h2>Due idee da smontare</h2>',
'<p><strong>I grassi non sono il nemico.</strong> Servono ad assorbire alcune vitamine, a costruire le membrane delle cellule e a produrre gli ormoni. Eliminarli fa danni. Cambia molto, invece, quali si scelgono.</p>',
'<p><strong>I carboidrati non fanno ingrassare di per sé.</strong> A parità di calorie complessive il peso dipende dal bilancio fra ciò che si introduce e ciò che si consuma. Quello che cambia è la qualità: un cereale integrale e una brioche si comportano in modo molto diverso nelle ore successive.</p>',
'<h2>La direzione generale</h2>',
'<p>Se dovessimo riassumere in una frase l\'alimentazione orientata alla longevità: nutrire molto, infiammare poco. Vuol dire più cibi veri e meno ultra-processati, più fibre, pesce con regolarità, e attenzione a zuccheri aggiunti e alcol — che restano la parte più facile da ridurre e quella che dà i risultati più visibili.</p>',
'<p>Nessuno mangia così ogni giorno, ed è normale. La media di un mese conta più del singolo pasto.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'cervello-in-allenamento',
title: 'Tenere il cervello in allenamento',
excerpt: 'Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.',
body: p(
'<p>Parliamo molto di come mantenere il corpo e poco di come mantenere la testa, eppure il cervello risponde agli stimoli con la stessa logica del muscolo: quello che non viene usato, l\'organismo lo mette da parte.</p>',
'<h2>Età sulla carta d\'identità ed età reale</h2>',
'<p>Esiste una differenza fra l\'età anagrafica e quella biologica, cioè l\'età alla quale l\'organismo sta effettivamente funzionando. Le abitudini quotidiane spostano la seconda in entrambe le direzioni, e il cervello ha una sua velocità di invecchiamento, distinta da quella del resto del corpo.</p>',
'<h2>Che cosa lo protegge davvero</h2>',
'<ul>',
'<li><strong>Il movimento.</strong> È l\'intervento con le prove più solide: migliora l\'irrorazione cerebrale e favorisce i processi di mantenimento dei neuroni.</li>',
'<li><strong>Il sonno.</strong> Durante la notte il cervello riordina i ricordi e smaltisce le scorie prodotte di giorno. Le notti corte croniche si pagano su memoria e attenzione.</li>',
'<li><strong>Le relazioni.</strong> L\'isolamento è un fattore di rischio per il declino cognitivo, con un peso paragonabile a quello di altri fattori più discussi.</li>',
'<li><strong>Imparare cose nuove.</strong> Non i giochini di allenamento mentale, che migliorano soprattutto in quei giochini: una lingua, uno strumento, un ballo, un mestiere. Meglio se difficili.</li>',
'<li><strong>La tavola.</strong> Pesce, verdura, frutta secca, olio extravergine; poco zucchero e poco alcol.</li>',
'</ul>',
'<h2>Un\'osservazione che vale per tutto</h2>',
'<p>Queste abitudini sono le stesse che proteggono cuore, metabolismo e articolazioni. Non esistono cinque programmi diversi da seguire: esiste un modo di vivere la giornata che lavora contemporaneamente su tutti i fronti.</p>',
'<p>E vale a ogni età. Cominciare a sessant\'anni dà meno margine che cominciare a trenta, ma il margine c\'è, ed è documentato.</p>'
),
},
];
// ---------------------------------------------------------------------------
const db = new Database(DB_PATH);
function idAutore(nome) {
const esistente = db.prepare('SELECT id FROM users WHERE username = ?').get(nome);
if (esistente) return esistente.id;
// Password casuale: l'account nasce come firma, non per essere usato subito.
const hash = bcrypt.hashSync(randomBytes(12).toString('base64url'), 12);
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(nome, hash, 'user');
console.log(`Creato l'autore ${nome} (imposta una password dal pannello Utenti per usarlo).`);
return Number(res.lastInsertRowid);
}
const autori = new Map();
let inseriti = 0;
let saltati = 0;
const inserisci = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, NULL, 0, @author_id, datetime('now', @quando))`
);
const tx = db.transaction(() => {
articoli.forEach((a, i) => {
if (db.prepare('SELECT 1 FROM posts WHERE slug = ?').get(a.slug)) {
saltati += 1;
return;
}
if (!autori.has(a.autore)) autori.set(a.autore, idAutore(a.autore));
inserisci.run({
title: a.title,
slug: a.slug,
category: a.categoria,
excerpt: a.excerpt,
body_html: a.body,
author_id: autori.get(a.autore),
// Pubblicazioni scaglionate a ritroso, così l'elenco non ha venti articoli alla stessa ora.
quando: `-${i * 2} days`,
});
inseriti += 1;
});
});
tx();
console.log(`Blog: ${inseriti} articoli inseriti, ${saltati} già presenti.`);
db.close();
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
npm run build
PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
trap 'kill $SRV' EXIT
sleep 2
fail=0
for path in / /about /training /services /services/performance-class /services/rehab /blog /contact /admin/login /robots.txt; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
echo "$code $path"
[ "$code" = "200" ] || fail=1
done
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
echo "$code /admin (atteso 302)"
[ "$code" = "302" ] || fail=1
# Contenuti taggati
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/sitemap.xml)
echo "$code /sitemap.xml"
[ "$code" = "200" ] || fail=1
locs=$(curl -s http://localhost:4399/sitemap.xml | grep -c '<loc>')
echo "$locs <loc> in sitemap (atteso >= 12)"
[ "$locs" -ge 12 ] || fail=1
tags=$(curl -s http://localhost:4399/ | grep -c 'data-tag=')
echo "$tags righe con data-tag= in home (atteso >= 1)"
[ "$tags" -ge 1 ] || fail=1
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
[ "$badges" = "0" ] || fail=1
# /login pubblico raggiungibile
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
echo "$code /login"
[ "$code" = "200" ] || fail=1
# Header mostra "Accedi" da anonimo
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
# Nessun badge tag-badge da anonimo sulla home semplice
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
[ "$badges2" = "0" ] || fail=1
# Rotta gestione utenti protetta (redirect al login da anonimo)
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
echo "$code /admin/users (atteso 302)"
[ "$code" = "302" ] || fail=1
echo "smoke ruoli/login/inline OK"
exit $fail
+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.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+55
View File
@@ -0,0 +1,55 @@
---
import { agenda, disciplines, days, slots } from '../data/agenda';
import T from './content/T.astro';
---
<section class="section section--dark agenda">
<div class="container">
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
{disciplines.map((d, i) => (
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
))}
</div>
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
<div class="agenda__corner"></div>
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
{slots.map((slot, si) => (
<>
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
{days.map((_, dayIdx) => {
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
return entry
? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
: <div class="agenda__cell agenda__cell--empty"></div>;
})}
</>
))}
</div>
</div>
</section>
<style>
.agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
.agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
.agenda__tab.is-active { background: var(--c-accent); color: #fff; }
.agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
.agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
.agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
.agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
.agenda__cell em { font-style: normal; opacity: .85; }
.agenda__cell--empty { background: transparent; }
.agenda__cell.is-dim { opacity: .15; }
@media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
</style>
<script>
document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
tabs.forEach((tab) => tab.addEventListener('click', () => {
tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
const f = tab.dataset.filter!;
cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
}));
});
</script>
@@ -0,0 +1,261 @@
---
// 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. */
/* `table-layout: fixed` e' quello che rende le sette colonne dei giorni ESATTAMENTE
uguali: in automatico il browser le dimensiona sul contenuto, cosi' il sabato con la
nota «solo il 12 settembre» diventava largo il doppio e i giorni vuoti si stringevano.
Con il layout fisso basta dare una larghezza alla colonna delle ore: tutto il resto
viene diviso in parti uguali fra le sette rimanenti. E' lo stesso criterio del PDF,
dove le colonne sono `(larghezzaUtile - larghezzaOra) / 7` da sempre.
⚠️ Il `min-width` e' passato da 720 a 780px INSIEME al layout fisso, e i due sono
legati: a colonne uguali la piu' stretta deve contenere la parola piu' lunga, mentre
in layout automatico il browser allargava da solo le colonne piene e stringeva quelle
vuote. Il conto, misurato sul Montserrat 400 che il sito serve davvero:
REFORMER a .7rem con letter-spacing .1em = 75,4 px
colonna a 720px = (720 - 64) / 7 = 93,7 px, meno 20 di padding e 1 di bordo = 72,7
cioe' 2,7 px in meno del necessario: la parola sarebbe uscita dalla cella sul
telefono, dove il minimo e' quello che si vede. A 780px la colonna da' 81,3 px di
spazio utile, ~6 di margine. Chi tocca padding, corpo o spaziatura rifaccia il conto. */
.cal-tab { border-collapse: collapse; width: 100%; min-width: 780px; table-layout: fixed; }
.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;
}
/* L'unica larghezza dichiarata: la colonna delle ore. Resta piu' stretta dei giorni,
come nel PDF — ci sta dentro «12.15» e nient'altro. */
.cal-tab__ora-h, .cal-tab__ora { width: 64px; }
.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>
@@ -0,0 +1,58 @@
---
import { t } from '../lib/content';
import T from './content/T.astro';
interface Props { variant?: 'full' | 'footer' }
const { variant = 'full' } = Astro.props;
---
<form
class:list={['cform', `cform--${variant}`]}
method="post"
action="/api/contact"
novalidate
data-msg-success={t('form.msg.success')}
data-msg-error-generic={t('form.msg.error-generic')}
data-msg-error-network={t('form.msg.error-network')}
>
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
<input class="field" name="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
<input class="hp" type="text" name="hp_field" tabindex="-1" autocomplete="off" aria-hidden="true" />
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
<p class="form-msg" hidden></p>
</form>
<style>
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
.cform__privacy a { text-decoration: underline; }
</style>
<script>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
const btn = form.querySelector<HTMLButtonElement>('button')!;
btn.disabled = true;
msg.hidden = true;
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json();
msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
if (res.ok) form.reset();
} catch {
msg.textContent = form.dataset.msgErrorNetwork!;
msg.className = 'form-msg form-msg--err';
}
msg.hidden = false;
btn.disabled = false;
});
});
</script>
+54
View File
@@ -0,0 +1,54 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear();
---
<footer class="ftr">
<div class="container ftr__grid">
<div>
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
<T tag="footer.tagline" as="p" class="ftr__tag" />
<T tag="footer.about" as="p" />
<p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
<p class="ftr__hours">{t('global.hours.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</p>
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
</div>
<nav aria-label="training">
<T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav>
<nav aria-label="servizi">
<T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
<p>© {year} <T tag="footer.copyright" as="span" /> <span class="ftr__sep">·</span> <a href="/privacy">Privacy Policy</a> <span class="ftr__sep">·</span> <a href="/cookie-policy">Cookie Policy</a></p>
<p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div>
</div>
</footer>
<style>
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.ftr__grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr; gap: 40px; padding-block: 26px; text-align: center; align-items: start; }
/* Il logo è display:block (reset globale): lo centro nella colonna */
.ftr__grid img { margin-inline: auto; }
/* Training e Servizi allineati alla riga "Promuoviamo il movimento..." (logo+tagline ~110px) */
.ftr__grid nav { padding-top: 110px; }
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
.ftr__hours { margin: 0; }
.ftr__legal { margin-top: 14px; font-size: .78rem; opacity: .8; }
.ftr__bar-in .ftr__sep { margin: 0 4px; opacity: .5; }
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; }
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } .ftr__grid nav { padding-top: 0; } }
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
</style>
+125
View File
@@ -0,0 +1,125 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
import { landingFor } from '../lib/auth';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname;
// ⚠️ 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
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
// ⚠️ 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';
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
(!('adminOnly' in item && item.adminOnly) || user?.role === 'admin') && (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
)
))}
</nav>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={AREA_RISERVATA}>Area riservata</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style>
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__link--bio { color: var(--c-accent-dark); }
/* Menu Piattaforme: su desktop è un pannello che scende dalla voce, in mobile —
dove la nav è già una colonna — le voci si aprono in linea sotto il titolo. */
.plat { position: relative; }
.plat__btn { cursor: pointer; list-style: none; }
.plat__btn::-webkit-details-marker { display: none; }
.plat__panel {
position: absolute; left: 0; top: calc(100% + 14px); background: #fff; min-width: 210px;
box-shadow: 0 8px 22px rgba(0,0,0,.12); display: flex; flex-direction: column; padding: 8px 0; z-index: 200;
}
.plat__item { padding: 10px 16px; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.plat__item:hover, .plat__item.is-active { background: #f4f2ef; color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
.plat__panel { position: static; box-shadow: none; padding: 0 0 8px; min-width: 0; }
.plat__item { padding: 10px 0 10px 14px; }
}
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style>
<script>
const header = document.getElementById('site-header')!;
const toggle = document.getElementById('nav-toggle')!;
const nav = document.getElementById('site-nav')!;
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
// Il pannello Piattaforme è un <details>: da solo resterebbe aperto anche cliccando altrove.
const plat = document.querySelector<HTMLDetailsElement>('.plat');
if (plat) {
document.addEventListener('click', (e) => {
if (plat.open && !plat.contains(e.target as Node)) plat.open = false;
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') plat.open = false; });
}
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
</script>
+25
View File
@@ -0,0 +1,25 @@
---
import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
const { title, eyebrow, bgImage } = Astro.props;
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
---
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
<div class="container" data-reveal>
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
</div>
</section>
<style>
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
.phero .container { width: 100%; }
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
.phero--photo { position: relative; background-size: cover; background-position: center; }
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
.phero--photo .container { position: relative; }
@media (max-width: 767px) { .phero { min-height: 240px; } }
</style>
@@ -0,0 +1,31 @@
---
import T from '../content/T.astro';
import { t } from '../../lib/content';
const stages = [1, 2, 3];
---
<section class="section">
<div class="container">
<div class="section-heading">
<T tag="about.storylab.title" as="h2" />
<T tag="about.storylab.body" as="p" />
</div>
<div class="tl">
<T tag="about.timeline.label" as="h3" class="tl__label" />
<T tag="about.timeline.open-text" as="p" />
{stages.map((n) => (
<details class="tl__stage">
<summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
<T tag={`about.timeline.stage${n}.text`} as="p" />
</details>
))}
</div>
</div>
</section>
<style>
.tl { max-width: 720px; margin-inline: auto; }
.tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
.tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
.tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
.tl__stage summary::-webkit-details-marker { display: none; }
</style>
@@ -0,0 +1,136 @@
---
import { CATEGORIES } from '../../lib/blog-const';
import type { Post } from '../../lib/posts';
interface Props { post?: Post }
const { post } = Astro.props;
---
<form id="post-form" data-id={post?.id ?? ''}>
<input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
<input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
<select class="afield" name="category">
{CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
</select>
<textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
<label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
<input type="hidden" name="cover" value={post?.cover ?? ''} />
<p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
<div class="ed-toolbar" id="ed-toolbar">
<button type="button" data-cmd="h2">H2</button>
<button type="button" data-cmd="h3">H3</button>
<button type="button" data-cmd="bold"><b>B</b></button>
<button type="button" data-cmd="italic"><i>I</i></button>
<button type="button" data-cmd="bullet">• Elenco</button>
<button type="button" data-cmd="ordered">1. Elenco</button>
<button type="button" data-cmd="quote">" Citazione</button>
<button type="button" data-cmd="link">Link</button>
<button type="button" data-cmd="image">Immagine</button>
</div>
<div id="editor" class="ed-body"></div>
<label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
<p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
</form>
<style>
.ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
.ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
.ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
.ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
.ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
</style>
<script>
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
const form = document.getElementById('post-form') as HTMLFormElement;
const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Image,
Link.configure({ openOnClick: false }),
],
content: initial,
});
async function uploadFile(file: File): Promise<string | null> {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
if (!res.ok) { alert('Upload non riuscito.'); return null; }
return (await res.json()).url;
}
document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const c = editor.chain().focus();
switch (btn.dataset.cmd) {
case 'h2': c.toggleHeading({ level: 2 }).run(); break;
case 'h3': c.toggleHeading({ level: 3 }).run(); break;
case 'bold': c.toggleBold().run(); break;
case 'italic': c.toggleItalic().run(); break;
case 'bullet': c.toggleBulletList().run(); break;
case 'ordered': c.toggleOrderedList().run(); break;
case 'quote': c.toggleBlockquote().run(); break;
case 'link': {
const url = prompt('URL del link:');
if (url) c.setLink({ href: url }).run();
break;
}
case 'image': {
const input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
input.onchange = async () => {
const url = input.files?.[0] && await uploadFile(input.files[0]);
if (url) editor.chain().focus().setImage({ src: url }).run();
};
input.click();
break;
}
}
});
document.getElementById('cover-file')!.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const url = await uploadFile(file);
if (url) {
(form.elements.namedItem('cover') as HTMLInputElement).value = url;
document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const id = form.dataset.id;
const body = {
title: (form.elements.namedItem('title') as HTMLInputElement).value,
slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
category: (form.elements.namedItem('category') as HTMLSelectElement).value,
excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
body_html: editor.getHTML(),
};
const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const msg = document.getElementById('save-msg')!;
if (res.ok) {
msg.textContent = 'Salvato ✓';
if (!id) location.href = '/admin';
} else {
msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
}
});
</script>
@@ -0,0 +1,26 @@
---
import { getDb } from '../../lib/db';
import { listRecent, listArchiveMonths } from '../../lib/posts';
import { CATEGORIES } from '../../lib/blog-const';
const db = getDb();
const recent = listRecent(db, 4);
const months = listArchiveMonths(db);
const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
---
<aside class="bside">
<h3>Categorie</h3>
{CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
<h3>Ultimi articoli</h3>
{recent.map((p) => (
<p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
))}
<h3>Archivio</h3>
{months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
</aside>
<style>
.bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
.bside a { text-decoration: none; color: var(--c-text); }
.bside a:hover { color: var(--c-accent-dark); }
.bside__month { text-transform: capitalize; }
</style>
+11
View File
@@ -0,0 +1,11 @@
---
import { resolveContent } from '../../lib/content';
interface Props { tag: string; as?: string; class?: string }
const { tag, as = 'span', class: cls } = Astro.props;
const Tag = as;
const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
---
{c.type === 'html'
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
@@ -0,0 +1,16 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { resolveContent } from '../../lib/content';
interface Props {
tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
}
const { tag, src, alt, class: cls, ...rest } = Astro.props;
const c = resolveContent(tag);
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
const guid = c.guid || undefined;
---
{override
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}

Some files were not shown because too many files have changed in this diff Show More