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/week/[location]/+page.svelte
T
2026-07-20 11:26:49 +02:00

219 lines
6.8 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
import { defaultParameters } from '../../options';
import DailyCards from './DailyCards.svelte';
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
import VariableSidebar from './VariableSidebar.svelte';
import type { PageData } from './$types';
import type { FetchedDaily, FetchedHourly } from './types';
let { data }: { data: PageData } = $props();
let params = $state({
models: ['best_match'],
...defaultParameters
});
let variableSidebarOpen = $state(false);
// Number of meteogram chart panels currently enabled: used to reserve the
// exact chart area height before data arrives (no layout shift)
let enabledChartCount = $derived.by(() => {
const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true;
return (
(on('temperature') || on('cloud_cover') ? 1 : 0) +
(on('precipitation') || on('precipitation_probability') ? 1 : 0) +
(on('wind') || on('humidity') ? 1 : 0)
);
});
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
});
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
const selectedDay = new SvelteDate();
let fetchedHourly: FetchedHourly | null = $state(null);
let fetchedDaily: FetchedDaily | null = $state(null);
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime());
};
onMount(() => {
// preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)];
mounted = true;
});
$effect(() => {
const loc = location;
const modelList = params.models;
if (!mounted || !loc || !modelList?.length) return;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true;
loadError = null;
fetchWeekForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
model: modelList[0],
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',
forecast_days: 7,
past_days: 0,
timezone: loc.timezone
})
.then((result: WeekForecastResult) => {
if (version !== requestVersion) return;
fetchedHourly = {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
};
fetchedDaily = {
daily: result.daily,
timezone: result.timezone,
dailyDates: result.dailyDates
};
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
</script>
<svelte:head>
<title>Drizzli | Weather</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head>
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
7-day forecast
</p>
</div>
</div>
<div class="flex items-center gap-3">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
storedModel.set(model);
}}
/>
<button
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground"
onclick={() => (variableSidebarOpen = true)}
aria-label="Choose visible variables"
>
<!-- sliders icon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden md:inline">Variables</span>
</button>
</div>
</div>
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
{#if fetchedHourly && fetchedDaily}
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
{/if}
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8">
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
{/if}
</div>
</div>