# PromoGest v2: SvelteKit + Turso su Vercel

Spec di progetto, 11 agosto 2026.
Sostituisce l'applicazione PHP su Tophost (`duebytes.it/app/vale/`), che va dismessa.

---

## 1. Perché si rifà

Il server PHP di Tophost va dismesso. Non c'è una scadenza immediata, quindi il lavoro si fa bene
invece che in fretta.

L'app di oggi funziona ma ha tre problemi strutturali che una migrazione a parità non risolverebbe:

- **Pesa troppo per l'uso che se ne fa.** `xlsx.full.min.js` sono 923 KB caricati con un tag
  `<script>` sincrono a ogni apertura, anche sulla schermata di login e anche quando Valentina vuole
  solo guardare una data. Più Bootstrap, le sue icone e Chart.js da CDN. Lei apre l'app dallo
  smartphone decine di volte al giorno girando fra sedi.
- **Non è testabile.** La logica di parsing legge lo stato globale (`App.aliases`,
  `App.ignoredInsegne`), quindi non esiste modo di metterci un test sopra. È esattamente il codice
  dove sono stati trovati i bug più costosi.
- **Non valida niente.** Il 10 giugno 2026 un mapping colonne sbagliato ha prodotto 2.833 eventi
  spazzatura su 2.929 e l'import ha risposto "importazione riuscita". Vedi §11.

## 2. Vincoli

- Utente unica: Valentina. Nessuna prospettiva di multiutenza.
- Usa lo smartphone tutto il giorno spostandosi fra punti vendita, in zona Milano, con copertura
  buona. Carica i file Excel sia da PC sia da smartphone.
- Volumi reali: 3 aziende, ~1.500-3.000 eventi su 12-18 mesi, 226 insegne, file Excel da 31 a 452 KB.
- Deve funzionare pienamente anche da browser desktop.

## 3. Stack

Lo stesso del progetto Alleanza (`Siti Web/Lavoro/Inlumia/Alleanza/app`), per non avere due modi
diversi di fare la stessa cosa:

| | |
|---|---|
| Framework | SvelteKit 2, Svelte 5 con le runes |
| Linguaggio | TypeScript |
| Stile | Tailwind 4, palette in `@theme` dentro `src/app.css`. **Nessuna libreria di componenti** |
| Dati | Drizzle ORM su `@libsql/client` (Turso) |
| Auth | `jose` (JWT HS256) + `bcryptjs` |
| Deploy | `@sveltejs/adapter-vercel`, repo GitHub privata |
| Test | Vitest |
| Excel | SheetJS, **solo lato server** |

Due precisazioni.

**SheetJS non va preso da npm.** Il pacchetto `xlsx` su npm è fermo alla 0.18.5 del 2022 e ha due
advisory `high` senza correzione. Le versioni correnti (0.20.x, che è quella già usata oggi) si
installano dal tarball ufficiale `cdn.sheetjs.com`.

**Regione e runtime vanno dichiarati.** Su Vercel Hobby la regione è una sola e di default è
Virginia. Senza `adapter({ runtime: 'nodejs22.x', regions: ['fra1'] })` ogni chiamata dallo
smartphone di Valentina fa Italia, Virginia, Turso, Virginia, Italia.

## 4. Architettura

### 4.1 Rendering: guscio statico, non SSR

`export const ssr = false` e `export const prerender = true` nel `+layout.ts` radice. Ogni rotta
diventa un file HTML statico servito dalla CDN; i dati arrivano da endpoint `/api/*` che restano
funzioni serverless.

Questa è la differenza principale rispetto ad Alleanza, ed è imposta dal requisito offline: se la
pagina la costruisce il server, senza server non c'è pagina, e nessuna cache dei dati rimedia.

Verificato sul sorgente di SvelteKit, non dedotto:

- `ssr = false` + `prerender = true` funziona con `adapter-vercel` e produce un guscio HTML per ogni
  rotta. L'adapter genera già un catch-all valutato dopo `handle: 'filesystem'`, quindi **non serve
  `adapter-static` con fallback** (che oltretutto non genererebbe le funzioni degli endpoint).
- `+server.ts` **non eredita** `prerender` dal layout (`prerender: mod.prerender ?? false`), quindi
  non serve escludere `/api` dal prerendering.
- `hooks.server.ts` gira **anche a build time**, senza guardia su `state.prerendering`. Il filtro sul
  pathname deve essere la prima riga dell'hook, altrimenti il build si rompe o congela pagine di
  errore dentro i gusci.

### 4.2 Autenticazione

JWT HS256 firmato con `jose`, in un cookie HttpOnly, sliding a 90 giorni come in Alleanza.
`hooks.server.ts` protegge **solo `/api/*`**: le pagine sono file statici sulla CDN e non passano
da SvelteKit.

**La risposta a un accesso negato è 401 JSON, mai un redirect.** Un redirect verso `/login` non
arriverebbe mai a un `fetch`.

**Revoca.** Colonna `token_version` sulla riga utente, trasportata nel JWT e confrontata
dall'hook. Due azioni distinte, perché usa due dispositivi:

- *Esci*: cancella il cookie su quel dispositivo soltanto.
- *Esci da tutti i dispositivi*: incrementa `token_version` e invalida ogni token esistente.

Lo stesso incremento avviene al cambio password, il che chiude un buco presente oggi: in
`api/auth.php:90-98` cambiare password non invalida i remember token già emessi.

Scelto al posto di una tabella `sessions`, che andrebbe poi ripulita, e al posto del JWT puro, che
non revoca niente.

### 4.3 Lettura offline

Decisione: **lettura offline sì, coda di sincronizzazione delle scritture no.**

Valentina ha esattamente un tipo di scrittura, l'import di un Excel, che sostituisce in blocco gli
eventi di un'azienda. Non esiste lo scenario "importa senza rete e mettilo in coda": se non ha campo
riprova dopo. Costruire una coda di conflitti per un caso che non si verifica significa portarsi
dietro quel codice per anni.

**Il service worker gestisce solo il guscio. Non tocca mai i dati.**

```js
// src/service-worker.ts
if (url.pathname.startsWith('/api/')) return;   // niente respondWith
```

Questa riga non è un dettaglio di coerenza, previene una perdita di dati silenziosa. Senza:
Valentina importa da PC, poi apre il telefono; il codice applicativo fa `fetch('/api/events')`
scritto assumendo "se fallisce uso la copia locale"; **non fallisce**, il service worker risponde
con la copia di tre giorni prima, `res.ok` è vero, il `catch` non gira mai, e l'app sovrascrive lo
store locale con dati più vecchi. Nessun errore, nessun indizio.

**La cache dei dati è codice applicativo normale**, una cinquantina di righe in `$lib/offline.ts`:
all'avvio legge la copia locale e disegna subito, poi va in rete, aggiorna la vista e riscrive la
copia. L'app risulta istantanea sempre, non solo quando manca il campo.

`localStorage` con una chiave sola basta: `JSON.parse` dell'intero dataset da 722 KB costa 1,9 ms.
IndexedDB sarebbe cerimonia.

Due trappole verificate:

- L'esempio ufficiale del service worker di SvelteKit precachea `[...build, ...files]` e **omette
  `prerendered`**. Copiandolo, la PWA online sembra perfetta e offline dà errore su ogni rotta mai
  visitata. Va scritto `const ASSETS = [...build, ...files, ...prerendered]`.
- In `vite dev` gli array `build`/`files`/`prerendered` sono vuoti. L'unico collaudo offline valido
  è `vite build && vite preview` con DevTools in modalità Offline.

### 4.4 Aggiornamento dopo un deploy

Niente `skipWaiting()` incondizionato. `kit.version.pollInterval: 300000`, e quando esce una versione
nuova un avviso "nuova versione, tocca per ricaricare" che manda `postMessage({type:'skip-waiting'})`
e ricarica su `controllerchange`.

Senza, il service worker nuovo si attiva sotto una scheda già aperta, l'`activate` dell'esempio
cancella i chunk che quella scheda sta usando, e in produzione quei chunk restano 404. Un
`location.reload()` da solo non basta: un reload normale non promuove il service worker in attesa.

## 5. Modello dati

```
users            id, username, password_hash, token_version, created_at
companies        id, name, color, mapping (JSON), last_import
events           id, company_id, insegna, insegna_raw,
                 sellout_start, sellout_end, products (JSON), imported_at,
                 draft (0/1), import_id
aliases          id, canonical_name
alias_variants   id, alias_id, variant          UNIQUE(variant)
ignored_insegne  name (pk)
```

**Vincoli**, ed è la parte che il 10 giugno avrebbe salvato la situazione:

```sql
CHECK (sellout_start <= sellout_end)
CHECK (sellout_start BETWEEN '2020-01-01' AND '2099-12-31')
FOREIGN KEY (company_id) REFERENCES companies(id)   -- con PRAGMA foreign_keys=ON
```

Gli indici che servono sono `events(company_id)` per la cancellazione in blocco, `events(company_id, sellout_start)`
per il calendario, `alias_variants(variant)` unico.

**Niente vincolo unico su `(company_id, insegna, sellout_start, sellout_end)`.** I duplicati nei file
reali esistono, `mergeConflicts` nasce apposta per gestirli, e siccome l'import è un batch atomico un
duplicato farebbe rifiutare l'intero import: Valentina caricherebbe un file e non succederebbe niente.

**Scelte di normalizzazione:**

- *Alias in due tabelle.* Non per velocità: oggi niente impedisce di mappare la stessa variante su
  due insegne diverse, e in quel caso la risoluzione ne pesca una in base all'ordine dell'array, in
  silenzio. Il vincolo unico rende quella situazione impossibile invece che invisibile.
- *`products` resta JSON.* Si legge e si scrive sempre insieme al suo evento. Normalizzare
  significherebbe ~15.000 righe e una join a ogni lettura per zero vantaggi. Se un giorno servisse
  cercare per prodotto, SQLite ha `json_each()`.
- *`mapping` resta JSON dentro `companies`.* Oggetto di configurazione di sette campi, letto sempre
  intero.

La colonna JSON ha una trappola. Una singola riga con testo non-JSON fa fallire l'intera `SELECT`,
non quella riga. Lo script di migrazione deve fare `JSON.parse` in try/catch su ogni valore prima di
scriverlo, normalizzando il vuoto a `'[]'` e mai a `''`.

**`insegna_raw` è un contratto, non un residuo.** Serve al calcolo delle insegne non mappate. Uno
schema di validazione che fa `strip` lo elimina e il pannello smette di funzionare senza errori.

## 6. Import

### 6.1 Il file sale una volta sola

L'anteprima non può essere ricalcolata alla conferma rimandando il file, come inizialmente proposto:
**il parsing non è deterministico nel tempo**, dipende da `aliases` e `ignored`, e l'anteprima serve
proprio a far aggiungere alias. Sequenza reale: l'anteprima dice 312 eventi, Valentina mappa
`ESSELUNGA S.P.A.`, conferma, il server ri-parsa con la tabella nuova, le insegne collassano,
`groupRows` le fonde, ne scrive 308. Ha approvato un numero e ne ha ottenuto un altro.

Contratto:

1. `POST /api/import` multipart con `file`, `companyId`, `draftId` (UUID generato dal client, così un
   retry dopo un timeout andato a buon fine sovrascrive lo stesso draft invece di crearne due).
   Il server parsa, valuta tutti i fogli contro il mapping e sceglie quello con più righe valide,
   scrive gli eventi con `draft = 1` e `import_id = draftId`, e risponde con i conteggi, i primi
   ~10 eventi, gli scarti con riga e motivo, i conflitti già risolti e gli accorpamenti proposti.
2. `POST /api/import/[draftId]/confirm` con `{ sheet?, rejectedMerges[], canonicalOverrides{} }`,
   poche centinaia di byte. In un solo `db.batch()`: applica le fusioni, cancella gli eventi vivi
   dell'azienda, promuove i draft, aggiorna `last_import`, e scrive gli accorpamenti accettati in
   `aliases`.
3. Pulizia: `DELETE FROM events WHERE draft = 1 AND imported_at < now-24h` all'inizio di ogni import.
   Nessun cron, nessuna tabella di appoggio. Le bozze usano lo stesso `imported_at` degli eventi vivi,
   valorizzato al momento della scrittura.

### 6.2 Transazione

**`db.batch()`, non `db.transaction()`.**

`db.transaction()` su Turso remoto ha un timeout di 5 secondi e ogni statement è un giro HTTP con
baton: mille insert non ci stanno. `db.batch()` genera `BEGIN`, gli step condizionati e
`COMMIT`/`ROLLBACK` in una richiesta sola, ed è atomico per costruzione.

La parte insidiosa: in sviluppo, su file SQLite locale, quel timeout non esiste. Una transazione
funzionerebbe perfettamente in locale e fallirebbe solo in produzione, con i volumi veri. Il commento
va scritto in cima al modulo del DB, perché è l'errore che ritorna la prossima volta che qualcuno
"aggiunge solo due update".

Il limite di bind parameter è 32.766, quindi con 7 colonne stanno ~4.680 righe per statement: mille
righe non obbligano a spezzare. Chunk da 200 solo per avere errori diagnosticabili.

L'operazione è **idempotente**, ed è la proprietà che rende sicuro il retry dopo un timeout.

### 6.3 Guard obbligatori

- **Zero righe valide su un'azienda che ha già eventi: rifiuta.** Oggi non c'è, e un foglio sbagliato
  in un file multi-foglio cancella tutti gli eventi dell'azienda con un messaggio di successo. Il
  messaggio di errore deve dire di controllare il foglio e il mapping colonne.
- Bottone di conferma disabilitato quando l'anteprima dà `0 eventi`.
- Conteggio della sostituzione dentro l'etichetta del bottone: `Importa 96 eventi (sostituisce i 312 attuali)`.
- Se `navigator.onLine` è falso il selettore file è disabilitato con "sei offline, l'import richiede
  connessione". Onesto invece che ottimista.

### 6.4 Accorpamento insegne

Nei dati reali 28 gruppi su 226 insegne sono lo stesso cliente scritto in modi diversi
(`ESSELUNGA` / `ESSELUNGA SPA`, `MAIORA` / `MAIORA SPA` / `MAIORA SRL`). La funzione alias esiste da
sempre ed è rimasta vuota, perché richiede di creare gli alias a mano uno per uno spulciando 226 nomi.

Nel nuovo import il server **propone** gli accorpamenti, con il numero di eventi coinvolti, e
Valentina conferma con un tocco. 26 dei 28 gruppi sono deterministici (basta togliere
SPA/SRL/SNC/SAS/SOC COOP/SCARL e la punteggiatura) e meritano una riga già accettata, non 26 schede
da confermare.

Gli accorpamenti confermati vengono scritti negli alias, così al prossimo import quei gruppi non
ricompaiono. **La lista si estingue da sola**, ed è questa la differenza rispetto a un questionario.

### 6.5 Caricamento

- Avanzamento reale con `XMLHttpRequest` e `upload.onprogress`; `fetch` non espone il progresso
  dell'upload. Con una tacca, 452 KB possono metterci un minuto e la barra deve muoversi.
- Il riferimento al `File` resta nello stato della pagina: in errore il bottone diventa *Riprova* e
  rispedisce lo stesso file, senza ripassare dal selettore allegati di iOS.
- All'apertura: "hai un import di Caseifici GT in sospeso da 10 minuti, riprendi o scarta".
- L'input file va creato usa-e-getta o azzerato dopo l'uso: un input persistente non riemette
  `change` se si riseleziona lo stesso file.

## 7. Schermate

Quattro rotte più login e import. Mobile-first, desktop pienamente funzionante.

Si tengono le schermate di oggi, rifatte graficamente. La struttura
funziona, cambia l'esecuzione.

### 7.1 Calendario (`/`)

Griglia mensile come vista principale, come adesso. Sopra la griglia, sulla stessa pagina, una banda
"cosa parte e cosa chiude nei prossimi giorni".

Il motivo sta nei dati: le tre barre colorate delle celle mostrano sempre le stesse proporzioni
(le tre aziende sono il 69, 22 e 8 percento del totale), quindi comunicano poco. E la densità è
piatta: marzo ha media 220 promozioni attive al giorno con un picco di 261, cioè il 15% di
escursione. Il segnale utile non è quante ne sono attive, è quante partono e quante chiudono.

Se Valentina confermerà di non usare la griglia, si inverte il default cambiando una riga.

**Ricerca insegna a livello di pagina**, che oggi manca. Esiste solo dentro il popup del giorno e
solo sopra i tre eventi: per trovare una delle 226 insegne bisogna già sapere in che giorno cercarla.
Ricerca per sottostringa, mai per prefisso: le insegne sono ragioni sociali complete
(`IPERAL SUPERMERCATI SPA con socio unico`) e si digita "iperal".

Le aziende diventano un filtro invece che tre barre decorative. Legenda dei colori, che oggi è un
`<div>` vuoto da sempre.

### 7.2 Lista (`/lista`)

Filtri e ordinamento come oggi. **Il filtro per data lavora per sovrapposizione, non per
contenimento**: escluso solo se `end < from || start > to`. Valentina chiede cosa è attivo in una
finestra, non cosa inizia. Il rifacimento ingenuo (`start >= from && end <= to`) farebbe sparire metà
della lista senza errori.

### 7.3 Analisi (`/analisi`)

Come oggi, con due correzioni. **Il filtro delle insegne escluse va applicato anche qui**: oggi manca,
e le stesse insegne raccontano storie diverse a seconda della pagina. E il grafico mensile deve usare
la stessa semantica del calendario: oggi ignora l'anno e attribuisce l'evento al solo mese di inizio,
mentre il calendario lo spalma su tutto il periodo, quindi le due pagine si contraddicono.

`extra_info_label` è l'informazione più importante di questa pagina (Caseifici la usa per
"Meccanica", Salumifici per "Tema", Parmacotto per "Volantino": la stessa colonna significa cose
diverse) ed è nascosta dentro un `<small>`.

### 7.4 Import (`/import`)

**Pagina, non modale.** Su Android il gesto Indietro chiude un dialog, su iOS passare a Mail per
recuperare l'allegato può scaricare la pagina, e lì dentro sta l'unica operazione lunga e distruttiva
dell'app. Una rotta sopravvive a entrambe. In più oggi da smartphone l'import si raggiunge solo
scendendo dentro l'accordion delle impostazioni, perché la barra in basso non lo espone.

Percorso: scegli azienda, scegli file, leggi una riga, tocca *Importa N eventi*.

### 7.5 Adattamenti mobile da rimettere subito

Sono invisibili su desktop e dolorosi in uso reale, quindi vanno messi all'inizio e verificati su
iPhone, non alla fine:

- `viewport-fit=cover` e `env(safe-area-inset-bottom)` sulla barra inferiore
- `overscroll-behavior: none`, che disattiva il pull-to-refresh; in modalità standalone quel gesto
  ricarica l'app e fa perdere lo stato
- lo stato della vista (mese corrente, filtri) deve sopravvivere al cambio pagina. Con le rotte
  SvelteKit i componenti si smontano e lo `$state` locale si azzera, mentre oggi il DOM non viene
  distrutto e Valentina ritrova il mese dove l'aveva lasciato. Meglio nella URL (`?m=2026-04`), che
  sopravvive anche al refresh.

## 8. Gestione degli errori

- Status HTTP reali, non tutto 400 come oggi. I messaggi in italiano vanno portati uno per uno:
  sono testo utente, non stringhe di debug.
- **Un 401 non tocca mai la copia locale.** Rientro dalle ferie, app aperta in metropolitana, la
  sessione è scaduta: se il client reagisce con logout, pulizia e redirect, ha appena cancellato i
  dati e non può rifare login con una tacca. Banner "sessione scaduta, tocca per rientrare", dati
  ancora visibili in sola lettura. `/login` va prerenderizzata **e** inclusa nel precache.
- Distinguere "il server ha detto no" da "non riesco a parlare col server". Sono due messaggi diversi.
- **Mai un catch che degrada un errore di lettura in dataset vuoto.** Oggi `readJson` ritorna `[]` su
  file illeggibile, e siccome ogni scrittura è un read-modify-write, una lettura fallita distrugge i
  dati. Query fallita significa 500, non 200 con lista vuota.
- L'indicatore online/offline va rifatto, non riportato: `navigator.onLine` dice solo che esiste
  un'interfaccia di rete, quindi il wifi di una sede senza uscita risulta "Online". Con la lettura
  offline l'indicatore giusto è "dati aggiornati alle HH:MM".
- Timeout e `AbortController` su tutte le chiamate. Oggi non ci sono, e nel flusso di import questo
  significa spinner infinito senza sapere se l'operazione distruttiva sia andata a buon fine.
- Tre stati distinti dove oggi ce n'è uno: caricamento, zero eventi in assoluto, zero risultati per i
  filtri correnti.

## 9. Test (Vitest)

Il primo vale più degli altri messi insieme.

1. **`parseDate` con `TZ=Europe/Rome`, `TZ=UTC` e `TZ=Pacific/Auckland` deve dare lo stesso
   risultato.** Il Mac di sviluppo è Europe/Rome, Vercel gira in UTC. È esattamente il bug trovato
   l'11 agosto 2026: in UTC il codice sbagliato dava per caso la risposta giusta, quindi sviluppando
   su un server in UTC non lo si sarebbe mai visto.
2. Invariante `validi + scartati + ignorati === totale`.
3. `mergeConflicts`: una data uguale fonde, entrambe diverse non fonde, catena a tre gruppi senza
   perdere prodotti (oggi se ne perde uno), coppia a cavallo del cambio d'ora del 25/10/2026.
4. Ordine alias-poi-ignorate: riga scritta con una variante e canonico nella lista ignorate,
   la riga va ignorata.
5. `Esselunga` e `ESSELUNGA` producono un solo gruppo.
6. Calendario: evento 2026-04-22 → 2026-05-05 compare nelle celle 22-30 aprile e 1-5 maggio.
   **L'ultimo giorno di ogni mese deve poter contenere eventi.**
7. Lista: lo stesso evento con filtro 1-31 maggio deve comparire.
8. Barre calendario: due eventi stessa azienda stessa insegna stesso giorno contano 1, non 2.
9. `startOffset` per marzo 2026, che inizia di domenica, deve dare 6.
10. Dopo un reset, i mapping colonne delle tre aziende sono invariati. È la perdita più costosa e più
    silenziosa possibile: sono sette indici per tre aziende, tarati a mano guardando dentro gli Excel.
11. Un indice colonna con valore `0` sopravvive al giro form, DB, parser. `0` è un indice legittimo e
    `header_row` vale 0 per tutte e tre le aziende, quindi ogni controllo di verità su quel valore è
    un bug.
12. Parsing di un file Excel vero, prendendone uno da `- mat/`.

## 10. Logica che migra invariata

Queste funzioni sono già pure e diventano un modulo server testabile:

```ts
parseWorkbook(buffer, mapping, aliases, ignored)
  → { valid, discarded, ignoredRows, groups, conflicts, unmapped }
```

Ordine obbligatorio della pipeline: `parseFile → groupRows → mergeConflicts → findUnmapped`.
`mergeConflicts` lavora sui gruppi, `findUnmapped` sulle righe già filtrate.

Comportamenti da riprodurre esattamente, con la motivazione, perché sembrano tutti superflui:

| Cosa | Perché |
|---|---|
| L'alias si risolve **prima** di applicare le ignorate | Il set delle ignorate si confronta col nome canonico, così escludere `ESSELUNGA` esclude tutte le varianti |
| `mergeConflicts` fonde solo se **una** data coincide e l'altra differisce di ≤2 giorni | Se differiscono entrambe sono due promozioni distinte. Non semplificare in "sovrapposizione di intervalli" |
| Nel merge vince chi ha più prodotti, a parità l'intervallo più largo, a parità ancora il primo | Il tie-break deterministico è ciò che rende l'import ripetibile. Le date sono in blocco quelle del vincitore, non l'unione |
| Quattro scarti motivati in ordine fisso: inizio, fine, insegna, prodotto | L'utente ha imparato quei messaggi. Il numero di riga va corretto in `i + header_row + 2` |
| Un alias senza varianti è valido | Serve a dichiarare "questo nome è già giusto" e silenziare le segnalazioni |
| Lettura del foglio come matrice, non come oggetti | Il mapping è per indice; passare ai nomi di colonna cambierebbe l'intero contratto |
| Ordinamento con collator italiano | Il sort nativo è per code point e mette le maiuscole prima. Un `Intl.Collator('it')` creato una volta sola |
| Le barre contano **insegne uniche**, non eventi | Facilissimo da sbagliare: si conta `events.length` e sembra funzionare |
| Il conteggio delle aziende nei badge è deduplicato | La stessa insegna compare nei piani di più aziende; senza dedup si vedono 12 badge identici |

**Le date sono stringhe `YYYY-MM-DD`, non istanti.** Confronto lessicografico, formattazione da
componenti espliciti, mai costruire un `Date` per confrontare o formattare una data civile. L'unico
timestamp vero è `last_import`. Un solo helper `formatDateIt(iso)` in `$lib/date.ts`, oggi duplicato
in cinque punti.

## 11. Migrazione dei dati

### 11.1 Lo stato di partenza è compromesso

Rilevato l'11 agosto 2026 scaricando la produzione. **2.833 eventi su 2.929 sono spazzatura**: tutti
i 2.104 di Caseifici GT e 727 dei 729 di Parmacotto hanno la data di fine prima di quella di inizio,
con anni tipo 1952, 1968, 2173, 2242.

Causa: i mapping colonne sono stati cambiati e per due aziende su tre sono sbagliati. Applicati ai
file Excel veri, quello di Caseifici legge `sellout_start` dalla colonna "Qta.Prev." (22000, 15000) e
`sellout_end` da "S1" (0.56, 0.03); il ramo del seriale Excel converte quei numeri in date assurde.
La colonna insegna finisce su "Codice Articolo".

Nessun livello valida che la fine venga dopo l'inizio, quindi l'import ha accettato tutto e ha
mostrato il messaggio di successo. Valentina se n'è accorta solo indirettamente: delle 682 insegne
che ha messo fra le ignorate, **438 sono codici pratica** tipo `250309_DM_IGES_D12400`. Si è messa a
nascondere a mano la spazzatura per ripulirsi la vista.

I dati non si riparano: le colonne di origine non sono mai state salvate.

### 11.2 Procedura

1. Farsi mandare da Valentina i tre file Excel attuali.
2. Ricavare il mapping colonne corretto verificandolo contro quei file, riga per riga: la verifica è
   che le colonne lette come date contengano date e che la colonna insegna contenga ragioni sociali.
3. Popolare Turso con lo script di migrazione partendo dai JSON di produzione. Si portano gli alias e
   le insegne ignorate, che sono conoscenza accumulata a mano, e i mapping **nella versione corretta
   ricavata al punto 2**, non in quella salvata in produzione, che è la causa del danno.
4. Non migrare gli eventi. Far rifare a Valentina l'import delle tre aziende dai file attuali.
5. Ripulire le 438 voci finte dalla lista delle insegne ignorate, riconoscibili perché corrispondono
   al formato dei codici pratica e non a nessuna insegna reale.

I JSON di produzione, non la copia locale: quella è ferma a marzo. Backup pre-intervento in
`_backup-produzione-2026-08-11/`.

### 11.3 Da verificare prima di eseguire

- L'hash password in `users.json` è in formato `$2y$`. Alcune implementazioni JS accettano solo `$2a$`
  e `$2b$`. Va provato con `bcryptjs` sull'hash reale, oppure, più semplice, si fa reimpostare la
  password una volta.
- Il fallback `DATABASE_URL ?? 'file:...'` copiato da Alleanza è una trappola: su Vercel, con la
  variabile dimenticata o nello scope sbagliato, l'app non esplode, apre un SQLite vuoto nel
  filesystem effimero, e Valentina vede un calendario vuoto senza errori nei log. Serve
  `if (!dev && !env.DATABASE_URL) throw new Error('DATABASE_URL mancante')`. Alleanza questa guardia
  non ce l'ha: è una lacuna del progetto di riferimento, non un pattern da copiare.

## 12. Cosa resta fuori

Elencato per evitare che rientri di soppiatto:

- Coda di sincronizzazione delle scritture offline (§4.3)
- Multiutenza e ruoli. Lo schema ha una tabella `users` con una riga
- Storico degli import e rollback
- Notifiche push
- CRUD aziende. Le tre sono fisse da sempre, l'endpoint POST di oggi non è raggiungibile da nessuna
  interfaccia e il DELETE non esiste, nonostante il CLAUDE.md lo dichiari
- Il picker dei fogli come passo bloccante. Il server sceglie il foglio con più righe valide e lo
  mostra come chip modificabile; un selettore vero si aggiunge solo se l'euristica sbaglia su un file reale
- Upload a pezzi. A 452 KB il retry integrale costa meno del codice per riprenderlo
- Distinzione delle promozioni che oggi appaiono come prodotto ripetuto. Rinviata: lo schema la
  regge già, si popola quando Valentina dirà se le serve

## 13. Rischi archiviati

Verificati e non progettati, per non costruire difese contro fantasmi:

| Rischio | Perché non si verificherà |
|---|---|
| Quote Turso Free | Un import è ~2.000 write; quattro import a settimana sono lo 0,3% del mensile. Letture: venti aperture al giorno fanno lo 0,17% |
| Tetto 4,5 MB del body Vercel | File reale più grosso 452 KB. Tutti gli eventi sono 722 KB minificati, 66 KB gzip. A tre anni si sta sotto i 2 MB |
| Prestazioni del parsing Excel | Misurato sul file più grosso: 319 ms, 51 MB di heap, su un limite di 300 s e 2 GB |
| IndexedDB | `JSON.parse` di 722 KB costa 1,9 ms |
| Concorrenza in scrittura | Una sola utente, batch atomico e idempotente |
| Safari che cancella lo storage dopo 7 giorni di inattività | Apre l'app tutti i giorni. Va comunque installata sulla schermata home |
| Rate limiting sul login | Da delegare al firewall di Vercel sulla rotta, non da costruire in-app |

Una nota onesta sullo stack: Drizzle e Turso sono la parte più pesante del progetto per 1.500 righe,
una sola scrittrice e zero query relazionali. L'intero dataset sta in 66 KB gzip. La giustificazione
non è tecnica, è che Alleanza è già su questo stack e le convenzioni si copiano. È una ragione valida,
ed è probabilmente decisiva, ma va detta per quello che è.

## 14. Punti aperti

1. **La vista mensile serve ancora a Valentina o è abitudine?** In attesa di risposta. Il disegno
   attuale funziona con entrambe le risposte: cambia quale delle due sezioni è il default.
2. **Le promozioni distinte che oggi appaiono come prodotto ripetuto.** Accantonato per ora.
   Ogni azienda ha una colonna che descrive quale promozione è (`Descrizione Promo` per Caseifici,
   `DESCR_ECR` per Salumifici) e il mapping non la prende.
3. **Il mapping colonne corretto per le tre aziende**, che si ricava solo dai file Excel attuali.
4. **La soglia dei 2 giorni del merge conflitti** resta fissa o diventa configurabile per azienda.
   È la classica cosa che si chiede di alzare a 3 al primo file strano.

---

## Appendice: lo stato dell'app PHP all'11 agosto 2026

Corretto e caricato in produzione lo stesso giorno: `toISOString()` chiamato su Date che
rappresentano la mezzanotte locale, quindi restituiva il giorno prima. Quattro punti, risolti con una
funzione `ymd()` in `js/app.js`:

- `js/import.js:28`, ogni data importata salvata un giorno prima
- `js/calendar.js:84`, chiave dell'indice, per cui l'ultimo giorno di ogni mese risultava sempre vuoto
- `js/calendar.js:31` e `js/analytics.js:62`, "oggi" sbagliato fra mezzanotte e le due di notte

I primi due si sommavano: un evento con data vera 23 aprile veniva salvato il 22 e mostrato il 21.
`js/import.js:34` (ramo del seriale Excel) è rimasto con `toISOString()` ed è corretto, perché lì la
Date nasce da un epoch UTC.

Lo storico **non** è stato spostato di +1 giorno, perché è quasi tutto compromesso per il motivo del
§11.1 e verrà rifatto con una reimportazione.
