Compare commits
4 Commits
2246b57c2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02f23b391d | |||
| 5ac470ba46 | |||
| 267b174931 | |||
| 1af46c0fa3 |
@@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
container: docker.io/oven/bun:1-alpine
|
||||||
|
steps:
|
||||||
|
- name: Installa git
|
||||||
|
run: apk add --no-cache git
|
||||||
|
|
||||||
|
- name: Checkout del codice
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Installa dipendenze
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Deploy su Vercel
|
||||||
|
run: bunx vercel --prod --yes
|
||||||
|
env:
|
||||||
|
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
@@ -14,48 +14,67 @@ componenti, file, classi CSS, e qualsiasi termine tecnico consolidato (es.
|
|||||||
|
|
||||||
## Panoramica
|
## Panoramica
|
||||||
|
|
||||||
Portale web **statico** (solo frontend, nessun backend) per visualizzare le
|
Portale web per visualizzare le centraline di monitoraggio ambientale di
|
||||||
centraline di monitoraggio ambientale di 37100lab e scaricare i dati storici in
|
37100lab e scaricare i dati storici come archivio ZIP contenente CSV.
|
||||||
formato CSV.
|
|
||||||
|
|
||||||
**Stack**: Svelte + Vite. Output finale: cartella `dist/` da servire con
|
**Stack**: SvelteKit + Vite + TypeScript. La lista centraline viene caricata
|
||||||
qualsiasi web server statico.
|
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`,
|
||||||
## Setup iniziale
|
`bun run preview`.
|
||||||
|
|
||||||
Usare il template Svelte ufficiale di Vite. I comandi di sviluppo standard sono
|
|
||||||
`dev`, `build` e `preview`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Struttura del progetto
|
## Struttura del progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
portale-centraline/
|
download-dati-centraline/
|
||||||
├── CLAUDE.md
|
├── CLAUDE.md
|
||||||
├── package.json
|
├── package.json
|
||||||
├── vite.config.js
|
├── svelte.config.js
|
||||||
├── index.html
|
├── vite.config.ts
|
||||||
|
├── tsconfig.json
|
||||||
|
├── flake.nix
|
||||||
|
├── static/
|
||||||
|
│ └── favicon.svg
|
||||||
└── src/
|
└── src/
|
||||||
├── App.svelte
|
├── app.css # variabili CSS globali, font, stili base
|
||||||
|
├── app.html # template HTML (carica Poppins da Google Fonts)
|
||||||
|
├── app.d.ts
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── api.js # fetch verso l'API
|
│ ├── types.ts # interfacce Station e Measurement
|
||||||
│ ├── timezone.js # conversione UTC → Europe/Rome, iterazione giorni
|
│ ├── api.ts # fetchStations, fetchDay
|
||||||
│ └── csv.js # costruzione CSV e download
|
│ ├── timezone.ts # formatTimestampRome, formatDayParam, getDayRange
|
||||||
└── components/
|
│ ├── csv.ts # buildCsv, downloadBlob, buildDayFilename, buildZipFilename
|
||||||
├── StationList.svelte
|
│ ├── download-pool.ts # coda di download con concorrenza limitata
|
||||||
├── StationRow.svelte
|
│ ├── groups.json # raggruppamento centraline per area geografica
|
||||||
└── DownloadProgress.svelte
|
│ └── 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`
|
**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
|
### GET `/api/campagne_map_data` — lista centraline
|
||||||
|
|
||||||
Risposta: GeoJSON. Le centraline si trovano in `.features[].properties`.
|
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 |
|
| `created_at` | ISO 8601 UTC | Data/ora di creazione |
|
||||||
| `ended_at` | string o null | Data/ora di fine; null se ancora attiva |
|
| `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
|
Il campo `pk` va convertito a numero intero.
|
||||||
decrescente.
|
|
||||||
|
|
||||||
### GET `/api/get_measurements/{id}?day=YYYY-M-D` — misurazioni giornaliere
|
### GET `/api/get_measurements/{id}?day=YYYY-M-D` — misurazioni giornaliere
|
||||||
|
|
||||||
@@ -79,8 +97,9 @@ decrescente.
|
|||||||
- ✅ `2026-5-6`
|
- ✅ `2026-5-6`
|
||||||
- ❌ `2026-05-06`
|
- ❌ `2026-05-06`
|
||||||
|
|
||||||
La risposta contiene una chiave `misure` con un array di oggetti. Ogni oggetto
|
La risposta contiene una chiave `misure` con un array di oggetti, oppure un
|
||||||
ha:
|
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 |
|
| Campo | Tipo | Descrizione |
|
||||||
|--------|---------|-----------------------|
|
|--------|---------|-----------------------|
|
||||||
@@ -89,135 +108,209 @@ ha:
|
|||||||
| `pm10` | number | PM10 (µg/m³) |
|
| `pm10` | number | PM10 (µg/m³) |
|
||||||
| `time` | ISO UTC | Timestamp misurazione |
|
| `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
|
||||||
|
|
||||||
```
|
Il download produce un file `.zip` contenente:
|
||||||
"Data","Temperatura","Umidità","PM10"
|
|
||||||
"05/05/2026 22:00:53.7",13.45,89.22,16.3
|
- **Un CSV per ogni giorno** con misurazioni, senza colonna Data:
|
||||||
"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
|
"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 |
|
| Caratteristica | Valore |
|
||||||
|-------------------|--------------------------------------------------------------|
|
|-------------------|--------------------------------------------------------------|
|
||||||
| Encoding | **UTF-8 con BOM** (`\uFEFF`) — necessario per Excel italiano |
|
| Encoding | **UTF-8 con BOM** (``) — necessario per Excel italiano |
|
||||||
| Separatore | virgola |
|
| Separatore | virgola |
|
||||||
| Intestazioni | tra doppi apici, prima riga |
|
| 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) |
|
| Colonne numeriche | senza apici, valori originali (non arrotondare) |
|
||||||
| Timestamp | convertito da UTC a **Europe/Rome** (gestisce ora legale) |
|
| Timestamp | convertito da UTC a **Europe/Rome** (gestisce ora legale) |
|
||||||
|
|
||||||
**Nome file**: `{NomeCentralina}_({id})_{YYYY-MM-DD}.csv` Esempio:
|
Libreria di compressione: `jszip` (unica dipendenza runtime).
|
||||||
`FabLab_Mantova_(178)_2026-05-07.csv` Sostituire spazi con `_`, rimuovere
|
|
||||||
caratteri non alfanumerici eccetto `_`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Moduli
|
## Moduli
|
||||||
|
|
||||||
### `lib/api.js`
|
### `lib/types.ts`
|
||||||
|
|
||||||
Contiene due funzioni: una per caricare la lista delle centraline e una per
|
Interfacce TypeScript condivise: `Station` e `Measurement`.
|
||||||
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.
|
|
||||||
|
|
||||||
### `lib/timezone.js`
|
### `lib/api.ts`
|
||||||
|
|
||||||
Gestisce tutto ciò che riguarda date e timezone. Non usare librerie esterne:
|
- **`fetchStations(fetch?)`**: carica la lista centraline dal backend esterno.
|
||||||
`Intl.DateTimeFormat` con `timeZone: 'Europe/Rome'` è sufficiente e gestisce
|
Accetta il `fetch` di SvelteKit per il SSR. Lancia eccezione in caso di
|
||||||
automaticamente il cambio ora legale (CET/CEST).
|
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
|
### `lib/timezone.ts`
|
||||||
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 →
|
Gestisce date e timezone senza librerie esterne.
|
||||||
.7`. I millisecondi sono indipendenti dal timezone e si leggono direttamente
|
|
||||||
dall'oggetto `Date`.
|
- **`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
|
- **`formatDayParam(date)`**: restituisce la data locale Europe/Rome nel formato
|
||||||
`YYYY-M-D` senza zero-padding, da usare come parametro `day` nelle chiamate
|
`YYYY-M-D` senza zero-padding, tramite `Intl.DateTimeFormat` con
|
||||||
API. Usare `Intl.DateTimeFormat` con `month: 'numeric'` e `day: 'numeric'` in
|
`formatToParts`.
|
||||||
`formatToParts` — in questo modo i valori non hanno zero-padding.
|
|
||||||
|
|
||||||
- **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di oggetti
|
- **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di `Date`,
|
||||||
`Date`, uno per ogni giorno dal giorno di `startIsoUtc` al giorno di
|
uno per ogni giorno dal giorno di `startIsoUtc` al giorno di `endIsoUtc`
|
||||||
`endIsoUtc` (estremi inclusi), calcolati in `Europe/Rome`. Se `endIsoUtc` è
|
(estremi inclusi) in `Europe/Rome`. Se `endIsoUtc` è `null`, il limite
|
||||||
`null`, il limite superiore è oggi. Usare **mezzogiorno UTC** (`T12:00:00Z`)
|
superiore è oggi. Usa **mezzogiorno UTC** (`T12:00:00Z`) come ancora giornaliera
|
||||||
come ancora per ogni cursore giornaliero: mezzogiorno UTC corrisponde alle
|
per non attraversare mai un cambio di data locale.
|
||||||
13:00 o 14:00 a Roma, quindi non attraversa mai un cambio di data locale
|
|
||||||
neanche al cambio ora legale.
|
|
||||||
|
|
||||||
### `lib/csv.js`
|
### `lib/csv.ts`
|
||||||
|
|
||||||
Contiene tre funzioni: una per costruire la stringa CSV da un array di
|
- **`buildCsv(measurements, includeDate?)`**: costruisce la stringa CSV con BOM.
|
||||||
misurazioni (con BOM), una per triggerare il download nel browser, e una per
|
Se `includeDate` è `true` (default), include la colonna "Data"; altrimenti solo
|
||||||
costruire il nome del file.
|
"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
|
## Componenti
|
||||||
|
|
||||||
### `App.svelte`
|
### `routes/+page.server.ts`
|
||||||
|
|
||||||
Componente radice. Al montaggio carica la lista delle centraline e gestisce i
|
Funzione `load` di SvelteKit: chiama `fetchStations` lato server al page load.
|
||||||
tre stati: caricamento, errore, lista pronta.
|
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`
|
### `StationList.svelte`
|
||||||
|
|
||||||
Riceve l'array di centraline e le mostra in una tabella con colonne: ID, nome,
|
Raggruppa le centraline in sezioni usando `groups.json`. All'interno di ogni
|
||||||
data di creazione (formattata in `Europe/Rome`), stato (attiva/terminata),
|
sezione, ordina: attive prima delle terminate, poi per `created_at` decrescente.
|
||||||
pulsante download.
|
Le centraline non in nessun gruppo vanno nella sezione "Altre". Sezioni vuote
|
||||||
|
vengono omesse.
|
||||||
|
|
||||||
### `StationRow.svelte`
|
### `StationRow.svelte`
|
||||||
|
|
||||||
Gestisce la riga di una singola centralina e contiene tutta la logica di
|
Card di una singola centralina. Contiene tutta la logica di download:
|
||||||
download:
|
|
||||||
|
|
||||||
1. Calcola il range di giorni con `getDayRange`. Le centraline **attive**
|
1. Calcola il range di giorni con `getDayRange` (limite superiore: `ended_at` o
|
||||||
(`ended_at: null`) usano oggi come limite superiore; le centraline
|
oggi per le attive).
|
||||||
**terminate** (`ended_at` valorizzato) usano `ended_at` come limite
|
2. Scarica tutti i giorni **in parallelo** tramite `enqueue` dal pool
|
||||||
superiore.
|
(max 3 richieste simultanee) con `Promise.all`.
|
||||||
2. Itera i giorni **sequenzialmente** (non in parallelo) per non sovraccaricare
|
3. La cancellazione avviene tramite `AbortController`: `controller.abort()`
|
||||||
l'API.
|
interrompe le fetch in corso; `fetchDay` intercetta `AbortError` e ritorna `[]`.
|
||||||
3. Per ogni giorno chiama `fetchDay` e accumula le misurazioni.
|
4. Tiene traccia del progresso con i contatori `currentDay` / `totalDays`
|
||||||
4. Aggiorna il contatore di progresso ad ogni giorno completato.
|
(incrementati al completamento di ogni giorno).
|
||||||
5. Supporta la cancellazione: controlla un flag `cancelled` ad ogni iterazione;
|
5. Al completamento di tutte le fetch (non cancellate), genera lo ZIP con `jszip`
|
||||||
se attivo, interrompe il loop senza scaricare nulla.
|
e lo scarica.
|
||||||
6. Al termine, se ci sono misurazioni e il download non è stato annullato,
|
6. Il pulsante di download mostra uno spinner durante il fetch; al hover diventa
|
||||||
genera e scarica il CSV.
|
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`
|
Il tooltip sulla data di fine centralina è implementato come Svelte action
|
||||||
|
(`floatingTooltip`) che crea/rimuove un `div` nel `document.body` — necessario
|
||||||
Mostra il progresso durante il download: giorno corrente, totale giorni e
|
perché la card ha `overflow: hidden` e non può contenere il tooltip.
|
||||||
percentuale. Ha un pulsante "Annulla" che emette un evento verso `StationRow`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Edge case e comportamenti attesi
|
## Edge case e comportamenti attesi
|
||||||
|
|
||||||
| Situazione | Comportamento |
|
| Situazione | Comportamento |
|
||||||
|-----------------------------------------------|-----------------------------------------------------------------------|
|
|-----------------------------------------------|----------------------------------------------------------------------------|
|
||||||
| Giorno senza misurazioni (`misure: []`) | Ignorare, continuare con il giorno successivo |
|
| Giorno senza misurazioni (`misure: []`) | Ignorare, il giorno non contribuisce allo ZIP |
|
||||||
| Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]`, ignorare e continuare |
|
| Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]` dopo 3 tentativi, ignorare e continuare |
|
||||||
| Download annullato dall'utente | Il loop si interrompe al controllo del flag, nessun file scaricato |
|
| Download annullato dall'utente | `AbortController` interrompe le fetch, nessun file scaricato |
|
||||||
| Nessuna misurazione dopo tutti i giorni | Non generare il CSV, nessun download |
|
| 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 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Design
|
||||||
|
|
||||||
Il portale riguarda dati ambientali e scientifici raccolti sul campo da stazioni
|
Font: **Poppins** (Google Fonts, pesi 400/500/600/700).
|
||||||
fisiche. L'estetica deve riflettere questo contesto: strumentale, precisa,
|
|
||||||
leggibile. Scegliere una direzione stilistica netta — non generica. Evitare
|
Palette (variabili CSS in `app.css`):
|
||||||
palette pastello, font di sistema e layout cookie-cutter. Curare tipografia,
|
|
||||||
spaziatura e la gerarchia visiva della tabella.
|
| 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}`).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@types/node": "^25.6.2",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
@@ -89,6 +90,8 @@
|
|||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
@@ -211,6 +214,8 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@8.0.11", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
"vite": ["vite@8.0.11", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@types/node": "^25.6.2",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
|
|||||||
+29
@@ -27,3 +27,32 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip-floating {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.45em 0.9em;
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-floating::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: var(--border);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
export async function fetchStations(fetch = globalThis.fetch) {
|
|
||||||
const res = await fetch('http://37100lab.it:8101/api/campagne_map_data');
|
|
||||||
if (!res.ok) throw new Error(`Errore HTTP ${res.status}`);
|
|
||||||
const geojson = await res.json();
|
|
||||||
return geojson.features
|
|
||||||
.map(f => ({ ...f.properties, pk: parseInt(f.properties.pk, 10) }))
|
|
||||||
.sort((a, b) => b.pk - a.pk);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDay(id, day, signal) {
|
|
||||||
// Converte YYYY-M-D (da formatDayParam) in YYYY-MM-DD per la chiamata API
|
|
||||||
const [y, m, d] = day.split('-');
|
|
||||||
const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
|
||||||
|
|
||||||
// L'API risponde con 5xx transienti: tre tentativi limitano i buchi nel dataset scaricato.
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/get_station_data?id=${id}&date=${date}`, { signal });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return await res.json();
|
|
||||||
} catch (e) {
|
|
||||||
// La cancellazione non è un errore: uscire subito senza consumare altri retry.
|
|
||||||
if (e.name === 'AbortError') return [];
|
|
||||||
if (attempt === 2) return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Station, Measurement } from './types.js';
|
||||||
|
|
||||||
|
export async function fetchStations(fetch = globalThis.fetch): Promise<Station[]> {
|
||||||
|
const res = await fetch('http://37100lab.it:8101/api/campagne_map_data');
|
||||||
|
if (!res.ok) throw new Error(`Errore HTTP ${res.status}`);
|
||||||
|
const geojson = await res.json();
|
||||||
|
return (geojson.features as Array<{ properties: Record<string, unknown> }>)
|
||||||
|
.map(f => ({
|
||||||
|
pk: parseInt(f.properties.pk as string, 10),
|
||||||
|
title: f.properties.title as string,
|
||||||
|
created_at: f.properties.created_at as string,
|
||||||
|
ended_at: f.properties.ended_at as string | null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.pk - a.pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDay(id: number, day: string, signal: AbortSignal): Promise<Measurement[]> {
|
||||||
|
const [y, m, d] = day.split('-');
|
||||||
|
const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/get_station_data?id=${id}&date=${date}`, { signal });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json() as Measurement[];
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') return [];
|
||||||
|
if (attempt === 2) return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import StationRow from './StationRow.svelte';
|
import StationRow from './StationRow.svelte';
|
||||||
import groups from '$lib/groups.json';
|
import groups from '$lib/groups.json';
|
||||||
|
import type { Station } from '$lib/types.js';
|
||||||
|
|
||||||
let { stations = [] } = $props();
|
let { stations = [] }: { stations: Station[] } = $props();
|
||||||
|
|
||||||
// Set per lookup O(1): con molte centraline, includes() su array per ogni riga sarebbe O(n²).
|
// Set per lookup O(1): con molte centraline, includes() su array per ogni riga sarebbe O(n²).
|
||||||
const groupedIds = new Set(groups.flatMap(g => g.ids));
|
const groupedIds = new Set(groups.flatMap(g => g.ids));
|
||||||
|
|
||||||
// Le attive appaiono prima delle terminate per dare prominenza alle stazioni in funzione.
|
// Le attive appaiono prima delle terminate per dare prominenza alle stazioni in funzione.
|
||||||
function sortStations(items) {
|
function sortStations(items: Station[]): Station[] {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
if (a.ended_at === null && b.ended_at !== null) return -1;
|
if (a.ended_at === null && b.ended_at !== null) return -1;
|
||||||
if (a.ended_at !== null && b.ended_at === null) return 1;
|
if (a.ended_at !== null && b.ended_at === null) return 1;
|
||||||
return new Date(b.created_at) - new Date(a.created_at);
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,52 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getDayRange, formatDayParam } from '$lib/timezone.js';
|
import { getDayRange, formatDayParam } from '$lib/timezone.js';
|
||||||
import { fetchDay } from '$lib/api.js';
|
import { fetchDay } from '$lib/api.js';
|
||||||
import { buildCsv, downloadBlob, buildDayFilename, buildZipFilename } from '$lib/csv.js';
|
import { buildCsv, downloadBlob, buildDayFilename, buildZipFilename } from '$lib/csv.js';
|
||||||
import { enqueue } from '$lib/download-pool.js';
|
import { enqueue } from '$lib/download-pool.js';
|
||||||
|
import type { Station, Measurement } from '$lib/types.js';
|
||||||
|
|
||||||
let { station } = $props();
|
function floatingTooltip(node: HTMLElement, text: string) {
|
||||||
|
let el: HTMLDivElement | null = null;
|
||||||
|
|
||||||
const formatDate = d => new Intl.DateTimeFormat('it-IT', {
|
function show() {
|
||||||
|
if (!text) return;
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'tooltip-floating';
|
||||||
|
el.textContent = text;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.style.top = `${rect.top - el.offsetHeight - 10}px`;
|
||||||
|
el.style.left = `${rect.left + rect.width / 2 - el.offsetWidth / 2}px`;
|
||||||
|
void el.offsetHeight; // force reflow prima della transizione
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (!el) return;
|
||||||
|
const target = el;
|
||||||
|
el = null;
|
||||||
|
target.style.opacity = '0';
|
||||||
|
target.style.transform = 'translateY(4px)';
|
||||||
|
target.addEventListener('transitionend', () => target.remove(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', show);
|
||||||
|
node.addEventListener('mouseleave', hide);
|
||||||
|
return {
|
||||||
|
update(t: string) { text = t; },
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('mouseenter', show);
|
||||||
|
node.removeEventListener('mouseleave', hide);
|
||||||
|
hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { station }: { station: Station } = $props();
|
||||||
|
|
||||||
|
const formatDate = (d: string) => new Intl.DateTimeFormat('it-IT', {
|
||||||
timeZone: 'Europe/Rome',
|
timeZone: 'Europe/Rome',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -16,12 +55,12 @@
|
|||||||
|
|
||||||
const isActive = $derived(station.ended_at === null);
|
const isActive = $derived(station.ended_at === null);
|
||||||
const createdDate = $derived(formatDate(station.created_at));
|
const createdDate = $derived(formatDate(station.created_at));
|
||||||
const endDate = $derived(isActive ? null : formatDate(station.ended_at));
|
const endDate = $derived(isActive ? null : formatDate(station.ended_at!));
|
||||||
|
|
||||||
let downloading = $state(false);
|
let downloading = $state(false);
|
||||||
let cancelling = $state(false);
|
let cancelling = $state(false);
|
||||||
let completed = $state(false);
|
let completed = $state(false);
|
||||||
let controller = null;
|
let controller: AbortController | null = null;
|
||||||
let currentDay = $state(0);
|
let currentDay = $state(0);
|
||||||
let totalDays = $state(0);
|
let totalDays = $state(0);
|
||||||
|
|
||||||
@@ -37,7 +76,7 @@
|
|||||||
totalDays = days.length;
|
totalDays = days.length;
|
||||||
currentDay = 0;
|
currentDay = 0;
|
||||||
|
|
||||||
const resultsByDay = new Array(days.length);
|
const resultsByDay: Measurement[][] = new Array(days.length);
|
||||||
|
|
||||||
await Promise.all(days.map((day, idx) =>
|
await Promise.all(days.map((day, idx) =>
|
||||||
enqueue(async () => {
|
enqueue(async () => {
|
||||||
@@ -53,7 +92,7 @@
|
|||||||
|
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
let allMeasurements = [];
|
let allMeasurements: Measurement[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < days.length; i++) {
|
for (let i = 0; i < days.length; i++) {
|
||||||
const measurements = resultsByDay[i];
|
const measurements = resultsByDay[i];
|
||||||
@@ -98,12 +137,12 @@
|
|||||||
<div class="right">
|
<div class="right">
|
||||||
<span class="date">Attivata il {createdDate}</span>
|
<span class="date">Attivata il {createdDate}</span>
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
<span class="status" class:active={isActive} class:ended={!isActive}>
|
<span
|
||||||
{isActive ? 'Attiva' : 'Terminata'}
|
class="status"
|
||||||
{#if !isActive}
|
class:active={isActive}
|
||||||
<span class="tooltip">Terminata il {endDate}</span>
|
class:ended={!isActive}
|
||||||
{/if}
|
use:floatingTooltip={!isActive ? `Terminata il ${endDate}` : ''}
|
||||||
</span>
|
>{isActive ? 'Attiva' : 'Terminata'}</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -152,6 +191,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -271,47 +311,13 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.bar-wrap {
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 16px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 400;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0.45em 0.9em;
|
|
||||||
border-radius: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status:hover .tooltip {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: 6px solid transparent;
|
|
||||||
border-top-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-wrap {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 0 0 14px 14px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { Measurement } from './types.js';
|
||||||
import { formatTimestampRome, ROME } from './timezone.js';
|
import { formatTimestampRome, ROME } from './timezone.js';
|
||||||
|
|
||||||
export function buildCsv(measurements, includeDate = true) {
|
export function buildCsv(measurements: Measurement[], includeDate = true): string {
|
||||||
const header = includeDate
|
const header = includeDate
|
||||||
? '"Data","Ora","Temperatura","Umidità","PM10"'
|
? '"Data","Ora","Temperatura","Umidità","PM10"'
|
||||||
: '"Ora","Temperatura","Umidità","PM10"';
|
: '"Ora","Temperatura","Umidità","PM10"';
|
||||||
@@ -14,7 +15,7 @@ export function buildCsv(measurements, includeDate = true) {
|
|||||||
return '' + [header, ...rows].join('\n');
|
return '' + [header, ...rows].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadBlob(blob, filename) {
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -23,11 +24,11 @@ export function downloadBlob(blob, filename) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDayFilename(date) {
|
export function buildDayFilename(date: Date): string {
|
||||||
return new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date) + '.csv';
|
return new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date) + '.csv';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildZipFilename(title, id) {
|
export function buildZipFilename(title: string, id: number): string {
|
||||||
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
||||||
return `${sanitized}_(${id}).zip`;
|
return `${sanitized}_(${id}).zip`;
|
||||||
}
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// Limita le richieste HTTP simultanee per non sovraccaricare l'API del server.
|
|
||||||
export const DOWNLOAD_WORKERS = 3;
|
|
||||||
|
|
||||||
let active = 0;
|
|
||||||
const queue = [];
|
|
||||||
|
|
||||||
function next() {
|
|
||||||
while (active < DOWNLOAD_WORKERS && queue.length) {
|
|
||||||
active++;
|
|
||||||
const { fn, resolve } = queue.shift();
|
|
||||||
// Promise.resolve() garantisce che fn() sia trattato come asincrono anche se
|
|
||||||
// restituisse un valore sincrono, rendendo il finally sempre raggiungibile.
|
|
||||||
Promise.resolve(fn()).then(resolve).finally(() => {
|
|
||||||
active--;
|
|
||||||
// Rientrata ricorsiva: ogni job completato sveglia il prossimo in coda.
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enqueue(fn) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
queue.push({ fn, resolve });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const DOWNLOAD_WORKERS = 3;
|
||||||
|
|
||||||
|
type Job = { fn: () => Promise<void>; resolve: () => void };
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
const queue: Job[] = [];
|
||||||
|
|
||||||
|
function next(): void {
|
||||||
|
while (active < DOWNLOAD_WORKERS && queue.length) {
|
||||||
|
active++;
|
||||||
|
const { fn, resolve } = queue.shift()!;
|
||||||
|
Promise.resolve(fn()).then(resolve).finally(() => {
|
||||||
|
active--;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueue(fn: () => Promise<void>): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
queue.push({ fn, resolve });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
export const ROME = 'Europe/Rome';
|
export const ROME = 'Europe/Rome';
|
||||||
|
|
||||||
// Pre-calcolato una volta: viene chiamato per ogni riga del CSV, potenzialmente decine di migliaia di volte.
|
|
||||||
const PAD2 = Array.from({ length: 100 }, (_, i) => String(i).padStart(2, '0'));
|
const PAD2 = Array.from({ length: 100 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
|
||||||
// L'offset CET/CEST cambia solo due volte l'anno: cachare evita una chiamata Intl per ogni timestamp.
|
const offsetCache = new Map<string, number>();
|
||||||
const offsetCache = new Map();
|
|
||||||
|
|
||||||
function getRomeOffsetMs(date) {
|
function getRomeOffsetMs(date: Date): number {
|
||||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}-${date.getUTCHours()}`;
|
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}-${date.getUTCHours()}`;
|
||||||
if (offsetCache.has(key)) return offsetCache.get(key);
|
if (offsetCache.has(key)) return offsetCache.get(key)!;
|
||||||
|
|
||||||
const romeHour = parseInt(
|
const romeHour = parseInt(
|
||||||
new Intl.DateTimeFormat('en', { timeZone: ROME, hour: 'numeric', hour12: false }).format(date),
|
new Intl.DateTimeFormat('en', { timeZone: ROME, hour: 'numeric', hour12: false }).format(date),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
let diff = romeHour - date.getUTCHours();
|
let diff = romeHour - date.getUTCHours();
|
||||||
// Se diff supera ±12 le ore attraversano mezzanotte UTC: il salto va corretto.
|
|
||||||
if (diff > 12) diff -= 24;
|
if (diff > 12) diff -= 24;
|
||||||
if (diff < -12) diff += 24;
|
if (diff < -12) diff += 24;
|
||||||
const offsetMs = diff * 3_600_000;
|
const offsetMs = diff * 3_600_000;
|
||||||
@@ -23,11 +20,9 @@ function getRomeOffsetMs(date) {
|
|||||||
return offsetMs;
|
return offsetMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTimestampRome(isoString) {
|
export function formatTimestampRome(isoString: string): string {
|
||||||
const d = new Date(isoString);
|
const d = new Date(isoString);
|
||||||
const frac = Math.floor(d.getMilliseconds() / 100);
|
const frac = Math.floor(d.getMilliseconds() / 100);
|
||||||
// Shift manuale invece di toLocaleString: controllo totale sul formato,
|
|
||||||
// indipendente dall'implementazione Intl specifica del browser.
|
|
||||||
const r = new Date(d.getTime() + getRomeOffsetMs(d));
|
const r = new Date(d.getTime() + getRomeOffsetMs(d));
|
||||||
|
|
||||||
const dd = PAD2[r.getUTCDate()];
|
const dd = PAD2[r.getUTCDate()];
|
||||||
@@ -40,9 +35,7 @@ export function formatTimestampRome(isoString) {
|
|||||||
return `${dd}/${mo}/${yyyy} ${hh}:${mm}:${ss}.${frac}`;
|
return `${dd}/${mo}/${yyyy} ${hh}:${mm}:${ss}.${frac}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDayParam(date) {
|
export function formatDayParam(date: Date): string {
|
||||||
// formatToParts garantisce valori senza zero-padding, a differenza di format()
|
|
||||||
// il cui output dipende dalla localizzazione del browser.
|
|
||||||
const parts = new Intl.DateTimeFormat('it-IT', {
|
const parts = new Intl.DateTimeFormat('it-IT', {
|
||||||
timeZone: ROME,
|
timeZone: ROME,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -50,22 +43,19 @@ export function formatDayParam(date) {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}).formatToParts(date);
|
}).formatToParts(date);
|
||||||
|
|
||||||
const get = type => parts.find(p => p.type === type).value;
|
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDayRange(startIsoUtc, endIsoUtc) {
|
export function getDayRange(startIsoUtc: string, endIsoUtc: string | null): Date[] {
|
||||||
// en-CA produce YYYY-MM-DD, l'unico formato ISO interpretato uniformemente da tutti i browser.
|
const toRomeDateString = (date: Date) => new Intl.DateTimeFormat('en-CA', {
|
||||||
const toRomeDateString = date => new Intl.DateTimeFormat('en-CA', {
|
|
||||||
timeZone: ROME,
|
timeZone: ROME,
|
||||||
}).format(date);
|
}).format(date);
|
||||||
|
|
||||||
const startStr = toRomeDateString(new Date(startIsoUtc));
|
const startStr = toRomeDateString(new Date(startIsoUtc));
|
||||||
const endStr = toRomeDateString(endIsoUtc ? new Date(endIsoUtc) : new Date());
|
const endStr = toRomeDateString(endIsoUtc ? new Date(endIsoUtc) : new Date());
|
||||||
|
|
||||||
const days = [];
|
const days: Date[] = [];
|
||||||
// Mezzogiorno UTC (13:00/14:00 a Roma) non attraversa mai un cambio data locale,
|
|
||||||
// neanche durante il passaggio ora legale/solare.
|
|
||||||
let cursor = new Date(`${startStr}T12:00:00Z`);
|
let cursor = new Date(`${startStr}T12:00:00Z`);
|
||||||
const end = new Date(`${endStr}T12:00:00Z`);
|
const end = new Date(`${endStr}T12:00:00Z`);
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface Station {
|
||||||
|
pk: number;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Measurement {
|
||||||
|
temp: number;
|
||||||
|
umid: number;
|
||||||
|
pm10: number;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import type { PageServerLoad } from './$types.js';
|
||||||
import { fetchStations } from '$lib/api.js';
|
import { fetchStations } from '$lib/api.js';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
try {
|
try {
|
||||||
return { stations: await fetchStations(fetch) };
|
return { stations: await fetchStations(fetch) };
|
||||||
} catch {
|
} catch {
|
||||||
return { stations: [], loadError: 'Impossibile caricare le centraline. Controlla la connessione e riprova.' };
|
return { stations: [], loadError: 'Impossibile caricare le centraline. Controlla la connessione e riprova.' };
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import StationList from '$lib/components/StationList.svelte';
|
import StationList from '$lib/components/StationList.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|||||||
+11
-5
@@ -1,16 +1,21 @@
|
|||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
|
||||||
function cacheControl(date) {
|
function cacheControl(date: string): string {
|
||||||
if (dev) return 'no-store';
|
if (dev) return 'no-store';
|
||||||
const toIso = d => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(d);
|
const toIso = (d: Date) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(d);
|
||||||
const yesterday = toIso(new Date(Date.now() - 86_400_000));
|
const yesterday = toIso(new Date(Date.now() - 86_400_000));
|
||||||
return date < yesterday ? 'public, max-age=604800' : 'public, max-age=60';
|
return date < yesterday ? 'public, max-age=604800' : 'public, max-age=60';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET({ url }) {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
const date = url.searchParams.get('date'); // YYYY-MM-DD
|
const date = url.searchParams.get('date'); // YYYY-MM-DD
|
||||||
|
|
||||||
|
if (!id || !date) {
|
||||||
|
return new Response('[]', { status: 400, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
// Il backend vuole YYYY-M-D senza zero-padding
|
// Il backend vuole YYYY-M-D senza zero-padding
|
||||||
const [y, m, d] = date.split('-');
|
const [y, m, d] = date.split('-');
|
||||||
const day = `${parseInt(y)}-${parseInt(m)}-${parseInt(d)}`;
|
const day = `${parseInt(y)}-${parseInt(m)}-${parseInt(d)}`;
|
||||||
@@ -25,7 +30,8 @@ export async function GET({ url }) {
|
|||||||
// Il backend Python emette NaN/Infinity per valori mancanti o fuori range,
|
// Il backend Python emette NaN/Infinity per valori mancanti o fuori range,
|
||||||
// che non sono JSON valido e farebbero esplodere JSON.parse.
|
// che non sono JSON valido e farebbero esplodere JSON.parse.
|
||||||
const sanitized = text.replace(/(?<=[:\s\[,])(?:nan|inf|-inf|infinity|-infinity)(?=[,\]\s}])/gi, 'null');
|
const sanitized = text.replace(/(?<=[:\s\[,])(?:nan|inf|-inf|infinity|-infinity)(?=[,\]\s}])/gi, 'null');
|
||||||
const misure = (JSON.parse(sanitized).misure ?? [])
|
type RawMeasurement = { temp: number; umid: number; pm10: number; time: string };
|
||||||
|
const misure = ((JSON.parse(sanitized).misure ?? []) as RawMeasurement[])
|
||||||
.filter(m => m != null && Number.isFinite(new Date(m.time).getTime()));
|
.filter(m => m != null && Number.isFinite(new Date(m.time).getTime()));
|
||||||
|
|
||||||
return new Response(JSON.stringify(misure), {
|
return new Response(JSON.stringify(misure), {
|
||||||
@@ -34,4 +40,4 @@ export async function GET({ url }) {
|
|||||||
'cache-control': cacheControl(date),
|
'cache-control': cacheControl(date),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
Reference in New Issue
Block a user