# Fase 3: l'interfaccia

> **Fase chiusa il 17 agosto 2026, in produzione.** Questo resta il registro di cosa è stato deciso
> e perché, non una lista di cose da fare. Alcune sezioni contengono correzioni scritte *durante*
> l'esecuzione, quando la realtà ha smentito il piano: sono segnate come tali e sono la parte più
> utile da rileggere.

Piano di esecuzione. Otto task, un implementatore e un revisore ciascuno, come nelle fasi 1 e 2.

Alla fine di questa fase Valentina può usare l'applicazione dal telefono e dal PC senza `curl`, e la
v1 può essere spenta.

---

## Da dove si parte

La fase 2 è chiusa e **verificata su Vercel il 13 agosto 2026**, non solo in locale: login con l'hash
`$2y$` di PHP, `GET /api/dati` con gli 895 eventi veri, import completo di un Excel dall'anteprima
alla conferma. Vedi `docs/il-giorno-della-migrazione.md`, passo 1.

Quindi gli endpoint sotto non sono un'ipotesi di progetto: rispondono davvero, con questi corpi.

```
GET    /api/dati                             { eventi, aziende, alias, insegneEscluse, generatoIl }
POST   /api/sessione                         login          → { id, username, tokenVersion }
DELETE /api/sessione                         logout
POST   /api/password                         cambio password (incrementa tokenVersion)
POST   /api/import                           multipart: companyId + file → anteprima con bozzaId
POST   /api/import/<bozzaId>/conferma        { conferma?, accorpamentiAccettati? } → snapshot
DELETE /api/import/<bozzaId>                 annulla la bozza
POST   /api/eventi                           → snapshot
POST   /api/alias, DELETE /api/alias/<id>    → snapshot
POST   /api/insegne-escluse                  → snapshot
PATCH  /api/aziende/<id>                     mapping, colore, nome → snapshot
```

**Ogni mutazione risponde con lo snapshot completo.** Il client fa `dati = risposta`, mai un merge:
non esiste invalidazione da mantenere e non esiste lo stato semi-aggiornato. Il prezzo è che ogni
interruttore nelle impostazioni riscarica 443 KB; se in uso reale dà fastidio si passa a `204` più un
`GET`, ma è una decisione da prendere guardando il comportamento, non adesso.

Quello che **non** esiste ancora: `+layout.ts`, il service worker, qualunque pagina
(`+page.svelte` è ancora il benvenuto di SvelteKit), qualunque codice client.

`src/app.css` ha già i tre colori aziendali come token Tailwind e `overscroll-behavior: none`.
`src/app.html` ha già `viewport-fit=cover` e `lang="it"`.

---

## Come si esegue

Stesso metodo delle fasi 1 e 2: `superpowers:subagent-driven-development`, un implementatore e un
revisore per task, ledger in `.superpowers/sdd/progress.md` aggiornato **dopo ogni task**, commit per
task.

Tre cose da mettere nel prompt di ogni revisore, che sono quelle che hanno prodotto valore:

1. **Il piano è fallibile.** Nelle fasi 1 e 2 sono stati trovati **quindici difetti e stavano tutti
   qui dentro**, non nel lavoro degli implementatori. Un difetto imposto dal piano va segnalato come
   rilievo Importante con l'etichetta "imposto dal piano".
2. **Eseguire, non leggere.** I rilievi migliori sono arrivati da sonde costruite apposta. Per
   l'interfaccia questo significa aprire il browser: `npm run build && npm run preview`, guardare, e
   in particolare guardare a 390 px di larghezza.
3. **Il report va scritto anche su file** in `.superpowers/sdd/`, e il messaggio finale deve
   contenerlo per intero. I messaggi finali spesso non arrivano.

Controlli obbligatori prima di dire fatto, tutti e tre:

```
npm test        # tre fusi orari, non uno
npm run check   # svelte-check, i componenti .svelte ci passano dentro
npm run build   # in fase 2 ha intercettato un difetto passato sotto 114 test verdi
```

Gli implementatori vanno tenuti **sequenziali**: toccano tutti `src/routes` e in fase 2 due commit in
parallelo si sono contesi l'indice git.

---

## Dove stanno i dati veri per provare

`local.db` in locale è vuoto e la copia dei JSON della v1 si ferma a marzo 2026. I dati veri, gli 895
eventi dell'11 agosto, stanno nel database di prova Turso `promogest-test`, quello del deploy di prova.

```
turso db show promogest-test --url
turso db tokens create promogest-test
```

I due valori vanno in `DATABASE_URL` e `DATABASE_AUTH_TOKEN` come variabili d'ambiente, **senza
scrivere un `.env` nella repo**. È da lì che vengono i numeri dei criteri di fatto: 115 eventi attivi
il 20 agosto, 116 insegne, sei giorni di agosto senza inizi.

In alternativa si semina `local.db` con un dataset della stessa forma, che è quello che hanno fatto
implementatore e revisore del task 3. Va bene, ma i numeri del piano non corrisponderanno.

## Come si testa un'interfaccia, qui

**Non si introduce browser mode né Playwright.** La regola è un'altra, e va rispettata task per task:

> La logica di ogni schermata sta in una funzione pura in `$lib/vista/`, testata con Vitest.
> Il componente `.svelte` fa solo rendering di quello che la funzione ha già deciso.

Un componente che contiene un `if` sulle date, un ordinamento o un conteggio ha la logica nel posto
sbagliato. Spostarla non è pulizia estetica: è ciò che rende il difetto visibile prima del rilascio.
Le fasi 1 e 2 hanno 241 test e tutti i quindici difetti sono stati trovati eseguendo codice.

Quello che resta senza copertura automatica — che una cella sia toccabile, che la barra in basso non
finisca sotto la tacca dell'iPhone — si verifica a mano nel browser, ed è scritto nel criterio di
fatto dei task che lo riguardano.

---

## Decisioni chiuse prima di cominciare

**Le schermate restano quelle di oggi**: calendario, lista, analisi, impostazioni, più login e
import. Sono il minimo che serve a Valentina (deciso da Paolo il 13 agosto). Cambia l'esecuzione.

**La griglia del calendario conta gli inizi, non gli attivi.** Misurato su agosto 2026 coi dati veri:

| | attivi al giorno | inizi al giorno |
|---|---|---|
| minimo | 100 | 0 |
| massimo | 132 | 21 |

Gli attivi sono quasi una costante perché una promozione dura dieci o quattordici giorni, e infatti
nella v1 tutte le celle di agosto si somigliano. **Il dettaglio del giorno continua a mostrare tutti
gli attivi**, ed è lì che si risponde a "il 20 agosto cosa c'è da Esselunga". Sei giorni di agosto
hanno zero inizi: quelle celle restano vuote **e cliccabili**.

**L'import è una pagina, non un modale** (`/import`). Su Android il gesto Indietro chiude un dialog e
su iOS passare a Mail per recuperare l'allegato può scaricare la pagina: lì dentro sta l'unica
operazione lunga e distruttiva dell'app.

**Niente libreria di componenti.** Tailwind 4 con i token già in `app.css`.

Il resto delle decisioni di architettura sta in `docs/design.md` §4 e §7, che vale come specifica:
guscio statico con `ssr = false`, cache offline in `localStorage` come codice applicativo, service
worker che non tocca mai `/api/`.

---

## Trappole verificate, da non riscoprire una per una

Queste vengono dalla lettura del sorgente di SvelteKit e dalle prove sul deploy. Vanno lette prima di
scrivere codice, non dopo.

1. **`hooks.server.ts` gira anche a build time**, senza guardia su `state.prerendering`. Il filtro
   sul pathname è già la prima riga: non spostarlo, o il build si rompe.
2. **Il service worker di esempio di SvelteKit precachea `[...build, ...files]` e omette
   `prerendered`.** Copiandolo, la PWA online sembra perfetta e offline dà errore su ogni rotta mai
   visitata. Deve essere `const ASSETS = [...build, ...files, ...prerendered]`.
3. **In `vite dev` gli array `build`, `files` e `prerendered` sono vuoti.** L'unico collaudo offline
   valido è `npm run build && npm run preview` con DevTools in Offline.
4. **Un reload normale non promuove il service worker in attesa.** Serve
   `postMessage({type:'skip-waiting'})` e ricaricare su `controllerchange`.
5. **Il 403 CSRF di SvelteKit non è JSON.** Sull'upload multipart arriva `Cross-site POST form
   submissions are forbidden` come testo. Il wrapper `fetch` deve trattare "corpo non parsabile" come
   errore generico invece di esplodere sul `JSON.parse`. Verificato dal vivo il 13 agosto.
6. **`gravita: 'rifiuto'` arriva con `bozzaId: null` e status 200**, non 422: qui c'era scritto 422 ed
   era sbagliato, il codice risponde 200 apposta e il motivo sta scritto accanto
   (`api/import/+server.ts:161-167`). Il corpo ha forma di successo, e un wrapper costruito sulla
   regola "non-2xx vuol dire busta d'errore" scarterebbe proprio la spiegazione che serve a dire a
   Valentina cosa non va. **Il segnale su cui il client decide è `gravita` nel corpo, non lo status.**
   Non c'è niente da confermare, e un pulsante "conferma" su un rifiuto è un pulsante che non può
   funzionare.
7. **Il filtro per data lavora per sovrapposizione, non per contenimento**: escluso solo se
   `end < from || start > to`. Il rifacimento ingenuo (`start >= from && end <= to`) fa sparire metà
   della lista senza nessun errore.
8. **Mai costruire una `Date` per confrontare o formattare.** `$lib/date.ts` ha già tutto ed è
   condivisibile fra server e browser. È il bug che ha spostato ogni data di un giorno per cinque
   mesi in produzione: `toISOString()` su una `Date` a mezzanotte locale italiana restituisce il
   giorno prima. Il divieto scritto in cima a quel file vale identico per il client.
9. **`npm test` gira su tre fusi.** In UTC il bug delle date non fa fallire niente: una CI a fuso
   singolo sarebbe verde col bug rimesso.
10. **`autocomplete="off"` su ogni campo di ricerca**, e vale per i task 3, 4 e 7, che sono quelli che
    li creano: il task 1 non ne ha nessuno, quindi lì la regola è soddisfatta a vuoto. Nella v1
    l'autofill del browser ha riempito "Cerca prodotto" con un nome proprio e la lista è diventata
    "Nessun evento trovato", che sembra dati spariti. Non vale per i campi del login, dove
    `username` e `current-password` sono corretti e `off` sarebbe un errore.

---

## Task 1 — Guscio, navigazione, login

**Obiettivo:** l'applicazione si apre, si fa login, si naviga fra quattro sezioni vuote. Da qui in
poi ogni task riempie una sezione.

**File:** `src/routes/+layout.ts`, `+layout.svelte`, `src/routes/login/+page.svelte`,
`src/lib/vista/nav.ts`, `src/app.css`.

`+layout.ts` con `export const ssr = false` e `export const prerender = true`. Questo chiude anche il
rilievo della revisione di fase 2 che segnalava il commento in `hooks.server.ts:2-6` come descrizione
di una configurazione inesistente.

Barra di navigazione in basso su mobile e in alto su desktop, quattro voci più l'accesso a `/import`,
che oggi nella v1 da smartphone si raggiunge **solo** scendendo dentro l'accordion delle impostazioni.

Adattamenti mobile, da fare adesso e non alla fine perché sono invisibili su desktop e dolorosi in
uso reale:

- `env(safe-area-inset-bottom)` sulla barra inferiore (`viewport-fit=cover` è già in `app.html`)
- aree toccabili di almeno 44 px
- `autocomplete="off"` su **ogni** campo di ricerca dell'applicazione. Nella v1 l'autofill del
  browser ha riempito "Cerca prodotto" con un nome proprio e la lista è diventata "Nessun evento
  trovato" senza dire perché: sembra che i dati siano spariti.

**Test** (`src/lib/vista/nav.test.ts`):

- la voce attiva è quella della rotta corrente, per ciascuna delle cinque rotte
- `/import` risulta raggiungibile dalla navigazione principale, non annidata dentro le impostazioni

Serve anche un comando **Esci** nell'intestazione, che chiami `DELETE /api/sessione`. Il resto del
profilo utente è del task 7, ma senza questo per sei task non si esce se non dalla console del
browser. Deciso da Paolo il 13 agosto, dopo che la revisione ha fatto notare che il criterio di fatto
qui sotto pretendeva una cosa che il piano rimandava di sei task.

**Fatto quando:** `npm run build` genera un guscio HTML per ogni rotta e in `.vercel/output/functions`
non compaiono funzioni per le pagine. Il `![-]/catchall.func` che ci si trova accanto **non è un
difetto**: `adapter-vercel` lo genera sempre con `ssr = false`, `config.json` lo instrada dopo
`{"handle":"filesystem"}` e nessuna rotta reale lo raggiunge. La verifica che conta è che
`index.func` e `index/__data.json.func`, presenti prima di questo task, **non ci siano più**.

Login, logout e la barra in basso che non finisce sotto la tacca a 390 px vanno verificati sul
browser. `resize_window` non riduce il viewport reale: il modo che funziona è caricare l'app dentro
un `<iframe>` 390×844 della stessa origine, dove le media query si valutano sul documento interno. Su
desktop `env(safe-area-inset-bottom)` vale 0, quindi l'inset va simulato sovrascrivendo la regola
compilata con `padding-bottom: 34px`, il valore reale di un iPhone in verticale.

---

## Task 2 — Il client dei dati e la cache offline

**Obiettivo:** un solo posto da cui passano tutte le chiamate, e la copia locale che rende l'app
istantanea sempre, non solo quando manca il campo.

**File:** `src/lib/client/api.ts`, `src/lib/client/dati.svelte.ts`, `src/lib/client/cache.ts`.

All'avvio: legge la copia da `localStorage` e disegna subito, poi va in rete, aggiorna e riscrive.
Una chiave sola: `JSON.parse` dell'intero dataset costa meno di 2 ms, IndexedDB sarebbe cerimonia.

Tre regole che non sono dettagli:

- **Un 401 non tocca mai la copia locale, e con una copia locale presente non porta nemmeno al
  login.** Rientro dalle ferie, app aperta in metropolitana, sessione scaduta: se il client reagisce
  con logout, pulizia e redirect, ha appena cancellato i dati e non può rifare login con una tacca di
  segnale. Banner "sessione scaduta, tocca per rientrare", dati ancora visibili in sola lettura, e il
  login lo sceglie lei toccando il banner.

  La seconda metà della regola è stata aggiunta il 13 agosto, decisa da Paolo dopo che la revisione
  del task 2 ha mostrato che il piano si contraddiceva: il task 1 diceva "solo un 401 esplicito porta
  al login", il task 2 diceva "un 401 lascia i dati leggibili", e vinceva sempre la navigazione perché
  è una navigazione. Il banner esisteva e non si vedeva mai. **Senza copia locale il 401 porta al
  login come prima**, perché non c'è niente da mostrare.

  Il prezzo, accettato: i dati restano a schermo per chi ha in mano il telefono senza essere
  autenticato. Sono comunque già sul dispositivo in `localStorage`, e chi vuole uscire davvero ha
  *Esci*, che li cancella.

  **Il banner vive nel guscio, in `+layout.svelte` sopra `<main>`, non dentro una schermata.** I task
  3-7 non devono ricordarsene: se sta in una pagina, su tutte le altre la sessione scaduta non si vede
  e l'unico modo di tornare al login diventa *Esci*, cioè buttare i dati che si stavano leggendo.
- **Al ritorno dalla back-forward cache i dati vanno riaggiornati**, chiamando il caricamento dentro
  il `pageshow` con `persisted`. Senza, su iPhone (dove passare a Mail e tornare è la norma) l'app
  resta ferma ai dati di ieri finché non la si ricarica a mano. Trovato dalla revisione del task 2.
- **Una risposta non parsabile è un errore generico, non un'eccezione.** Vedi trappola 5.
- **La copia locale si scrive solo da una risposta di rete riuscita.** Mai da uno stato intermedio.
- **Sessione scaduta e uscita volontaria non sono la stessa cosa.** Un 401 lascia i dati leggibili,
  come sopra. Un *Esci* deve **cancellare la copia locale**: altrimenti sul telefono resta l'intero
  dataset in `localStorage` e il tasto Indietro lo rimette a schermo. Sollevato dalla revisione del
  task 1, dove il piano diceva solo la prima metà.

**Test** (`src/lib/client/cache.test.ts`, `api.test.ts`, con `fetch` finto):

- 401 → la copia locale è ancora intatta dopo la chiamata, e lo stato espone "sessione scaduta"
- risposta con corpo non JSON (il 403 CSRF letterale) → errore generico, nessuna eccezione propagata
- risposta di rete riuscita → `localStorage` contiene lo snapshot nuovo
- copia locale corrotta a mano (JSON invalido) → l'app parte comunque e va in rete
- copia locale di uno schema più vecchio (manca un campo) → non manda in errore il primo render
- **uscita volontaria → la copia locale è vuota; 401 → la copia locale è intatta.** Sono due test, non
  uno: è la distinzione che il task 1 ha fatto emergere
- `generatoIl` più vecchio di quello già in cache → **decidere e testare cosa vince.** Oggi non esiste
  ETag né versione per riga: `generatoIl` è l'unico appiglio.

**Fatto quando:** ricaricando a rete spenta l'app mostra gli ultimi dati con un avviso di stato, e a
rete riaccesa si aggiorna da sola.

---

## Task 3 — Calendario

**Obiettivo:** la schermata principale, quella che Valentina apre in macchina fra una sede e l'altra.

**File:** `src/lib/vista/calendario.ts`, `src/routes/+page.svelte`, componenti in
`src/lib/componenti/`.

Tutta la logica in `calendario.ts` come funzioni pure:

```
costruisciMese(anno, mese0, eventi, filtri)  → celle con: giorno, inizi[], vuota, fuoriMese
attiviIl(eventi, giorno, filtri)             → gli eventi attivi quel giorno, ordinati
prossimi(eventi, oggi, giorni)               → cosa parte e cosa chiude nei prossimi N giorni
```

La griglia mostra **il numero degli inizi**. Il dettaglio del giorno mostra **tutti gli attivi**, con
ricerca, come oggi. Sopra la griglia la banda "cosa parte e cosa chiude nei prossimi giorni".

**Le righe del dettaglio si aprono sui prodotti**, come nella v1 e come fa la lista del task 4. Senza,
l'elenco dice "3 prodotti" e non permette di vedere quali: è un elenco fine a sé stesso. Segnalato da
Paolo il 14 agosto guardando la schermata, dopo che il task 3 era già stato chiuso da due revisioni:
il piano diceva "come oggi" senza dire cosa comprendesse, e implementatore e revisore hanno guardato
il brief invece della v1.

Le aziende diventano un filtro invece di tre barre decorative, con la legenda dei colori che nella v1
è un `<div>` vuoto da sempre. **Ricerca insegna a livello di pagina**, che oggi manca del tutto:
esiste solo dentro il popup del giorno, quindi 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 Valentina digita "iperal".

Lo stato della vista sta **nella URL** (`?m=2026-04`), non in `$state` locale: con le rotte SvelteKit
i componenti si smontano e il mese si azzererebbe a ogni cambio pagina, mentre nella v1 il DOM non
viene distrutto e lei ritrova il mese dov'era. Nella URL sopravvive anche al refresh.

**Test** (`calendario.test.ts`), sui dati veri di agosto 2026 dove indicato:

- un evento che inizia il 13 agosto compare **solo** nella cella del 13, non nelle successive
- i sei giorni di agosto senza inizi producono celle vuote **e non disabilitate**
- una di quelle celle vuote, aperta, mostra un centinaio di eventi attivi
- l'ultimo giorno del mese esiste nella griglia (nella v1 era sempre vuoto per un bug di indice)
- un evento a cavallo di due mesi compare nel dettaglio di entrambi
- 1° marzo 2026, domenica: `offsetPrimoGiorno` con la settimana che inizia di lunedì
- febbraio 2028, bisestile: 29 celle
- il filtro azienda cambia i conteggi della griglia **e** il contenuto del dettaglio, coerentemente
- ricerca "iperal" trova `IPERAL SUPERMERCATI SPA con socio unico`; ricerca "spa" non deve trovare
  tutto per prefisso
- `prossimi()` non restituisce mai eventi già chiusi. **Nella v1 "Prossimi eventi" mostra 06/07 e
  08/07 con oggi al 13 agosto**: è un bug, non una scelta, e non va riprodotto
- l'intera suite gira coi tre fusi: nessuna `Date` costruita per confronto o formattazione

**Fatto quando:** aperto a 390 px, agosto 2026 mostra celle chiaramente diverse fra loro; il dettaglio
del 20 agosto elenca i 115 eventi attivi; il mese sopravvive al passaggio a Lista e ritorno.

---

## Task 4 — Lista

**File:** `src/lib/vista/lista.ts`, `src/routes/lista/+page.svelte`.

Filtri e ordinamento come oggi: aziende, ricerca insegna, ricerca prodotto, intervallo di date.
Raggruppamento per insegna con "N prodotti, M periodi".

**Quei due numeri contano cose distinte, non righe.** La v1 somma le righe in entrambi i casi, e sui
dati veri il numero dei prodotti risulta gonfio per 94 insegne su 116, in media il doppio: «IPER
MONTEBELLO SPA: 264 prodotti, 51 periodi» sono in realtà 49 articoli diversi ripromossi 51 volte.
Deciso da Paolo il 13 agosto, dopo che la revisione del task 4 ha misurato lo scarto contro il
database. **Valentina va avvisata che i numeri saranno più bassi di quelli a cui è abituata**,
altrimenti penserà che manchino dei dati.

Conseguenza da conoscere: l'intestazione di un gruppo può dire «11 periodi» sopra 12 righe, quando due
righe hanno le stesse date. È corretto, ed è il motivo per cui questa regola è scritta qui e non
riscoperta nel task 5.

**Il filtro per data lavora per sovrapposizione** (trappola 7). È il difetto più facile da introdurre
qui e il più difficile da notare: non dà nessun errore, dà solo metà dei risultati.

Lo stato vuoto deve **nominare il filtro che sta escludendo tutto**: "nessun risultato per «paolo»",
non "Nessun evento trovato". Insieme all'`autocomplete="off"` del task 1, chiude il caso in cui
l'autofill del browser fa sembrare che i dati siano spariti.

**Test** (`lista.test.ts`):

- filtro 1–31 agosto: un evento 25 luglio → 5 agosto **c'è** (sovrapposizione). Con la logica per
  contenimento sparirebbe: questo test deve fallire se qualcuno la reintroduce
- evento che contiene interamente la finestra (1 luglio → 30 settembre su una finestra di agosto): c'è
- `from` senza `to` e `to` senza `from` funzionano entrambi
- `from` successivo a `to`: comportamento definito, non risultati casuali
- ricerca prodotto insensibile a maiuscole e accenti
- il conteggio "M periodi" conta i periodi distinti, non le righe

**Fatto quando:** la lista con tutti i filtri aperti mostra le 116 insegne, e ogni filtro riduce nel
modo che il test descrive.

---

## Task 5 — Analisi

**File:** `src/lib/vista/analisi.ts`, `src/routes/analisi/+page.svelte`.

Le tre correzioni rispetto alla v1, tutte e tre necessarie:

1. **Il filtro delle insegne escluse va applicato anche qui.** Oggi manca, quindi le stesse insegne
   raccontano storie diverse a seconda della pagina che apri.

   **Va applicato in tutte e tre le schermate, storico compreso.** Deciso da Paolo il 14 agosto, dopo
   che la revisione ha accertato lo stato reale: l'esclusione agisce **solo durante l'import**
   (`api/import/+server.ts` la passa a `leggiWorkbook`), quindi quelle righe nel database non entrano
   mai e oggi zero eventi su 895 appartengono a un'insegna esclusa. La contraddizione esiste
   nell'unico caso in cui può esistere, cioè un'insegna esclusa **dopo** essere stata importata:
   provato escludendo `BENNET`, l'analisi scende a 888 eventi mentre calendario e lista restano a 895.
   Escludere significa "questa insegna non mi interessa più", quindi vale ovunque.
2. **Il grafico mensile deve usare la stessa semantica del calendario.** Oggi ignora l'anno e
   attribuisce l'evento al solo mese di inizio: il picco di luglio a 205 è la somma di sette luglio
   diversi, dal 2023 al 2029. Asse temporale con l'anno, e l'evento contato come nel calendario.
3. **`extra_info_label` in evidenza.** È l'informazione più importante di questa pagina, perché la
   stessa colonna significa cose diverse a seconda dell'azienda: "Meccanica" per Caseifici **e per
   Salumifici**, "Volantino" per Parmacotto. Nella v1 è nascosta dentro un `<small>`.

   Le etichette diverse sono **due, non tre**. Qui c'era scritto che Salumifici usa "Tema", ed era
   falso: la revisione del task 5 ha verificato che usa "Meccanica" sia in `promogest-test` sia in
   `data/companies.json` della v1. Corretto perché il task 7 mette mano proprio al mapping, e qualcuno
   avrebbe potuto "sistemare" Salumifici su un'informazione sbagliata.

"Prossimi eventi" usa la stessa funzione `prossimi()` del task 3. Una sola implementazione, non due.

Chart.js o SVG a mano: sceglie l'implementatore, ma se entra Chart.js entra con un motivo scritto nel
commit. Un istogramma per mese è dodici rettangoli.

**Test** (`analisi.test.ts`):

- un'insegna esclusa non compare in nessuna delle tre statistiche né nella top 10
- il grafico distingue luglio 2026 da luglio 2027
- un evento 28 luglio → 5 agosto è attribuito come lo attribuisce il calendario (il test enuncia la
  regola, così se cambia si vede)
- i totali (895 eventi, 3.238 prodotti, 116 insegne) coincidono con quelli di `/api/dati` una volta
  applicate le esclusioni

**Fatto quando:** i numeri della pagina coincidono con quelli del calendario per lo stesso periodo.

---

## Task 6 — Import

**Obiettivo:** l'unica operazione distruttiva dell'applicazione, con addosso tutto quello che la fase
2 ha costruito per renderla sicura.

**File:** `src/routes/import/+page.svelte`, `src/lib/vista/import.ts`, e le modifiche a
`src/routes/api/import/+server.ts` per i due rilievi qui sotto.

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

Il file **sale una volta sola**. L'anteprima scrive le bozze, la conferma le promuove.

Cosa deve mostrare, e sono tutti pezzi che esistono già nella risposta:

- `allarmi[]`, con `codice`, `gravita` e `messaggio` **già scritto in italiano per Valentina**: si
  mostra il messaggio, non si riscrive
- il secondo tocco **solo** quando `gravita === 'conferma'`. Su `'rifiuto'` (422, `bozzaId: null`) non
  c'è niente da confermare: c'è da rimandare il file
- `accorpamenti` come **caselle da spuntare, non come informazione**. È il pezzo di valore nuovo
  rispetto alla v1, dove la funzione alias non è mai stata usata perché andavano creati a mano uno per
  uno. Sul file Parmacotto vero ne propone **9** contro lo stato attuale di `promogest-test`, 11 se si
  tolgono le esclusioni, 0 su un database vuoto: dipende da cosa c'è già dentro, quindi non è un
  numero fisso. Qui c'era scritto 22, che non corrisponde a nessuno stato riproducibile
- **la tabella di anteprima delle righe vere** (`Insegna | Prodotto | Dal | Al | Extra`), come la v1.
  È l'unica difesa contro il mapping **quasi** giusto: gli allarmi prendono il caso estremo, zero
  eventi e 100% di scarti, ma un indice spostato di una colonna che produce comunque 156 eventi passa
  senza allarmi e a schermo restano solo numeri che tornano. È la forma esatta del 10 giugno. Un
  essere umano che legge tre righe vere se ne accorge in un secondo
- **`nonMappate` e `conflitti`**, che il server manda già e nessuno mostrava. Il secondo soprattutto:
  l'app fonde due periodi in uno da sola, e modificare i dati dell'utente senza dirglielo, sulla
  schermata distruttiva, è la cosa che meno di tutte dovrebbe restare muta
- `scartate[]` raggruppate per motivo, non 138 righe in fila. Sul file vero: 136 "data inizio mancante
  o non valida" più 2 date rovesciate
- `DELETE /api/import/<bozzaId>` cablato sul pulsante *annulla*, altrimenti le bozze restano 24 ore

**Due rilievi della fase 2 si chiudono qui, e vanno chiusi prima di disegnare la schermata:**

- **I5 — `fogli` è nella risposta ma non c'è modo di agirci.** Oggi il server sceglie il foglio da sé
  e la risposta elenca gli altri, il che promette una scelta che non esiste. O si accetta un parametro
  `foglio` in `POST /api/import`, o si toglie `fogli` dalla risposta. La prima, perché il giorno che
  l'euristica sbaglia foglio non c'è nessuna via d'uscita.
- **I8 — `draftId` è generato dal server invece che dal client**, quindi un retry dopo un timeout di
  rete crea una seconda bozza invece di ritrovare la prima. Il client genera un UUID e lo manda.

**Test** (`import.test.ts` per la vista, più i test degli endpoint per I5 e I8):

- `gravita: 'ok'` → un solo tocco, nessuna richiesta di conferma
- `gravita: 'conferma'` → il pulsante di conferma appare e manda `{ conferma: true }`
- `gravita: 'rifiuto'` → **nessun pulsante di conferma**, `bozzaId` nullo gestito
- accorpamenti: spuntandone due, la conferma manda esattamente quei due in `accorpamentiAccettati`
- accorpamenti: nessuno spuntato → array vuoto, non il campo assente
- 403 con corpo di testo → messaggio generico, nessuna eccezione (trappola 5)
- I5: `POST /api/import` con `foglio` esplicito legge quel foglio; con un nome inesistente dà 400
- I8: due `POST /api/import` con lo stesso `draftId` dal client non creano due bozze

**Fatto quando:** l'import dei tre Excel veri di Valentina, dall'inizio alla fine dal browser, su un
database di prova, dà gli stessi conteggi ottenuti con `curl` il 13 agosto (Parmacotto: 156 eventi,
40 insegne, 22 accorpamenti proposti, 138 righe scartate).

---

## Task 7 — Impostazioni

**File:** `src/routes/impostazioni/+page.svelte` e i componenti delle sei sezioni.

Le sezioni di oggi: profilo utente, aziende e mapping colonne, alias insegne, insegne da escludere,
gestione dati. L'import esce da qui e diventa la sua pagina (task 6).

Il comando *Esci* esiste già dal task 1: qui si aggiunge il resto del profilo, cioè cambio password
ed *esci da tutti i dispositivi*. Non duplicarlo.

**Il mapping colonne è la conoscenza più costosa del dataset.** Sette indici per azienda, ed è quello
che il 10 giugno ha prodotto 2.833 eventi spazzatura su 2.929 quando è andato storto. La schermata
deve mostrare **cosa significano quegli indici sul file vero**, non sette caselle numeriche: accanto a
`insegna = 3` va scritto cosa c'è nella colonna 3 dell'ultimo import.

Qui si chiude **I6 — `mappingValido` valida solo `typeof`**: un indice negativo o frazionario passa il
controllo e fa scartare tutte le righe senza spiegazione. Va validato come intero non negativo, lato
server, non solo nel form.

L'elenco delle insegne escluse sono 244 voci e va reso navigabile: ricerca, e il conteggio di quante
righe ciascuna sta togliendo dall'ultimo import. Il dubbio di fondo — quella lista taglia il 91% delle
righe di Caseifici — è una decisione di Valentina, non tecnica, e questa schermata è dove può vederla.

**Test:**

- `mappingValido` rifiuta: indice negativo, frazionario, `NaN`, stringa numerica, indice duplicato fra
  due campi diversi
- cambiare il mapping e poi rileggere `/api/dati` restituisce il mapping nuovo
- il cambio password incrementa `tokenVersion` e invalida le altre sessioni
- l'elenco delle insegne escluse mostra anche quelle che non compaiono in nessun evento (nella v1 una
  volta esclusa un'insegna spariva dall'elenco e non si poteva più riattivare — bug corretto in
  produzione l'11 agosto, non va reintrodotto)

**Fatto quando:** si può correggere un mapping sbagliato e rifare l'import senza toccare il database.

---

## Task 8 — Service worker, aggiornamenti, collaudo offline

**File:** `src/service-worker.ts`, `src/lib/client/versione.ts`, `vite.config.ts`.

```js
const ASSETS = [...build, ...files, ...prerendered];   // prerendered: vedi trappola 2
if (url.pathname.startsWith('/api/')) return;          // niente respondWith, mai
```

Quella seconda riga non è coerenza formale, previene una perdita di dati silenziosa: Valentina importa
da PC, poi apre il telefono, il client fa `fetch('/api/dati')` 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.

Aggiornamenti: `kit.version.pollInterval: 300000`, avviso "nuova versione, tocca per ricaricare",
`postMessage({type:'skip-waiting'})`, ricarica su `controllerchange`. Niente `skipWaiting()`
incondizionato: il service worker nuovo si attiverebbe sotto una scheda già aperta, l'`activate`
cancellerebbe i chunk che quella scheda sta usando, e in produzione quei chunk restano 404.

`/login` va prerenderizzata **e** inclusa nel precache, altrimenti la sessione che scade offline
lascia l'app senza nemmeno la pagina per rientrare.

Qui si chiude anche **la finestra delle date duplicata in quattro punti** (`2020-01-01` / `2099-12-31`
in `schema.ts`, `import/+server.ts`, `excel.ts`, `migra-da-php.ts`): una costante sola, importata.

**Collaudo, e va fatto con `npm run build && npm run preview`, non in `vite dev`** (trappola 3):

- prima visita, poi DevTools in Offline: **ogni** rotta si apre, comprese quelle mai visitate
- offline, i dati dell'ultimo caricamento si vedono con l'avviso di stato
- offline su `/import`: l'app dice che serve la rete, non fallisce a metà
- deploy di una versione nuova con la scheda aperta: compare l'avviso, si tocca, ricarica, e **non ci
  sono 404 sui chunk**
- sessione scaduta mentre si è offline: banner, dati ancora leggibili, `/login` raggiungibile

**Fatto quando:** l'app aggiunta alla schermata Home di un iPhone si apre e mostra i dati in modalità
aereo.

---

## Rilievi della fase 2, dove si chiudono

| | rilievo | task |
|---|---|---|
| I5 | `fogli` nella risposta senza modo di sceglierlo | 6 |
| I6 | `mappingValido` valida solo `typeof` | 7 |
| I8 | `draftId` generato dal server, retry non idempotente | 6 |
| I10 | finestra delle date duplicata in quattro punti | 8 |
| — | commento in `hooks.server.ts:2-6` che descrive una configurazione inesistente | 1 |

**I7** — la migrazione si ferma al primo record invalido senza dire quanti sono — **non è di questa
fase**: riguarda lo script, e il giorno della migrazione si aggira con un `jq`, come scritto al passo
8 di `docs/il-giorno-della-migrazione.md`.

---

## Cosa resta fuori, deliberatamente

- **Export e backup.** La v1 aveva `api/export.php`, la v2 non ha niente: dal giorno del passaggio il
  dato di Valentina sta in un posto solo e nessuno lo copia. Serve, ma è una riga di cron
  (`turso db shell <db> .dump`), non una schermata, e va deciso a parte.
- **Recupero password.** Non c'è email, non c'è endpoint: oggi si fa con un `UPDATE` su Turso. Va
  scritto in `docs/`, perché il giorno che serve nessuno se lo ricorderà.
- **Coda di sincronizzazione delle scritture.** Deciso in fase di progetto: Valentina ha una sola
  scrittura e non esiste il caso "importa offline".
- **Le domande ancora aperte con Valentina**: se le servano promozioni distinte per lo stesso
  prodotto, e se la lista delle insegne escluse debba davvero tagliare il 91% di Caseifici. Nessuna
  delle due blocca questa fase.
