Files

317 lines
13 KiB
Markdown

# 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 `<a download>`, 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}`).