diff --git a/src/App.svelte b/src/App.svelte
index c0d1927..23d5b84 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -18,7 +18,7 @@
});
-
+
diff --git a/src/lib/api.js b/src/lib/api.js
index b5c4b22..721e619 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -7,13 +7,18 @@ export async function fetchStations() {
.sort((a, b) => b.pk - a.pk);
}
-export async function fetchDay(id, date) {
- try {
- const res = await fetch(`/api/get_measurements/${id}?day=${date}`);
- if (!res.ok) return [];
- const data = await res.json();
- return data.misure ?? [];
- } catch {
- return [];
+export async function fetchDay(id, date, signal) {
+ // 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_measurements/${id}?day=${date}`, { signal });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ return data.misure ?? [];
+ } catch (e) {
+ // La cancellazione non è un errore: uscire subito senza consumare altri retry.
+ if (e.name === 'AbortError') return [];
+ if (attempt === 2) return [];
+ }
}
}
diff --git a/src/lib/csv-pool.js b/src/lib/csv-pool.js
new file mode 100644
index 0000000..921aed0
--- /dev/null
+++ b/src/lib/csv-pool.js
@@ -0,0 +1,48 @@
+import CsvWorker from './csv.worker.js?worker';
+
+// Costruire il CSV di un'intera centralina (potenzialmente decine di migliaia di righe)
+// blocca il main thread per centinaia di ms: si delega a Worker separati.
+// Due worker perché la costruzione CSV è CPU-bound: aggiungerne di più non migliora
+// il singolo job ma aumenta il consumo di memoria.
+const POOL_SIZE = 2;
+
+const queue = [];
+const idle = [];
+
+for (let i = 0; i < POOL_SIZE; i++) {
+ const w = new CsvWorker();
+
+ w.onmessage = ({ data }) => {
+ // _task aggancia la Promise al Worker: il message handler non riceve l'identità
+ // del worker mittente, quindi è l'unico modo per risalire alla Promise da risolvere.
+ const { resolve } = w._task;
+ w._task = null;
+ idle.push(w);
+ resolve(data);
+ dispatch();
+ };
+
+ w.onerror = e => {
+ const { reject } = w._task;
+ w._task = null;
+ idle.push(w);
+ reject(e);
+ dispatch();
+ };
+
+ idle.push(w);
+}
+
+function dispatch() {
+ if (!queue.length || !idle.length) return;
+ const w = idle.pop();
+ w._task = queue.shift();
+ w.postMessage(w._task.measurements);
+}
+
+export function buildCsvAsync(measurements) {
+ return new Promise((resolve, reject) => {
+ queue.push({ measurements, resolve, reject });
+ dispatch();
+ });
+}
diff --git a/src/lib/csv.js b/src/lib/csv.js
new file mode 100644
index 0000000..447e06a
--- /dev/null
+++ b/src/lib/csv.js
@@ -0,0 +1,31 @@
+import { formatTimestampRome, ROME } from './timezone.js';
+
+export function buildCsv(measurements) {
+ const header = '"Data","Ora","Temperatura","Umidità","PM10"';
+ const rows = measurements.map(m => {
+ const [date, time] = formatTimestampRome(m.time).split(' ');
+ return `"${date}","${time.split('.')[0]}",${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 downloadCsv(content, filename) {
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ // revokeObjectURL libera subito la memoria del blob: il click sincrono è sufficiente
+ // perché il browser avvia il download prima di passare allo statement successivo.
+ URL.revokeObjectURL(url);
+}
+
+export function buildFilename(title, id, date) {
+ // en-CA produce YYYY-MM-DD, il formato ISO che vogliamo nel nome file.
+ const dateStr = new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date);
+ const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
+ return `${sanitized}_(${id})_${dateStr}.csv`;
+}
diff --git a/src/lib/csv.worker.js b/src/lib/csv.worker.js
new file mode 100644
index 0000000..74f5b1c
--- /dev/null
+++ b/src/lib/csv.worker.js
@@ -0,0 +1,5 @@
+import { buildCsv } from './csv.js';
+
+self.onmessage = ({ data: measurements }) => {
+ self.postMessage(buildCsv(measurements));
+};
diff --git a/src/lib/download-pool.js b/src/lib/download-pool.js
new file mode 100644
index 0000000..42890ec
--- /dev/null
+++ b/src/lib/download-pool.js
@@ -0,0 +1,26 @@
+// 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();
+ });
+}
diff --git a/src/lib/groups.json b/src/lib/groups.json
index b25013b..4465142 100644
--- a/src/lib/groups.json
+++ b/src/lib/groups.json
@@ -1,6 +1,6 @@
[
{
- "nome": "Mantova",
+ "name": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
}
]
diff --git a/src/lib/timezone.js b/src/lib/timezone.js
new file mode 100644
index 0000000..8a10496
--- /dev/null
+++ b/src/lib/timezone.js
@@ -0,0 +1,78 @@
+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'));
+
+// L'offset CET/CEST cambia solo due volte l'anno: cachare evita una chiamata Intl per ogni timestamp.
+const offsetCache = new Map();
+
+function getRomeOffsetMs(date) {
+ 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();
+ // Se diff supera ±12 le ore attraversano mezzanotte UTC: il salto va corretto.
+ 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) {
+ const d = new Date(isoString);
+ 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 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) {
+ // 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', {
+ timeZone: ROME,
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ }).formatToParts(date);
+
+ const get = type => parts.find(p => p.type === type).value;
+ return `${get('year')}-${get('month')}-${get('day')}`;
+}
+
+export function getDayRange(startIsoUtc, endIsoUtc) {
+ // en-CA produce YYYY-MM-DD, l'unico formato ISO interpretato uniformemente da tutti i browser.
+ const toRomeDateString = 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 = [];
+ // 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`);
+ 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;
+}