From 5ac470ba463c4fb6442f8f58fd6cb2930049beae Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Fri, 8 May 2026 17:40:26 +0200 Subject: [PATCH] Updated CLAUDE.md to reflect current codebase --- CLAUDE.md | 309 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 201 insertions(+), 108 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7efd102..dd39aaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,48 +14,67 @@ componenti, file, classi CSS, e qualsiasi termine tecnico consolidato (es. ## Panoramica -Portale web **statico** (solo frontend, nessun backend) per visualizzare le -centraline di monitoraggio ambientale di 37100lab e scaricare i dati storici in -formato CSV. +Portale web per visualizzare le centraline di monitoraggio ambientale di +37100lab e scaricare i dati storici come archivio ZIP contenente CSV. -**Stack**: Svelte + Vite. Output finale: cartella `dist/` da servire con -qualsiasi web server statico. +**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. ---- - -## Setup iniziale - -Usare il template Svelte ufficiale di Vite. I comandi di sviluppo standard sono -`dev`, `build` e `preview`. +**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 ``` -portale-centraline/ +download-dati-centraline/ ├── CLAUDE.md ├── package.json -├── vite.config.js -├── index.html +├── svelte.config.js +├── vite.config.ts +├── tsconfig.json +├── flake.nix +├── static/ +│ └── favicon.svg └── src/ - ├── App.svelte + ├── app.css # variabili CSS globali, font, stili base + ├── app.html # template HTML (carica Poppins da Google Fonts) + ├── app.d.ts ├── lib/ - │ ├── api.js # fetch verso l'API - │ ├── timezone.js # conversione UTC → Europe/Rome, iterazione giorni - │ └── csv.js # costruzione CSV e download - └── components/ - ├── StationList.svelte - ├── StationRow.svelte - └── DownloadProgress.svelte + │ ├── 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 +## 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`. @@ -69,8 +88,7 @@ Campi rilevanti di ogni `.properties`: | `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. Le centraline vanno ordinate per ID -decrescente. +Il campo `pk` va convertito a numero intero. ### GET `/api/get_measurements/{id}?day=YYYY-M-D` — misurazioni giornaliere @@ -79,8 +97,9 @@ decrescente. - ✅ `2026-5-6` - ❌ `2026-05-06` -La risposta contiene una chiave `misure` con un array di oggetti. Ogni oggetto -ha: +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 | |--------|---------|-----------------------| @@ -89,135 +108,209 @@ ha: | `pm10` | number | PM10 (µg/m³) | | `time` | ISO UTC | Timestamp misurazione | -`misure` può essere un array vuoto se il giorno non ha dati. +--- + +## 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 CSV +## Formato archivio ZIP -``` -"Data","Temperatura","Umidità","PM10" -"05/05/2026 22:00:53.7",13.45,89.22,16.3 -"05/05/2026 22:01:53.9",13.45,89.02,16.1 -"05/05/2026 22:02:54.1",13.47,88.88,15.5 -``` +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** (`\uFEFF`) — necessario per Excel italiano | +| 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 HH:MM:SS.s` | +| 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) | -**Nome file**: `{NomeCentralina}_({id})_{YYYY-MM-DD}.csv` Esempio: -`FabLab_Mantova_(178)_2026-05-07.csv` Sostituire spazi con `_`, rimuovere -caratteri non alfanumerici eccetto `_`. +Libreria di compressione: `jszip` (unica dipendenza runtime). --- ## Moduli -### `lib/api.js` +### `lib/types.ts` -Contiene due funzioni: una per caricare la lista delle centraline e una per -caricare le misurazioni di un singolo giorno. La seconda non deve mai lanciare -eccezioni — in caso di errore HTTP o di rete restituisce un array vuoto, così da -non interrompere il loop di download. +Interfacce TypeScript condivise: `Station` e `Measurement`. -### `lib/timezone.js` +### `lib/api.ts` -Gestisce tutto ciò che riguarda date e timezone. Non usare librerie esterne: -`Intl.DateTimeFormat` con `timeZone: 'Europe/Rome'` è sufficiente e gestisce -automaticamente il cambio ora legale (CET/CEST). +- **`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. -Contiene tre funzioni: +- **`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). -- **`formatTimestampRome(isoString)`**: converte un timestamp ISO UTC nella - stringa formattata per il CSV (`DD/MM/YYYY HH:MM:SS.s`). I millisecondi - vengono troncati al primo decimale con `Math.floor`, non arrotondati: `706ms → - .7`. I millisecondi sono indipendenti dal timezone e si leggono direttamente - dall'oggetto `Date`. +### `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, da usare come parametro `day` nelle chiamate - API. Usare `Intl.DateTimeFormat` con `month: 'numeric'` e `day: 'numeric'` in - `formatToParts` — in questo modo i valori non hanno zero-padding. + `YYYY-M-D` senza zero-padding, tramite `Intl.DateTimeFormat` con + `formatToParts`. -- **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di oggetti - `Date`, uno per ogni giorno dal giorno di `startIsoUtc` al giorno di - `endIsoUtc` (estremi inclusi), calcolati in `Europe/Rome`. Se `endIsoUtc` è - `null`, il limite superiore è oggi. Usare **mezzogiorno UTC** (`T12:00:00Z`) - come ancora per ogni cursore giornaliero: mezzogiorno UTC corrisponde alle - 13:00 o 14:00 a Roma, quindi non attraversa mai un cambio di data locale - neanche al cambio ora legale. +- **`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.js` +### `lib/csv.ts` -Contiene tre funzioni: una per costruire la stringa CSV da un array di -misurazioni (con BOM), una per triggerare il download nel browser, e una per -costruire il nome del file. +- **`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 -### `App.svelte` +### `routes/+page.server.ts` -Componente radice. Al montaggio carica la lista delle centraline e gestisce i -tre stati: caricamento, errore, lista pronta. +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` -Riceve l'array di centraline e le mostra in una tabella con colonne: ID, nome, -data di creazione (formattata in `Europe/Rome`), stato (attiva/terminata), -pulsante download. +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` -Gestisce la riga di una singola centralina e contiene tutta la logica di -download: +Card di una singola centralina. Contiene tutta la logica di download: -1. Calcola il range di giorni con `getDayRange`. Le centraline **attive** - (`ended_at: null`) usano oggi come limite superiore; le centraline - **terminate** (`ended_at` valorizzato) usano `ended_at` come limite - superiore. -2. Itera i giorni **sequenzialmente** (non in parallelo) per non sovraccaricare - l'API. -3. Per ogni giorno chiama `fetchDay` e accumula le misurazioni. -4. Aggiorna il contatore di progresso ad ogni giorno completato. -5. Supporta la cancellazione: controlla un flag `cancelled` ad ogni iterazione; - se attivo, interrompe il loop senza scaricare nulla. -6. Al termine, se ci sono misurazioni e il download non è stato annullato, - genera e scarica il CSV. +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. -### `DownloadProgress.svelte` - -Mostra il progresso durante il download: giorno corrente, totale giorni e -percentuale. Ha un pulsante "Annulla" che emette un evento verso `StationRow`. +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, continuare con il giorno successivo | -| Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]`, ignorare e continuare | -| Download annullato dall'utente | Il loop si interrompe al controllo del flag, nessun file scaricato | -| Nessuna misurazione dopo tutti i giorni | Non generare il CSV, 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 | `App.svelte` mostra un messaggio di errore all'utente | +| 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 -Il portale riguarda dati ambientali e scientifici raccolti sul campo da stazioni -fisiche. L'estetica deve riflettere questo contesto: strumentale, precisa, -leggibile. Scegliere una direzione stilistica netta — non generica. Evitare -palette pastello, font di sistema e layout cookie-cutter. Curare tipografia, -spaziatura e la gerarchia visiva della tabella. +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}`).