# CLAUDE.md — Portale Centraline 37100lab ## Lingua Tutto il progetto deve essere in **italiano**: commenti nel codice, messaggi all'utente, testo dell'interfaccia, documentazione interna, messaggi di errore, label dei pulsanti, intestazioni CSV. Fanno eccezione — e restano in inglese — i nomi di variabili, funzioni, componenti, file, classi CSS, e qualsiasi termine tecnico consolidato (es. `fetch`, `endpoint`, `build`, `download`, `timestamp`). --- ## Panoramica Portale web per visualizzare le centraline di monitoraggio ambientale di 37100lab e scaricare i dati storici come archivio ZIP contenente CSV. **Stack**: SvelteKit + Vite + TypeScript. La lista centraline viene caricata lato server al page load (`+page.server.ts`); le richieste di misurazioni vengono instradate attraverso una route API interna (`/api/get_station_data`) che funge da proxy verso il backend esterno, aggiungendo sanitizzazione e cache HTTP. **Ambiente di sviluppo**: Nix flake + Bun. Entrare nell'ambiente con `nix develop`, poi usare i comandi `bun run dev`, `bun run build`, `bun run preview`. --- ## Struttura del progetto ``` download-dati-centraline/ ├── CLAUDE.md ├── package.json ├── svelte.config.js ├── vite.config.ts ├── tsconfig.json ├── flake.nix ├── static/ │ └── favicon.svg └── src/ ├── app.css # variabili CSS globali, font, stili base ├── app.html # template HTML (carica Poppins da Google Fonts) ├── app.d.ts ├── lib/ │ ├── types.ts # interfacce Station e Measurement │ ├── api.ts # fetchStations, fetchDay │ ├── timezone.ts # formatTimestampRome, formatDayParam, getDayRange │ ├── csv.ts # buildCsv, downloadBlob, buildDayFilename, buildZipFilename │ ├── download-pool.ts # coda di download con concorrenza limitata │ ├── groups.json # raggruppamento centraline per area geografica │ └── components/ │ ├── StationList.svelte │ └── StationRow.svelte └── routes/ ├── +layout.svelte # importa app.css ├── +page.svelte # pagina principale ├── +page.server.ts # carica la lista centraline lato server └── api/ └── get_station_data/ └── +server.ts # proxy verso il backend esterno ``` --- ## API esterna **Base URL**: `http://37100lab.it:8101` Il frontend non chiama mai direttamente il backend esterno per le misurazioni: usa la route interna `/api/get_station_data` che fa da proxy (vedi sotto). Solo `fetchStations` chiama direttamente il backend (dalla funzione `load` di SvelteKit, lato server). ### GET `/api/campagne_map_data` — lista centraline Risposta: GeoJSON. Le centraline si trovano in `.features[].properties`. Campi rilevanti di ogni `.properties`: | Campo | Tipo | Note | |--------------|---------------|-----------------------------------------| | `title` | string | Nome della centralina | | `pk` | string | ID numerico, arriva come stringa | | `created_at` | ISO 8601 UTC | Data/ora di creazione | | `ended_at` | string o null | Data/ora di fine; null se ancora attiva | Il campo `pk` va convertito a numero intero. ### GET `/api/get_measurements/{id}?day=YYYY-M-D` — misurazioni giornaliere - `{id}`: ID numerico della centralina - `day`: data in **`Europe/Rome`**, formato **`YYYY-M-D` senza zero-padding** - ✅ `2026-5-6` - ❌ `2026-05-06` La risposta contiene una chiave `misure` con un array di oggetti, oppure un array vuoto. Il backend può emettere `NaN`/`Infinity` come valori JSON non standard — il proxy li sostituisce con `null` prima di parsare. | Campo | Tipo | Descrizione | |--------|---------|-----------------------| | `temp` | number | Temperatura (°C) | | `umid` | number | Umidità relativa (%) | | `pm10` | number | PM10 (µg/m³) | | `time` | ISO UTC | Timestamp misurazione | --- ## Route API interna: `/api/get_station_data` `src/routes/api/get_station_data/+server.ts` Parametri query: `id` (numero) e `date` (formato `YYYY-MM-DD` con zero-padding). Comportamento: 1. Converte `date` in `YYYY-M-D` senza zero-padding prima di chiamare il backend. 2. Sostituisce `NaN`/`Infinity`/`-inf` con `null` nella risposta grezza. 3. Filtra le misurazioni con `time` non valido o oggetti `null`. 4. Aggiunge header `Cache-Control`: **1 settimana** per giorni precedenti a ieri, **60 secondi** per ieri e oggi. In `dev`, usa `no-store`. Il client chiama questa route passando `date` in formato `YYYY-MM-DD`; la route si occupa della conversione al formato del backend. --- ## Formato archivio ZIP Il download produce un file `.zip` contenente: - **Un CSV per ogni giorno** con misurazioni, senza colonna Data: ``` "Ora","Temperatura","Umidità","PM10" "22:00:53",13.45,89.22,16.3 ``` Nome file: `YYYY-MM-DD.csv` - **Un CSV combinato** con tutte le misurazioni, con colonna Data: ``` "Data","Ora","Temperatura","Umidità","PM10" "05/05/2026","22:00:53",13.45,89.22,16.3 ``` Nome file: `{NomeCentralina}_(id).csv` Nome del file ZIP: `{NomeCentralina}_(id).zip` Esempio: `FabLab_Mantova_(178).zip` Sanitizzazione: spazi → `_`, caratteri non alfanumerici (eccetto `_`) rimossi. ### Caratteristiche CSV | Caratteristica | Valore | |-------------------|--------------------------------------------------------------| | Encoding | **UTF-8 con BOM** (``) — necessario per Excel italiano | | Separatore | virgola | | Intestazioni | tra doppi apici, prima riga | | Colonna Data | stringa tra doppi apici, formato `DD/MM/YYYY` | | Colonna Ora | stringa tra doppi apici, formato `HH:MM:SS` (senza decimali) | | Colonne numeriche | senza apici, valori originali (non arrotondare) | | Timestamp | convertito da UTC a **Europe/Rome** (gestisce ora legale) | Libreria di compressione: `jszip` (unica dipendenza runtime). --- ## Moduli ### `lib/types.ts` Interfacce TypeScript condivise: `Station` e `Measurement`. ### `lib/api.ts` - **`fetchStations(fetch?)`**: carica la lista centraline dal backend esterno. Accetta il `fetch` di SvelteKit per il SSR. Lancia eccezione in caso di errore (gestita da `+page.server.ts`). Ordina per `pk` decrescente. - **`fetchDay(id, day, signal)`**: scarica le misurazioni di un giorno chiamando la route interna `/api/get_station_data?id=…&date=YYYY-MM-DD`. Accetta un `AbortSignal`; ritenta fino a 3 volte in caso di errore; restituisce sempre `[]` in caso di errore o abort (non lancia mai eccezioni). ### `lib/timezone.ts` Gestisce date e timezone senza librerie esterne. - **`formatTimestampRome(isoString)`**: converte un timestamp ISO UTC nel formato `DD/MM/YYYY HH:MM:SS.s`. Usa un offset cache (per ora UTC) per evitare chiamate ripetute a `Intl.DateTimeFormat`. Il decimale dei secondi è troncato (non arrotondato): `706ms → .7`. - **`formatDayParam(date)`**: restituisce la data locale Europe/Rome nel formato `YYYY-M-D` senza zero-padding, tramite `Intl.DateTimeFormat` con `formatToParts`. - **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di `Date`, uno per ogni giorno dal giorno di `startIsoUtc` al giorno di `endIsoUtc` (estremi inclusi) in `Europe/Rome`. Se `endIsoUtc` è `null`, il limite superiore è oggi. Usa **mezzogiorno UTC** (`T12:00:00Z`) come ancora giornaliera per non attraversare mai un cambio di data locale. ### `lib/csv.ts` - **`buildCsv(measurements, includeDate?)`**: costruisce la stringa CSV con BOM. Se `includeDate` è `true` (default), include la colonna "Data"; altrimenti solo "Ora". L'ora è troncata ai secondi (niente decimali). - **`downloadBlob(blob, filename)`**: crea un URL oggetto, simula il click su un link ``, poi revoca l'URL. - **`buildDayFilename(date)`**: restituisce `YYYY-MM-DD.csv` in `Europe/Rome`. - **`buildZipFilename(title, id)`**: sanitizza il nome e restituisce `{NomeSanitizzato}_(id).zip`. ### `lib/download-pool.ts` Pool di concorrenza per le richieste HTTP di download. Costante `DOWNLOAD_WORKERS = 3` (richieste parallele massime). La funzione `enqueue(fn)` accoda un'operazione e restituisce una `Promise` che si risolve al termine. Gestisce internamente la coda FIFO e il contatore di worker attivi. ### `lib/groups.json` Array di oggetti `{ name, ids[] }` che mappa nomi di aree geografiche a set di ID centralina. Usato da `StationList` per raggruppare le card. Le centraline non presenti in nessun gruppo finiscono nella sezione "Altre". --- ## Componenti ### `routes/+page.server.ts` Funzione `load` di SvelteKit: chiama `fetchStations` lato server al page load. In caso di errore restituisce `{ stations: [], loadError: '…' }`. ### `routes/+page.svelte` Pagina principale. Riceve `data` da `+page.server.ts`. Mostra il messaggio di errore se presente, altrimenti renderizza `StationList`. Contiene header e footer. ### `StationList.svelte` Raggruppa le centraline in sezioni usando `groups.json`. All'interno di ogni sezione, ordina: attive prima delle terminate, poi per `created_at` decrescente. Le centraline non in nessun gruppo vanno nella sezione "Altre". Sezioni vuote vengono omesse. ### `StationRow.svelte` Card di una singola centralina. Contiene tutta la logica di download: 1. Calcola il range di giorni con `getDayRange` (limite superiore: `ended_at` o oggi per le attive). 2. Scarica tutti i giorni **in parallelo** tramite `enqueue` dal pool (max 3 richieste simultanee) con `Promise.all`. 3. La cancellazione avviene tramite `AbortController`: `controller.abort()` interrompe le fetch in corso; `fetchDay` intercetta `AbortError` e ritorna `[]`. 4. Tiene traccia del progresso con i contatori `currentDay` / `totalDays` (incrementati al completamento di ogni giorno). 5. Al completamento di tutte le fetch (non cancellate), genera lo ZIP con `jszip` e lo scarica. 6. Il pulsante di download mostra uno spinner durante il fetch; al hover diventa un'icona ✕ per annullare. Al completamento mostra un segno di spunta verde per 2 secondi. 7. La barra di progresso (3px, fondo card) si colora di verde al completamento e di rosso se annullata. 8. In `onDestroy`, chiama `controller?.abort()` per pulire fetch pendenti se il componente viene smontato. Il tooltip sulla data di fine centralina è implementato come Svelte action (`floatingTooltip`) che crea/rimuove un `div` nel `document.body` — necessario perché la card ha `overflow: hidden` e non può contenere il tooltip. --- ## Edge case e comportamenti attesi | Situazione | Comportamento | |-----------------------------------------------|----------------------------------------------------------------------------| | Giorno senza misurazioni (`misure: []`) | Ignorare, il giorno non contribuisce allo ZIP | | Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]` dopo 3 tentativi, ignorare e continuare | | Download annullato dall'utente | `AbortController` interrompe le fetch, nessun file scaricato | | Nessuna misurazione dopo tutti i giorni | Non generare lo ZIP, nessun download | | Centralina attiva (`ended_at: null`) | Range: da `created_at` a **oggi** in Europe/Rome | | Centralina terminata (`ended_at` valorizzato) | Range: da `created_at` a **`ended_at`** in Europe/Rome | | Nome centralina con caratteri speciali | Sanitizzare per il nome file: spazi → `_`, resto rimosso | | Errore nel caricamento della lista | `+page.svelte` mostra un messaggio di errore | | Backend emette `NaN`/`Infinity` | Il proxy li sostituisce con `null` con regex prima del parse | | Timestamp non valido nella risposta | `+server.ts` filtra le misurazioni con `time` non parsabile | --- ## Design Font: **Poppins** (Google Fonts, pesi 400/500/600/700). Palette (variabili CSS in `app.css`): | Variabile | Valore | Uso | |---------------|-----------|----------------------------------| | `--bg` | `#f0f2f5` | sfondo pagina | | `--surface` | `#ffffff` | sfondo card | | `--border` | `#dde1e7` | bordi | | `--text` | `#374151` | testo normale | | `--text-dim` | `#9ca3af` | testo secondario | | `--text-hi` | `#111827` | testo in evidenza | | `--accent` | `#2563eb` | blu (link, attivo, progresso) | Il layout usa card arrotondate (`border-radius: 14px`) al posto di una tabella. Le centraline attive sono in evidenza rispetto alle terminate. Il nome è un link alla pagina della centralina sul backend (`/campagna/{pk}`).