Compare commits

...

13 Commits

30 changed files with 1535 additions and 108 deletions
+26
View File
@@ -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 }}
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+201 -108
View File
@@ -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}`).
+36
View File
@@ -13,3 +13,39 @@
> [37100Lab](http://37100lab.it:8101/) in CSV > [37100Lab](http://37100lab.it:8101/) in CSV
✨ Vibe codato al 100% con Claude — perché la vita è troppo breve. ✨ Vibe codato al 100% con Claude — perché la vita è troppo breve.
## Avvio
```sh
git clone https://git.nicolabelluti.me/nicolabelluti/download-dati-centraline.git
cd download-dati-centraline
nix develop --command $SHELL
bun install
bun run dev
```
## Build
```sh
bun run build
bun run preview
```
## Gruppi di centraline
Le centraline vengono raggruppate visivamente tramite il file
`src/lib/groups.json`. La struttura è un array di gruppi:
```json
[
{
"nome": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
}
]
```
Ogni gruppo ha un nome e una lista di ID numerici delle centraline. Le
centraline non presenti in nessun gruppo vengono mostrate automaticamente nella
sezione "Altre". L'ordine dei gruppi nel file determina l'ordine di
visualizzazione sulla pagina.
+227
View File
@@ -0,0 +1,227 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "download-dati-centraline",
"dependencies": {
"jszip": "^3.10.1",
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.6.2",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7",
},
},
},
"packages": {
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.59.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@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=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.8.0", "", {}, "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rolldown": ["rolldown@1.0.0-rc.18", "", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
"svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"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=="],
"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=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
}
}
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+19
View File
@@ -0,0 +1,19 @@
{
description = "Portale centraline 37100Lab frontend Svelte/Vite";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs { inherit system; };
in {
devShell = pkgs.mkShell {
buildInputs = [
pkgs.bun
];
};
});
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "download-dati-centraline",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.6.2",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"jszip": "^3.10.1"
}
}
+58
View File
@@ -0,0 +1,58 @@
*, *::before, *::after {
box-sizing: border-box;
}
:root {
--bg: #f0f2f5;
--surface: #ffffff;
--border: #dde1e7;
--text: #374151;
--text-dim:#9ca3af;
--text-hi: #111827;
--accent: #2563eb;
--sans: 'Poppins', system-ui, sans-serif;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
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);
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Centraline 37100Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+32
View File
@@ -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 [];
}
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import StationRow from './StationRow.svelte';
import groups from '$lib/groups.json';
import type { Station } from '$lib/types.js';
let { stations = [] }: { stations: Station[] } = $props();
// 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));
// Le attive appaiono prima delle terminate per dare prominenza alle stazioni in funzione.
function sortStations(items: Station[]): Station[] {
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;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
const sections = $derived([
...groups.map(g => ({
name: g.name,
stations: sortStations(stations.filter(s => g.ids.includes(s.pk))),
})).filter(s => s.stations.length > 0),
{
name: 'Altre',
stations: sortStations(stations.filter(s => !groupedIds.has(s.pk))),
},
].filter(s => s.stations.length > 0));
</script>
<div class="content">
{#each sections as section}
<section>
<h2>{section.name}</h2>
<div class="list">
{#each section.stations as station (station.pk)}
<StationRow {station} />
{/each}
</div>
</section>
{/each}
</div>
<style>
.content {
display: flex;
flex-direction: column;
gap: 4rem;
max-width: 820px;
margin: 0 auto;
}
section h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
</style>
+349
View File
@@ -0,0 +1,349 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getDayRange, formatDayParam } from '$lib/timezone.js';
import { fetchDay } from '$lib/api.js';
import { buildCsv, downloadBlob, buildDayFilename, buildZipFilename } from '$lib/csv.js';
import { enqueue } from '$lib/download-pool.js';
import type { Station, Measurement } from '$lib/types.js';
function floatingTooltip(node: HTMLElement, text: string) {
let el: HTMLDivElement | null = null;
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',
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(d));
const isActive = $derived(station.ended_at === null);
const createdDate = $derived(formatDate(station.created_at));
const endDate = $derived(isActive ? null : formatDate(station.ended_at!));
let downloading = $state(false);
let cancelling = $state(false);
let completed = $state(false);
let controller: AbortController | null = null;
let currentDay = $state(0);
let totalDays = $state(0);
let pct = $derived(totalDays > 0 ? (currentDay / totalDays) * 100 : 0);
async function startDownload() {
const JSZip = (await import('jszip')).default;
controller = new AbortController();
const { signal } = controller;
downloading = true;
const days = getDayRange(station.created_at, station.ended_at);
totalDays = days.length;
currentDay = 0;
const resultsByDay: Measurement[][] = new Array(days.length);
await Promise.all(days.map((day, idx) =>
enqueue(async () => {
if (signal.aborted) return;
resultsByDay[idx] = await fetchDay(station.pk, formatDayParam(day), signal);
currentDay++;
})
));
if (!signal.aborted) {
currentDay = totalDays;
await new Promise(r => setTimeout(r, 900));
if (!signal.aborted) {
const zip = new JSZip();
let allMeasurements: Measurement[] = [];
for (let i = 0; i < days.length; i++) {
const measurements = resultsByDay[i];
if (!measurements?.length) continue;
zip.file(buildDayFilename(days[i]), buildCsv(measurements, false));
allMeasurements = allMeasurements.concat(measurements);
}
if (allMeasurements.length > 0) {
zip.file(buildZipFilename(station.title, station.pk).replace('.zip', '.csv'), buildCsv(allMeasurements));
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
downloadBlob(blob, buildZipFilename(station.title, station.pk));
completed = true;
await new Promise(r => setTimeout(r, 2000));
}
}
} else {
await new Promise(r => setTimeout(r, 700));
}
downloading = false;
cancelling = false;
completed = false;
controller = null;
}
function cancel() {
cancelling = true;
controller?.abort();
}
// Pulisce le fetch pendenti se il componente viene smontato durante un download.
onDestroy(() => controller?.abort());
</script>
<div class="card">
<div class="left">
<a class="name" href="http://37100lab.it:8101/campagna/{station.pk}" target="_blank" rel="noreferrer">{station.title}</a>
<span class="id">#{station.pk}</span>
</div>
<div class="right">
<span class="date">Attivata il {createdDate}</span>
<span class="sep">·</span>
<span
class="status"
class:active={isActive}
class:ended={!isActive}
use:floatingTooltip={!isActive ? `Terminata il ${endDate}` : ''}
>{isActive ? 'Attiva' : 'Terminata'}</span>
<button
type="button"
title={downloading ? 'Annulla' : 'Scarica CSV'}
onclick={downloading ? cancel : startDownload}
class:downloading
class:completed
>
{#if completed}
<svg width="18" height="18" viewBox="0 0 16 16" fill="none">
<path d="M2 8l4.5 4.5L14 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else if downloading}
<span class="icon-download">
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="40 20"/>
</svg>
</span>
<span class="icon-cancel">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
{:else}
<svg width="20" height="20" viewBox="0 0 16 16" fill="none">
<path d="M8 1v9M4.5 6.5 8 10l3.5-3.5M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</button>
</div>
{#if downloading}
<div class="bar-wrap">
<div class="bar" class:cancelled={cancelling} class:completed style="width: {Math.max(pct, 2)}%"></div>
</div>
{/if}
</div>
<style>
.card {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.4rem 1.8rem;
display: flex;
align-items: center;
gap: 1.25rem;
overflow: hidden;
}
button {
flex-shrink: 0;
margin-left: 0.75rem;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
button:hover {
background: #eff6ff;
border-color: var(--accent);
color: var(--accent);
}
button.downloading {
color: var(--accent);
border-color: var(--accent);
background: #eff6ff;
}
button.downloading .icon-cancel { display: none; }
button.downloading .icon-download { display: flex; }
button.downloading:hover {
background: #fef2f2;
border-color: #dc2626;
color: #dc2626;
}
button.downloading:hover .icon-cancel { display: flex; }
button.downloading:hover .icon-download { display: none; }
.icon-download, .icon-cancel {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.left {
display: flex;
align-items: baseline;
gap: 1.25rem;
flex: 1;
min-width: 0;
}
.name {
font-size: 1.05rem;
font-weight: 500;
color: var(--text-hi);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name:hover {
text-decoration: underline;
}
.id {
font-size: 0.75rem;
color: var(--text-dim);
white-space: nowrap;
flex-shrink: 0;
}
.right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.sep {
font-size: 0.78rem;
color: var(--text-dim);
opacity: 0.5;
}
.date {
font-size: 0.78rem;
color: var(--text-dim);
white-space: nowrap;
}
.status {
position: relative;
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.active {
color: var(--accent);
}
.ended {
color: var(--text-dim);
}
.bar-wrap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--border);
overflow: hidden;
}
.bar {
height: 100%;
background: var(--accent);
transition: width 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94), background 0.2s;
}
.bar.cancelled {
background: #dc2626;
}
.bar.completed {
background: #16a34a;
}
button.completed {
color: #16a34a;
border-color: #16a34a;
background: #f0fdf4;
}
button.completed:hover {
color: #16a34a;
border-color: #16a34a;
background: #f0fdf4;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import type { Measurement } from './types.js';
import { formatTimestampRome, ROME } from './timezone.js';
export function buildCsv(measurements: Measurement[], includeDate = true): string {
const header = includeDate
? '"Data","Ora","Temperatura","Umidità","PM10"'
: '"Ora","Temperatura","Umidità","PM10"';
const rows = measurements.map(m => {
const [date, time] = formatTimestampRome(m.time).split(' ');
const t = time.split('.')[0];
return includeDate ? `"${date}","${t}",${m.temp},${m.umid},${m.pm10}` : `"${t}",${m.temp},${m.umid},${m.pm10}`;
});
// BOM obbligatorio: Excel su Windows interpreta UTF-8 senza BOM come Windows-1252,
// corrompendo i caratteri accentati nelle intestazioni.
return '' + [header, ...rows].join('\n');
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function buildDayFilename(date: Date): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date) + '.csv';
}
export function buildZipFilename(title: string, id: number): string {
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
return `${sanitized}_(${id}).zip`;
}
+24
View File
@@ -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();
});
}
+6
View File
@@ -0,0 +1,6 @@
[
{
"name": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
}
]
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+68
View File
@@ -0,0 +1,68 @@
export const ROME = 'Europe/Rome';
const PAD2 = Array.from({ length: 100 }, (_, i) => String(i).padStart(2, '0'));
const offsetCache = new Map<string, number>();
function getRomeOffsetMs(date: Date): number {
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}-${date.getUTCHours()}`;
if (offsetCache.has(key)) return offsetCache.get(key)!;
const romeHour = parseInt(
new Intl.DateTimeFormat('en', { timeZone: ROME, hour: 'numeric', hour12: false }).format(date),
10
);
let diff = romeHour - date.getUTCHours();
if (diff > 12) diff -= 24;
if (diff < -12) diff += 24;
const offsetMs = diff * 3_600_000;
offsetCache.set(key, offsetMs);
return offsetMs;
}
export function formatTimestampRome(isoString: string): string {
const d = new Date(isoString);
const frac = Math.floor(d.getMilliseconds() / 100);
const r = new Date(d.getTime() + getRomeOffsetMs(d));
const dd = PAD2[r.getUTCDate()];
const mo = PAD2[r.getUTCMonth() + 1];
const yyyy = r.getUTCFullYear();
const hh = PAD2[r.getUTCHours()];
const mm = PAD2[r.getUTCMinutes()];
const ss = PAD2[r.getUTCSeconds()];
return `${dd}/${mo}/${yyyy} ${hh}:${mm}:${ss}.${frac}`;
}
export function formatDayParam(date: Date): string {
const parts = new Intl.DateTimeFormat('it-IT', {
timeZone: ROME,
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)!.value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function getDayRange(startIsoUtc: string, endIsoUtc: string | null): Date[] {
const toRomeDateString = (date: Date) => new Intl.DateTimeFormat('en-CA', {
timeZone: ROME,
}).format(date);
const startStr = toRomeDateString(new Date(startIsoUtc));
const endStr = toRomeDateString(endIsoUtc ? new Date(endIsoUtc) : new Date());
const days: Date[] = [];
let cursor = new Date(`${startStr}T12:00:00Z`);
const end = new Date(`${endStr}T12:00:00Z`);
while (cursor <= end) {
days.push(new Date(cursor));
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
}
return days;
}
+13
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+10
View File
@@ -0,0 +1,10 @@
import type { PageServerLoad } from './$types.js';
import { fetchStations } from '$lib/api.js';
export const load: PageServerLoad = async ({ fetch }) => {
try {
return { stations: await fetchStations(fetch) };
} catch {
return { stations: [], loadError: 'Impossibile caricare le centraline. Controlla la connessione e riprova.' };
}
};
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import StationList from '$lib/components/StationList.svelte';
let { data } = $props();
</script>
<div class="page">
<header>
<h1>Centraline</h1>
<p>Scarica i dati storici delle stazioni di monitoraggio ambientale di <a href="http://37100lab.it:8101/" target="_blank" rel="noreferrer">37100Lab</a>.</p>
</header>
<main>
{#if data.loadError}
<p class="status error">{data.loadError}</p>
{:else}
<StationList stations={data.stations} />
{/if}
</main>
<footer>
<p>Fatto da <strong><a href="https://nicolabelluti.me" target="_blank" rel="noreferrer">Nicola Belluti</a></strong> (e <a href="https://claude.com/product/claude-code" target="_blank" rel="noreferrer">Claude Code</a>) col ❤️</p>
<p class="links">
<a href="https://git.nicolabelluti.me/nicolabelluti/download-dati-centraline" target="_blank" rel="noreferrer">Codice sorgente</a>
·
<a href="https://choosealicense.com/licenses/agpl-3.0/" target="_blank" rel="noreferrer">Licenza GNU AGPLv3.0</a>
</p>
</footer>
</div>
<style>
.page {
max-width: 1100px;
margin: 0 auto;
padding: 7rem 0.5rem 0;
min-height: 100svh;
display: flex;
flex-direction: column;
}
header {
margin-top: 2rem;
margin-bottom: 7rem;
padding-left: 2rem;
}
main {
flex: 1;
padding: 0 0 5rem;
}
h1 {
margin: 0 0 1rem;
font-size: 3.75rem;
font-weight: 500;
color: var(--text-hi);
letter-spacing: -0.04em;
line-height: 1.1;
}
p {
margin: 0;
font-size: 0.9rem;
color: var(--text-dim);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.status {
font-size: 0.875rem;
color: var(--text-dim);
}
.error {
color: #dc2626;
}
footer {
margin-top: auto;
padding: 2rem 0 4rem;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer p {
font-size: 0.82rem;
}
footer strong {
font-weight: 600;
}
.links {
display: flex;
gap: 0.6rem;
align-items: center;
color: var(--text-dim);
}
</style>
@@ -0,0 +1,43 @@
import { dev } from '$app/environment';
import type { RequestHandler } from './$types.js';
function cacheControl(date: string): string {
if (dev) return 'no-store';
const toIso = (d: Date) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(d);
const yesterday = toIso(new Date(Date.now() - 86_400_000));
return date < yesterday ? 'public, max-age=604800' : 'public, max-age=60';
}
export const GET: RequestHandler = async ({ url }) => {
const id = url.searchParams.get('id');
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
const [y, m, d] = date.split('-');
const day = `${parseInt(y)}-${parseInt(m)}-${parseInt(d)}`;
const res = await fetch(`http://37100lab.it:8101/api/get_measurements/${id}?day=${day}`);
if (!res.ok) {
return new Response('[]', { status: res.status, headers: { 'content-type': 'application/json' } });
}
const text = await res.text();
// Il backend Python emette NaN/Infinity per valori mancanti o fuori range,
// che non sono JSON valido e farebbero esplodere JSON.parse.
const sanitized = text.replace(/(?<=[:\s\[,])(?:nan|inf|-inf|infinity|-infinity)(?=[,\]\s}])/gi, 'null');
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()));
return new Response(JSON.stringify(misure), {
headers: {
'content-type': 'application/json',
'cache-control': cacheControl(date),
},
});
};
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
<g>
<rect x="4" y="4" style="fill:#FFFFFF;" width="120" height="120"/>
<g>
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="24.35" x2="5.57" y2="24.35"/>
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="43.67" x2="5.47" y2="43.67"/>
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="62.99" x2="5.36" y2="62.99"/>
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="82.31" x2="5.26" y2="82.31"/>
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="101.64" x2="5.15" y2="101.64"/>
</g>
<path style="fill:#9CCC65;" d="M38.72,121.91H21.89V55.38c0-1.36,1.1-2.46,2.46-2.46h11.91c1.36,0,2.46,1.1,2.46,2.46V121.91z"/>
<path style="fill:#F44336;" d="M72.42,121.91H55.58V74.84c0-1.36,1.1-2.46,2.46-2.46h11.91c1.36,0,2.46,1.1,2.46,2.46V121.91z"/>
<path style="fill:#0091EA;" d="M103.64,121.91H91.73c-1.36,0-2.46-1.1-2.46-2.46V25.86c0-1.36,1.1-2.46,2.46-2.46h11.91 c1.36,0,2.46,1.1,2.46,2.46v93.59C106.1,120.81,105,121.91,103.64,121.91z"/>
<polygon style="fill:#B0BEC5;" points="124,120 8,120 8,4 4,4 4,124 124,124 "/>
<g>
<path style="fill:#B0BEC5;" d="M122,6v116H6V6H122 M124,4H4v120h120V4L124,4z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+13
View File
@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
runes: true,
},
kit: {
adapter: adapter()
}
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
});