Files
download-dati-centraline/CLAUDE.md
T

13 KiB

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}).