# Handoff — TerManager2 Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). --- ## Stato attuale L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, redeploy, login, tutte le pagine, code e scheduler. | Aspetto | Stato | |-----------------------------|-------| | Deploy Dokploy (Compose) | ✅ verificato | | Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici | | Redeploy senza interventi | ✅ ~25 secondi | | Sviluppo locale | ✅ stesso compose, profilo `dev` | | Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale | | Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) | | Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale | **Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre di avanzamento reali e console di log (commit `f909791`, deploy verificato confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo** su develop; la produzione non è stata toccata e resta sul suo deploy precedente, senza quella variabile. **Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap, immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico versioni. Architettura e agganci sono descritti in [Decisioni architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). Al primo deploy il compose costruisce anche l'immagine del renderer; non servono variabili nuove in Dokploy. **Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine si sono staccate dai territori (una piantina vive anche scollegata, il territorio si crea dall'editor), il disegno è passato su una pagina a schermo intero, la cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98% sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste). Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti** gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la porta](#http_host-a-nginx-deve-conservare-la-porta). Il 30/07 sono state fatte due cose. **La suite di test, che prima non esisteva**: 79 test eseguibili con un comando, senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione della logica di business — vedi *Cosa resta aperto*. Non fanno parte dell'immagine di produzione e non girano su un deploy: sono uno strumento locale. **La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con gli stati di focus, che prima non c'erano), e **una sola console** per tutte le operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di log sparsi. L'azzeramento è diventato un componente a sé per poter stare in fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel browser — apertura automatica della console, scorrimento, lampeggio delle zone — non è stato provato**, perché richiede un browser: è la prima cosa da guardare aprendo la pagina. Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto* e l'unica che richiede una decisione, non del lavoro. --- ## Lavori in corso — 12 ottobre 2026 (riprendere da qui) ### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato) - **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente: 20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per data. Codice in `app/Services/Registro/ModuloS13.php`. - **Blocco 1 della revisione** (integrità dati): date export/import che slittavano di un giorno, import XML che accettava file estranei, caratteri di controllo, territori senza zona scartati, admin estromesso dall'import, doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin, `APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella "Bug corretti". Suite: 328 test verdi. - Il push su `dev` fa partire il deploy su demo: va fatto quando si decide. ### Finito, NON ancora committato: riprogettazione dell'Editor Piantine Decisioni dell'utente: due pagine separate (gestione / editor); **un solo pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da confermare, disponibile da editor e gestione; dall'editor **si modificano i dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata **automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). Stato al 12/10/2026: - Codice completo: modello, servizio di pubblicazione + job, finestra condivisa, editor e gestione riscritti, JS e CSS. Migrazione `2026_10_09_000010_bozza_e_pubblicazione_piantine`. - Test: `PiantineEditorTest` (22), `PiantineManagerTest`, `PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**. - Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su poligono), collegamento, anteprima generata dal renderer vero, conferma, stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori schermo su telefono, crediti mappa stampati sul foglio del pannello. - Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true` l'entrypoint usciva con CRLF e il container locale non partiva ("no such file or directory"). Nel repository era già LF: il deploy non era a rischio. - **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon in modifica vertici (si modifica il primo poligono). - Visto e **non** corretto (fuori tema): su telefono la barra superiore dell'app trabocca con nome/email lunghi ("Esci" fuori schermo); `/favicon.ico` risponde 404. ### Revisione profonda: blocchi ancora aperti - **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza autenticazione (spostare su disco privato); ripristino non atomico e blocco "una sola operazione" ignorato da import XML/PDF; piantine e storico PDF assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20 file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo). - **Blocco 3:** `/cambia-password` senza password attuale, sessioni non invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s, `stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di `Setting` nel worker, azzeramento che lascia piantine e KML. - Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di sviluppo, più la demo da `dev`). Nessuna correzione una tantum delle date slittate da fare. --- ## Da dove veniamo Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.** L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro il container. Su Dokploy, senza sorgente sull'host, significava build a runtime a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione. Da qui la ristrutturazione descritta sotto. --- ## Dove gira davvero Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal repository Gitea `francesco/termanager2` con **autoDeploy attivo**. | Ambiente | Branch | Dominio | Compose (`appName`) | |--------------|--------|-------------------------------------|----------------------------| | `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` | | `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` | Conseguenze pratiche, tutte imparate sul campo: - **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione, che segue `main`. È la separazione su cui contare quando si vuole provare qualcosa senza rischi. - **Un "Rebuild deployment" NON scarica il codice nuovo**: ricostruisce il checkout già presente e ci mette una ventina di secondi. Per prendere l'ultimo commit serve un deploy vero (`compose.deploy` via API, o *Deploy* nell'interfaccia). Un rebuild che finisce troppo in fretta è il segnale che si sta ricostruendo il codice di prima. - **Le variabili d'ambiente stanno in Dokploy, non nel repository.** Il `.env` dell'ambiente lo genera l'interfaccia: una variabile aggiunta a `.env.example` non arriva da sola sui deployment, va aggiunta a mano lì. È successo con `MAX_UPLOAD_MB`, presente in develop e — deliberatamente — non in produzione. - **Verifica che il codice servito sia quello giusto** confrontando l'impronta degli asset: `public/build/assets/app-*.css` e `-*.js` hanno il nome derivato dal contenuto, quindi se coincide con quello della propria build, il frontend in linea è lo stesso sorgente. È il controllo più rapido dopo un deploy, e non richiede di autenticarsi. --- ## Decisioni architetturali Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il contesto per ridiscuterla è qui. ### L'immagine è autosufficiente `vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e copiati nell'immagine finale. I container di produzione **non montano il sorgente** e non eseguono build all'avvio. Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend, serve un rebuild dell'immagine, non un semplice restart. ### nginx e php-fpm nello stesso container Con container separati, nginx avrebbe bisogno di una copia di `public/` e del volume storage solo per servire file statici. Per questa scala è complessità senza beneficio. Sono coordinati da supervisor (`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due muore definitivamente — altrimenti resterebbe "up" senza servire nulla. ### La configurazione arriva dall'ambiente, non da `.env` riscritto Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel legge direttamente l'ambiente di processo, e `docker-compose.yml` si limita a `env_file: .env` senza ridichiarare nulla. Il `.env` è quindi l'**unica** fonte di configurazione. Una versione precedente fissava nel compose i valori dettati dalla topologia (`DB_HOST: mariadb` e simili) perché vincessero su un `.env` sbagliato, ma richiedeva gli anchor YAML (`x-app-env`, `<<:`) ed è stata scartata a favore della leggibilità. Il rovescio della medaglia: `DB_HOST` e `REDIS_HOST` devono valere `mariadb` e `redis`, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il container non trova più il database. È documentato in `.env.example` e nel README, ma è il punto fragile di questa scelta. Nota: perché la configurazione da ambiente funzioni, `www.conf` ha `clear_env = no`. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe nessuna variabile. ### Fail-fast sulle variabili mancanti Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il messaggio d'errore è più chiaro). Deliberato: un errore immediato è meglio di un database in produzione con password `secret`. ### Le piantine sono una sotto-app: renderer esterno, agganci minimi Le **Piantine** (solo amministratore) generano le mappe dei territori. Si carica il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet) nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta definizione e — a scelta — la cartolina sul modulo ufficiale S-12 (`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce il PDF del territorio. Il PDF precedente finisce nella tabella `territorio_pdf_versioni` ed è ripristinabile dalla pagina di gestione. Il rendering vero e proprio **non sta nell'applicazione**: lo fa il container `map-renderer` (`docker/renderer/`, Node + sharp), che scarica le tessere OSM (cache persistente nel volume `tile_cache`, User-Agent identificante, attribuzione impressa sull'immagine come da licenza), le cuce, disegna il confine e attenua l'esterno. L'app gli parla solo via HTTP sulla rete interna (`config/piantine.php`, default `http://map-renderer:3000`: nessuna variabile da aggiungere in Dokploy). Se il container è giù, l'unica cosa che smette di funzionare è la generazione delle immagini; il resto del gestionale non lo sa nemmeno. Gli agganci al core sono deliberatamente pochi: la rotta nel gruppo `settings.manage`, la voce di menu, e l'aggiornamento di `pdf_path`/`thumbnail_path` sul territorio quando si sostituisce un PDF (stesso percorso `territori-pdf/` + `TerritorioThumbnailService` dell'upload manuale). Le geometrie vivono nella tabella separata `territorio_mappe`, l'area KML in `storage/app/piantine/`: rimuovere la sotto-app significa togliere questi pezzi, non disfare il gestionale. **Il disegno viene prima del numero.** Una piantina non ha bisogno di un territorio: `territorio_mappe.territorio_id` è nullable e il collegamento si fa (e si disfa) quando si vuole, con `nome` come etichetta finché manca. Si taglia l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio non esiste, lo si crea dall'editor col solo numero. La chiave esterna è `nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare `territori` come fa ogni import XML, non porta più via i disegni. **Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate / scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre, si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`). **Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo: il confine a ogni disegno o spostamento di vertice, nome e collegamento appena cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio (numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano subito, colonna per colonna (un campo non valido non trascina gli altri). Il PDF cambia solo con **Pubblica**, uguale in editor e gestione (`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di Cloudflare), che genera immagine e PDF sul disco **privato** (`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40 caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato qualcosa di stampato, la conferma rifiuta. Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata), **Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`: sha256 di confine, territorio, numero, nome della zona e confini — cambia uno qualunque e la piantina torna da pubblicare anche col disegno identico. Vale "Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina (`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un upload a mano o un import la riportano "da pubblicare". "Ripristina il confine pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione `2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF in vigore del territorio veniva da una piantina. Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si parla per eventi: i dati di tutte le piantine li chiede il JS una volta (`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`). Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`, componente ``): i flash di sessione nel telaio non si vedono dopo un'azione Livewire. **La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi pagina 1 è il modulo con Località, numero e i **confini scritti** nello spazio centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la piantina sola al 98% della pagina. **La "Località" del modulo è il nome della zona.** Era un campo globale, poi un campo della piantina, e in entrambi i casi era un doppione: la zona esiste già, dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco marca i territori collegati senza zona: senza, l'intestazione della cartolina esce vuota e ce se ne accorge in stampa. **L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): - Il renderer compone alle tessere dello zoom più alto che entri in `max_side × 2` e poi riduce con Lanczos. Le etichette OSM sono disegnate dentro la tessera a dimensione fissa: salire di uno zoom le rende più definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale. - Chiede al renderer le **proporzioni della pagina** (`aspetto`): un territorio quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni, lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il ritaglio si mostrano più dintorni invece di carta vuota. - JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274 territori è la differenza fra qualche centinaio di MB e diversi GB. L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa: 22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e riportato alla scala di composizione: deve dire dove passa il confine senza cancellare la via che ci corre sotto, che è proprio quella che serve leggere. ### Il sistema visivo sta nei token, non nelle viste `resources/css/app.css` **ridefinisce le scale `gray` e `indigo` di Tailwind** dentro `@theme`. È la cosa che sorprende di più aprendo il file, ed è deliberata: le trentasette viste sono scritte con le utility (`text-gray-600`, `bg-indigo-50`), e cambiare la scala le riallinea tutte insieme. L'alternativa — riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non apre proprio quella pagina. Da qui tre conseguenze che vanno sapute prima di metterci mano: - **I grigi sono caldi.** Quelli di serie di Tailwind tirano al blu e, sommati a un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi diventano moduli di carta che passano di mano: un fondo caldo li avvicina a quello che sono. Non mescolare grigi freddi qui dentro. - **L'accento è uno.** Indaco = azione, verde = fatto, ambra = in sospeso, rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia. Prima le tinte erano cinque e decorative. - **Il carattere è servito dall'applicazione** (`@fontsource-variable/figtree`, dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi di persone non deve chiamare un dominio di terze parti a ogni pagina caricata, e così funziona anche senza rete. I componenti `tm-*` (pulsanti, console, card, campi, elenchi, editor) stanno tutti qui e **fuori da `@layer`**, quindi vincono sulle utility: un pulsante non si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi, non catene di utility nuove. ### `HTTP_HOST` a nginx deve conservare la porta `docker/nginx/default.conf` rimette `fastcgi_param HTTP_HOST $http_host;` dopo l'`include fastcgi_params`. Non e' ridondanza: dalla 1.26 il `fastcgi_params` di sistema usa `$host`, che la porta la butta via. Con `APP_URL` su porta non standard — cioe' lo sviluppo locale su `:8090` — Laravel ricostruisce l'URL della richiesta senza porta mentre `URL::forceRootUrl()` genera i link con, e ogni URL firmato non valida piu': **tutti** gli upload di file rispondono 401, perche' l'endpoint di Livewire aborta sulla firma. In produzione `APP_URL` non ha porta e le due forme coincidono, quindi il guasto si vede solo in locale. Inoltrare l'header grezzo e' sicuro perche' `trustHosts()` in `bootstrap/app.php` rifiuta gia' gli Host che non siano quello di `APP_URL`, `localhost` o `127.0.0.1`. ### Modifica/cancellazione delle assegnazioni: solo amministratore L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/ `askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli — `abort_if(..., 403)` nel componente e `@can('settings.manage')` in `registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha solo l'`amministratore`. Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` + `territori.assign` + `territori.return`) vede il Registro, crea nuove assegnazioni e le fa rientrare, ma non le modifica né le elimina. Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su `syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration `2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente` se presente. È idempotente (no-op sul baseline) e volutamente irreversibile — il `down()` non ripristina il permesso. Stesso principio per le **campagne**: creazione e visualizzazione restano su `campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione sono riservate all'amministratore** (`settings.manage`). Il gate è su tre livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`; `CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano `$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha `campagne.manage`, quindi il delete va protetto nel metodo, non solo in route); i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto `@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione è definitiva. Per i **territori** vale un modello leggermente diverso, perché la visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin, assistente e operatore); creazione/modifica/cestino restano su `territori.manage` (solo admin). Attenzione a due punti: - **Ordine delle rotte.** Le rotte letterali `territori/create` e `territori/cestino` devono restare registrate PRIMA del wildcard `territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di dettaglio. - **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo componente è ora servito da una rotta rilassata (`territori.assign`), quindi i suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`) NON sono più protetti dal middleware di route e hanno un `$this->authorize('territori.manage')` interno — senza, un operativo potrebbe invocarli via `/livewire/update` col pulsante nascosto. Per difesa in profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`, `TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete` (che restano comunque su rotte `territori.manage`). I pulsanti Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show` sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`. ### Sola lettura per proclamatori e campagne (non-admin) Stessa impostazione dei territori estesa ad altre due sezioni: - **Proclamatori**: `index` e `show` restano su `proclamatori.manage` (l'assistente li vede in sola lettura); `create`/`edit`/`cestino` e tutte le mutazioni (`ProclamatoreIndex::toggleActive`/`deleteProclamatore`/`anonimizza`, `ProclamatoreCreate::save`, `ProclamatoreEdit::save`, `ProclamatoreCestino::restore`/`forceDelete`) richiedono `settings.manage`. `ProclamatoreIndex` è servito da una rotta rilassata, quindi i suoi metodi di mutazione hanno il check interno; i pulsanti in `proclamatore-index`/`-show`/ `-cestino` sono sotto `@can('settings.manage')`. Le rotte letterali create/cestino restano prima del wildcard `{proclamatore}`. - **Campagne**: la **creazione** è ora admin-only come già modifica/eliminazione — `CampagnaCreate::save` chiama `authorize('settings.manage')`, la rotta `campagne/create` è nel gruppo `settings.manage` (registrata prima del wildcard) e il pulsante "Nuova Campagna" è sotto `@can('settings.manage')`. `index`/`show` restano su `campagne.manage` (sola lettura per l'assistente). ### Tabelle-elenco responsive su mobile (portrait) Le liste principali usano una sola "politica" CSS invece di layout duplicati: la classe `table-cards` (in `resources/css/app.css`) sotto `640px` nasconde il `thead` e trasforma ogni riga in una scheda, con ogni cella resa come "ETICHETTA … valore" (l'etichetta viene dall'attributo `data-label` della ``). Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha problemi"). Convenzioni: ogni `` dati ha `data-label="..."`; la cella azioni ha classe `cell-actions` (niente label, occupa la riga a piena larghezza); le celle `colspan` (stato vuoto, form di modifica utente inline) restano `block` a piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori, proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle "storico assegnazioni" di territorio-show/proclamatore-show, "territori conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange (una a classi Tailwind, una a stili inline — `border:none !important` nella regola gestisce anche i bordi inline). La pagina *Assegnazioni* era già ottimizzata. ### Scadenza link PDF unificata con la soglia rientro La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade esattamente quando l'assegnazione raggiunge la **soglia rientro** (`giorni_per_smarrito`), cioè `assigned_at + giorni_per_smarrito` — la stessa data di `Assegnazione::data_consegna_presunta` e del chip "Scadenza" nel viewer. `AssignmentPdfController::linkScaduto` e `ShortPdfLinkController` calcolano ora `addDays(giorni_per_smarrito)` invece di `addMonths(ttl)`; la scadenza vale solo per i non autenticati e il link muore comunque al rientro (`is_aperta`). Rimossi il campo Impostazioni e la proprietà `assignment_link_ttl_months` in `SettingsEdit`. **La colonna `assignment_link_ttl_hours` resta nel DB** (niente migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML continua a leggerla/scriverla senza effetti. ### Ricerca rapida nella lista link (pagina Assegna) La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per numero territorio, zona e proclamatore. Il filtro è **in memoria** (proprietà `linkSearch`, collection `assegnazioniAperteVisibili`) perché nome/cognome del proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun risultato"), perché l'`@if` esterno guarda la lista non filtrata. ### Assegnare un territorio ne azzera il flag "prioritario" `prioritario` è un flag **manuale**; `is_prioritario` è true se il flag è acceso *oppure* se la giacenza supera la soglia — ma la parte a soglia vale solo per i territori *in reparto* (`scopePrioritari`/`getIsPrioritarioAttribute` filtrano su `inReparto`). Quindi un territorio assegnato può risultare prioritario **solo** per via del flag manuale. Regola: alla creazione di un'assegnazione **aperta** il flag viene spento. È implementata come evento `Assegnazione::created` (in `booted()`), guardato su `returned_at === null` e `prioritario === true`, così copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e quale. ### Profilo utente, viewer PDF, privacy admin-only Tre ritocchi minori ma con qualche implicazione: - **Pagina profilo** (`Profile` → rotta `profilo`, nome `profile`) accessibile a ogni utente loggato (nel gruppo `auth`, nessun permesso): l'utente corrente può modificare il proprio nome/cognome ed email (`updateProfile`) e cambiare la password (`updatePassword`, con verifica della password attuale via regola `current_password`; il cast `hashed` cifra al salvataggio). Dopo il salvataggio dei dati `updateProfile` fa `redirect(navigate:true)` sulla stessa pagina, così header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori. Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar (`lg:hidden`, dopo un separatore) — sull'header è `hidden sm:flex`. Non esiste un campo "username" né "cognome" separato: `name` è il nome completo (etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica (ruolo) non è modificabile dal profilo — resta in gestione all'admin. - **Viewer PDF pubblico**: oltre a territorio/proclamatore/data mostra ora *giorni assegnato*, *consegna prevista* e *scadenza*. Sono attributi calcolati su `Assegnazione` (`data_consegna_presunta`, `giorni_alla_scadenza`) basati sulla soglia `giorni_per_smarrito` — la stessa oltre cui `Territorio::stato` diventa `da_rientrare`. Il viewer è servito solo per assegnazioni aperte, ma gli accessor gestiscono comunque il caso `returned_at` (ritornano null). - **Privacy solo admin**: la rotta `privacy` è passata dal solo `auth` al gruppo `permission:settings.manage` e il link in sidebar è sotto `@can('settings.manage')`. ### Backup completo `.zip`, ripristino, azzeramento L'export XML da solo non ricostruisce un'installazione: `pdf_path`, `thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un ripristino su volumi vuoti lascia ogni territorio che punta a un file inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip` (`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…` con **tutti** i file del disco `public`. L'export XML resta, etichettato come parziale. Scelte non ovvie: - **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo `settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero per la memoria del processo. - **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`, `ResetApplication`) che scrivono su `Services/BackupOperationState`, e `Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda perché un'installazione con qualche centinaio di PDF supera i 300 secondi di `fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà compressione, senza modo di distinguerlo da un errore. La barra conta unità di lavoro completate, non tempo. Per la compressione questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama `close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è `totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono cicli nostri, lì l'avanzamento è per-file senza artifici. - **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in `attesa-download`; la cancellazione parte da un secondo clic esplicito (`confermaAzzeramento`), che rifiuta se lo stato non registra un `servito_at` e se non riceve la password dell'amministratore. Il primo tentativo faceva partire il reset dalla coda dello `streamDownload`, "dopo l'ultimo byte" — **non funziona**: con `fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi prosegue anche se il browser ha chiuso la connessione al primo byte. Un download annullato azzerava lo stesso. Il server può verificare di aver *spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore, e per questo la conferma è umana. - **La password si richiede al secondo clic, e non passa dallo snapshot.** La sessione è già autenticata, quindi la password non serve a stabilire *chi* sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona — una scheda lasciata aperta su una postazione condivisa è lo scenario contro cui protegge. Viaggia come **parametro** di `confermaAzzeramento($password)`, non come proprietà `wire:model`: le proprietà pubbliche di Livewire vengono serializzate nello snapshot e rispedite al browser a ogni risposta, e una password non ha motivo di fare quel viaggio di ritorno. Attenzione al dettaglio che costa un pomeriggio: la proprietà `$passwordConferma` **esiste comunque, e resta sempre vuota**. Livewire scarta dalla risposta ogni errore la cui chiave non corrisponda a una proprietà del componente (`SupportValidation::dehydrate()` filtra su `Utils::hasProperty`), quindi senza quella riga `addError('passwordConferma', …)` sparisce in silenzio: il pulsante sembra non fare nulla, mentre il controllo sta funzionando. Non collegarla al campo con `wire:model`, o la password torna nello snapshot. Cinque tentativi sbagliati in 5 minuti bloccano l'azione (`RateLimiter`, chiave per utente). Senza, la richiesta di password su una sessione dirottata sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account: nessuno resta chiuso fuori dall'applicazione. - **Una sola operazione alla volta, con blocco che si autoripara.** Backup, ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso disco: due in parallelo — due schede, due amministratori — non si rallentano soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto unico; lo controllano sia il componente Livewire sia `BackupController::upload()`, che sono due porte d'ingresso separate (la seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e `corrente()` lo considera libero se l'operazione puntata non esiste più o è terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il worker uccide il job per timeout o errore fatale, cioè proprio i casi che `handle()` non può intercettare — che chiude l'operazione e libera il posto invece di lasciare una barra ferma per un'ora. - **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()` legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di conferma diventava irraggiungibile: nessuno poteva più né confermarlo né annullarlo, e il posto restava occupato fino alla scadenza. - **L'esito del ripristino si legge senza consumarlo.** Il job non ha una sessione in cui lasciare un flash, quindi deposita il messaggio in cache a nome di chi ha avviato il ripristino e la schermata di cambio password lo mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un `pull`: quando è il middleware a dirottare una richiesta di Livewire, il client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio — per poi rifare la stessa GET con `window.location`, che troverebbe la cache già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del proprio ripristino. - **Il tetto degli upload è una variabile sola, `MAX_UPLOAD_MB`.** Prima viveva in tre posti scollegati — `client_max_body_size` in `docker/nginx/default.conf`, `upload_max_filesize`/`post_max_size` in `docker/php/php.ini`, e la regola `max:` nella validazione — e bastava che uno restasse indietro perché il più piccolo vincesse nel modo peggiore. Adesso `entrypoint.sh` genera i primi due (`conf.d/upload-limit.conf` e `conf.d/zz-upload-limit.ini`) e `config/app.php` alimenta la terza. **Non rimettere `client_max_body_size` nel blocco `server` di `default.conf`**: una direttiva lì dentro vince su quella generata a livello http e reintroduce in silenzio proprio il disallineamento che questa disposizione esiste per evitare. La validazione tiene un 2% di margine sotto il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che sa spiegarsi, e non nginx. Il sintomo, quando il limite viene superato, non assomiglia affatto a un errore di dimensione: nginx risponde **413 dopo aver accettato zero byte** e chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una percentuale bassa e nessun evento utile arriva all'`XMLHttpRequest` — pagina muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista: controllo della dimensione *prima* di caricare, `xhr.timeout` con i relativi gestori di `timeout`/`abort`/`error` (senza, un caricamento fermo non produce più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419 non hanno un corpo JSON da cui ricavare una spiegazione. - **La console di log non è un vezzo diagnostico.** Quasi tutti i modi in cui un caricamento muore sono muti: Cloudflare o nginx chiudono la connessione e nessun evento del browser porta con sé una spiegazione. Il pannello *Console* nella scheda di ripristino registra file scelto e dimensione, limite del server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e interruzioni; sotto, i log lato server dell'operazione. Un battito ogni 15 secondi scrive «nessun avanzamento da N s», che è l'unico modo di distinguere *lento* da *fermo*. Il pulsante *Copia tutto* esiste perché il primo passo di ogni diagnosi è farsi mandare quel testo. - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il disco è l'unico passo che può fallire per cause esterne (permessi sul volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un errore sui file lascerebbe database azzerato e disco pieno di PDF orfani. Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale. Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un processo con un altro utente (l'entrypoint lavora da root, php-fpm e il worker da www-data) perché l'operazione si dichiari riuscita lasciando il disco popolato. - **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di cui non ci si accorge che era rotto finché non serve, quindi `BackupArchive::create()` non si fida di aver scritto bene: controlla il valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se il file è sparito o è illeggibile — l'archivio finirebbe con un buco silenzioso), e alla fine riapre l'archivio e verifica che contenga `data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun backup che un backup che sembra buono. Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or uninitialized Zip object`. Una chiusura di cortesia dentro un `catch` sostituiva il messaggio vero con quello, e l'amministratore leggeva una stringa incomprensibile al posto di «permesso negato su questo file». Da qui `chiudi()`, che cattura `getStatusString()` prima che vada perso, e i `close()` di pulizia avvolti in un try/catch che non può mascherare l'errore originale. - **Eliminare definitivamente un territorio porta via anche i suoi file.** `TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono *prima* della riga, così se la cancellazione fallisce il territorio resta nel cestino e l'operazione si può ritentare. - **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione appena creata ha un amministratore; cancellarli tutti lascerebbe l'applicazione senza nessuno che possa accedere fino al riavvio del container (l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non fa scattare. Il registro attività si svuota **per ultimo** — quelle cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che sopravvive, quella dell'azzeramento stesso. - **Ordine del restore: staging → database → file.** I file si estraggono prima in `storage/app/backup-restore/`, così un archivio corrotto o fuori limite fallisce mentre i dati correnti sono ancora intatti; poi `importXmlFromContent()` in transazione; poi la sostituzione dei file. Il disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna schermata mostra e nessuna cancellazione raggiunge. - **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su numero di entry, dimensione della singola entry e totale decompresso, pochi MB possono riempire il volume (zip bomb). I nomi delle entry sono normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) — senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione. - **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il restore accende `password_change_required` su **tutte** le righe di `users` (amministratore che sta ripristinando compreso) e azzera i `remember_token`, che sono credenziali persistenti e stanno pure loro nel backup. Il middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta su `password.change` tutto tranne quella pagina, il suo invio e il logout. - **La schermata di cambio password non è Livewire e non chiede la password attuale.** Non è Livewire perché deve restare raggiungibile mentre il middleware respinge anche le POST di Livewire, e distinguere quale componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si escludono per nome. Non chiede la password attuale perché il restore ha appena sovrascritto gli hash con quelli del backup: la "password attuale" di un account è quella dell'epoca del backup, che l'utente — l'amministratore per primo — può benissimo non ricordare. La garanzia è la sessione: a quella pagina ci si arriva già autenticati. ### Export/Import XML: backup completo e round-trip `Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali, non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`), i campi settings prima omessi (`public_base_url`, `logo_path`, `pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete (`deleted_at`) di proclamatori/territori, i path file dei territori (`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni (`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`, `pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità. L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così un export si reimporta senza perdite. Tre scelte non ovvie: - **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import **perdeva territori e assegnazioni** (riferimenti non risolti). Ora `addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale. - **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users` durante un restore rischierebbe di **estromettere l'amministratore** che sta importando (backup che precede il suo account, o file parziale). Gli account esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è senza perdite e non blocca fuori nessuno. - **Hash password e timestamp scritti col query builder.** Dopo il `save()` del modello, password/`remember_token`/`email_verified_at`/`created_at`/ `updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità. ### La pagina "Backup e import": cinque zone e una sola console La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato, la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a tre regole. **Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`, `2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test verifica che le cinque ancore esistano *e che siano in quell'ordine nel sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico. **Il "focus" della pagina.** Premuto un pulsante — o scelta una voce dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare (`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona è marcato `data-zona-focus` ed è quello che lo riceve. **Una sola console, agganciata in fondo alla finestra.** Prima la stessa operazione si raccontava in tre punti diversi: un ripristino cominciava nel riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio" richiudibile nel pannello di avanzamento, in cima. Per capire dove si era fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è interrotto il flusso di righe — un log che esce dallo schermo appena si scorre non serve proprio nel momento in cui servirebbe. Dettagli non ovvi della console: - **Le righe del server viaggiano già scremate.** Il conto di quante ne sono state spedite lo tiene il componente Livewire (`logSpediti`), non il browser: la coda intera dentro ogni risposta significherebbe sessanta righe poco meno di due volte al secondo, e per l'import PDF — la cui coda non ha tetto — l'intera cronologia a ogni poll. - **Per ogni operazione c'è un solo componente che la racconta** (`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi le stesse righe, e la console le mostrerebbe due volte. - **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero `window.location.href` due volte, e il pacchetto verrebbe scaricato due volte. L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo: il segno "download già inviato" sta nello stato condiviso, quindi chi segue l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca la cancellazione. - **Lo scorrimento automatico si sospende se si torna indietro a leggere.** Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è il modo più rapido di rendere inutile un log che scorre. Compare invece "Torna in fondo". - **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa. - **Il JavaScript della console sta in un `