Switched to per-day zipped CSVs with combined CSV included

This commit is contained in:
2026-05-08 14:10:34 +02:00
parent 41152138c1
commit 2246b57c2a
6 changed files with 66 additions and 79 deletions
+20 -14
View File
@@ -2,7 +2,7 @@
import { onDestroy } from 'svelte';
import { getDayRange, formatDayParam } from '$lib/timezone.js';
import { fetchDay } from '$lib/api.js';
import { downloadCsv, buildFilename } from '$lib/csv.js';
import { buildCsv, downloadBlob, buildDayFilename, buildZipFilename } from '$lib/csv.js';
import { enqueue } from '$lib/download-pool.js';
let { station } = $props();
@@ -28,9 +28,7 @@
let pct = $derived(totalDays > 0 ? (currentDay / totalDays) * 100 : 0);
async function startDownload() {
const { buildCsvAsync } = await import('$lib/csv-pool.js');
// Un controller per sessione: abort() cancella tutte le fetch di questa centralina
// senza interferire con i download paralleli di altre stazioni.
const JSZip = (await import('jszip')).default;
controller = new AbortController();
const { signal } = controller;
downloading = true;
@@ -39,8 +37,6 @@
totalDays = days.length;
currentDay = 0;
// Array pre-allocato per indice: le fetch partono in parallelo e completano fuori ordine,
// ma resultsByDay[idx] garantisce che il CSV rispetti la sequenza temporale.
const resultsByDay = new Array(days.length);
await Promise.all(days.map((day, idx) =>
@@ -52,17 +48,27 @@
));
if (!signal.aborted) {
const measurements = resultsByDay.flat();
currentDay = totalDays;
// Breve pausa prima del reset: l'utente deve poter vedere la barra al 100%.
await new Promise(r => setTimeout(r, 900));
if (!signal.aborted && measurements.length > 0) {
const csv = await buildCsvAsync(measurements);
downloadCsv(csv, buildFilename(station.title, station.pk, new Date()));
completed = true;
// Il segno di spunta deve restare visibile abbastanza a lungo da essere notato.
await new Promise(r => setTimeout(r, 2000));
if (!signal.aborted) {
const zip = new JSZip();
let allMeasurements = [];
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));
-48
View File
@@ -1,48 +0,0 @@
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();
});
}
+14 -12
View File
@@ -1,31 +1,33 @@
import { formatTimestampRome, ROME } from './timezone.js';
export function buildCsv(measurements) {
const header = '"Data","Ora","Temperatura","Umidità","PM10"';
export function buildCsv(measurements, includeDate = true) {
const header = includeDate
? '"Data","Ora","Temperatura","Umidità","PM10"'
: '"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}`;
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 downloadCsv(content, filename) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
export function downloadBlob(blob, filename) {
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`;
export function buildDayFilename(date) {
return new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date) + '.csv';
}
export function buildZipFilename(title, id) {
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
return `${sanitized}_(${id}).zip`;
}
-5
View File
@@ -1,5 +0,0 @@
import { buildCsv } from './csv.js';
self.onmessage = ({ data: measurements }) => {
self.postMessage(buildCsv(measurements));
};