This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/historical/[location]/+page.svelte
T
2026-08-01 17:58:03 +02:00

288 lines
8.7 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import {
storedArchiveModel,
storedChartLayout,
storedLocation,
storedUnits,
storedVariablePrefs
} from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
fetchClimateNormals,
fetchHistoricalWeather
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options';
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { neededHourlyApiVars } from '../../week/[location]/variables';
import DateRangeControls from './DateRangeControls.svelte';
import HistoricalDaily from './HistoricalDaily.svelte';
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
useHeroActions(heroActions);
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
});
let params = $state({ ...defaultParameters });
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// Request only what the table rows + meteogram layout actually show.
let hourlyVars = $derived(
neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
)
);
// ─── Date range ───────────────────────────────────────────────────────────
const iso = (d: Date): string => d.toISOString().slice(0, 10);
const addDays = (d: Date, n: number): Date => {
const c = new Date(d);
c.setUTCDate(c.getUTCDate() + n);
return c;
};
const MIN_DATE = '1940-01-01'; // ERA5 archive start
// The reanalysis archive lags real time by a few days.
let maxDate = $state(iso(addDays(new Date(), -5)));
let startDate = $state(iso(addDays(new Date(), -34)));
let endDate = $state(iso(addDays(new Date(), -5)));
onMount(() => {
const today = new Date();
maxDate = iso(addDays(today, -5));
endDate = maxDate;
startDate = iso(addDays(today, -34));
mounted = true;
});
let archiveModel = $state('best_match');
onMount(() => {
archiveModel = get(storedArchiveModel);
});
function onRangeChange(s: string, e: string) {
startDate = s;
endDate = e;
}
// ─── Fetch state ────────────────────────────────────────────────────────────
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let result = $state<HistoricalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
const selectedDay = new SvelteDate();
// Historical data: refetch on location / range / units / requested-vars change.
$effect(() => {
const loc = location;
const s = startDate;
const e = endDate;
const vars = hourlyVars;
const model = archiveModel;
void model;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchHistoricalWeather({
latitude: loc.latitude!,
longitude: loc.longitude!,
start_date: s,
end_date: e,
hourlyVariables: vars,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((r) => {
if (version !== requestVersion) return;
result = r;
// default the hourly drill-down to the last day in range
if (r.dailyDates.length > 0) {
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
}
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// Climate normals: independent of the range, so fetch once per location/units.
let normalsKey = $derived(
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
);
let normalsVersion = 0;
$effect(() => {
const key = normalsKey;
const loc = location;
if (!mounted || !$isSupporter || !loc) return;
const version = ++normalsVersion;
normals = null;
fetchClimateNormals({
latitude: loc.latitude!,
longitude: loc.longitude!,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
})
.then((n) => {
if (version === normalsVersion) normals = n;
})
.catch(() => {
// normals are a nice-to-have; a failure just hides the comparison
if (version === normalsVersion) normals = null;
});
// re-read key so the effect tracks it
void key;
});
// ─── Adapters so the reused week components accept historical data ──────────
let fetchedHourly = $derived<FetchedHourly | null>(
result
? {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
}
: null
);
let fetchedDaily = $derived<FetchedDaily | null>(
result
? {
daily: {
weather_code: result.daily.weather_code,
temperature_2m_max: result.daily.temperature_2m_max,
temperature_2m_min: result.daily.temperature_2m_min,
sunrise: result.daily.sunrise,
sunset: result.daily.sunset,
sunshine_duration: result.daily.sunshine_duration,
precipitation_sum: result.daily.precipitation_sum,
windspeed_10m_max: result.daily.windspeed_10m_max,
windgusts_10m_max: result.daily.windgusts_10m_max,
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
},
timezone: result.timezone,
dailyDates: result.dailyDates
}
: null
);
function switchDay(date: Date) {
selectedDay.setTime(date.getTime());
}
</script>
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
label={m.model_archive()}
onModelChange={(model) => {
archiveModel = model;
storedArchiveModel.set(model);
}}
/>
</div>
{/snippet}
<svelte:head>
<title>Drizz.li | {m.page_historical_subtitle()}</title>
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
minDate={MIN_DATE}
{maxDate}
onChange={onRangeChange}
/>
{#if loadError}
<div
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load historical data: {loadError}
</div>
{/if}
<div class="mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
dailyDates={result.dailyDates}
timezone={result.timezone}
units={params}
{normals}
{selectedDay}
onSelectDay={switchDay}
/>
<div class="mt-6">
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
/>
</div>
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate>