Migrated codebase to TypeScript
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"@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",
|
||||
@@ -89,6 +90,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -211,6 +214,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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 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²).
|
||||
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) {
|
||||
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) - new Date(a.created_at);
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script>
|
||||
<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';
|
||||
|
||||
let { station } = $props();
|
||||
let { station }: { station: Station } = $props();
|
||||
|
||||
const formatDate = d => new Intl.DateTimeFormat('it-IT', {
|
||||
const formatDate = (d: string) => new Intl.DateTimeFormat('it-IT', {
|
||||
timeZone: 'Europe/Rome',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -16,12 +17,12 @@
|
||||
|
||||
const isActive = $derived(station.ended_at === null);
|
||||
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 cancelling = $state(false);
|
||||
let completed = $state(false);
|
||||
let controller = null;
|
||||
let controller: AbortController | null = null;
|
||||
let currentDay = $state(0);
|
||||
let totalDays = $state(0);
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
totalDays = days.length;
|
||||
currentDay = 0;
|
||||
|
||||
const resultsByDay = new Array(days.length);
|
||||
const resultsByDay: Measurement[][] = new Array(days.length);
|
||||
|
||||
await Promise.all(days.map((day, idx) =>
|
||||
enqueue(async () => {
|
||||
@@ -53,7 +54,7 @@
|
||||
|
||||
if (!signal.aborted) {
|
||||
const zip = new JSZip();
|
||||
let allMeasurements = [];
|
||||
let allMeasurements: Measurement[] = [];
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const measurements = resultsByDay[i];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Measurement } from './types.js';
|
||||
import { formatTimestampRome, ROME } from './timezone.js';
|
||||
|
||||
export function buildCsv(measurements, includeDate = true) {
|
||||
export function buildCsv(measurements: Measurement[], includeDate = true): string {
|
||||
const header = includeDate
|
||||
? '"Data","Ora","Temperatura","Umidità","PM10"'
|
||||
: '"Ora","Temperatura","Umidità","PM10"';
|
||||
@@ -14,7 +15,7 @@ export function buildCsv(measurements, includeDate = true) {
|
||||
return '' + [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -23,11 +24,11 @@ export function downloadBlob(blob, filename) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function buildDayFilename(date) {
|
||||
export function buildDayFilename(date: Date): string {
|
||||
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, '');
|
||||
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';
|
||||
|
||||
// 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();
|
||||
const offsetCache = new Map<string, number>();
|
||||
|
||||
function getRomeOffsetMs(date) {
|
||||
function getRomeOffsetMs(date: Date): number {
|
||||
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(
|
||||
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;
|
||||
@@ -23,11 +20,9 @@ function getRomeOffsetMs(date) {
|
||||
return offsetMs;
|
||||
}
|
||||
|
||||
export function formatTimestampRome(isoString) {
|
||||
export function formatTimestampRome(isoString: string): string {
|
||||
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()];
|
||||
@@ -40,9 +35,7 @@ export function formatTimestampRome(isoString) {
|
||||
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.
|
||||
export function formatDayParam(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('it-IT', {
|
||||
timeZone: ROME,
|
||||
year: 'numeric',
|
||||
@@ -50,22 +43,19 @@ export function formatDayParam(date) {
|
||||
day: 'numeric',
|
||||
}).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')}`;
|
||||
}
|
||||
|
||||
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', {
|
||||
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 = [];
|
||||
// Mezzogiorno UTC (13:00/14:00 a Roma) non attraversa mai un cambio data locale,
|
||||
// neanche durante il passaggio ora legale/solare.
|
||||
const days: Date[] = [];
|
||||
let cursor = new Date(`${startStr}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';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { fetchStations } from '$lib/api.js';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
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.' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import StationList from '$lib/components/StationList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
+11
-5
@@ -1,16 +1,21 @@
|
||||
import { dev } from '$app/environment';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
function cacheControl(date) {
|
||||
function cacheControl(date: string): string {
|
||||
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));
|
||||
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 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)}`;
|
||||
@@ -25,7 +30,8 @@ export async function GET({ url }) {
|
||||
// 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');
|
||||
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()));
|
||||
|
||||
return new Response(JSON.stringify(misure), {
|
||||
@@ -34,4 +40,4 @@ export async function GET({ url }) {
|
||||
'cache-control': cacheControl(date),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user