This commit is contained in:
Vincent van der Wal
2026-08-06 19:11:11 +02:00
parent 025623aaa6
commit f4a02a208c
21 changed files with 336 additions and 78 deletions
+7 -4
View File
@@ -42,6 +42,11 @@
let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
// Joined here rather than in the markup: Svelte trims the whitespace around a
// line break, so a separator written as "{admin1},\n{country}" renders as
// "Canton of Schwyz,Switzerland".
let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
</script>
{#if subtitle && location}
@@ -57,10 +62,8 @@
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>{subtitle}
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
>{/if}{subtitle}
</p>
</div>
</div>
@@ -44,7 +44,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
@@ -59,7 +59,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
@@ -39,8 +39,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the paywall is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -213,7 +215,9 @@
);
function switchDay(date: Date) {
selectedDay.setTime(date.getTime());
// see the week page: an unformattable selected day breaks every consumer
const time = date?.getTime();
if (Number.isFinite(time)) selectedDay.setTime(time);
}
</script>
@@ -33,8 +33,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the paywall is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -189,33 +191,39 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
role="group"
aria-label={m.seasonal_range_aria()}
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
onclick={() => (rangeIndex = i)}
>
{range.label}
</button>
{/each}
</div>
{/if}
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
and archive pages: the controls then cannot move the heading when they
change size. -->
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
<!-- Range buttons reslice the already-fetched horizon (no refetch). Kept
mounted and merely hidden while the forecast is on its way: mounting
them on arrival re-flowed the row and nudged the heading. -->
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
class:invisible={!result}
role="group"
aria-label={m.seasonal_range_aria()}
aria-hidden={!result}
>
{#each RANGES as range, i (range.label)}
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
tabindex={result ? undefined : -1}
onclick={() => (rangeIndex = i)}
>
{range.label}
</button>
{/each}
</div>
<ModelSelector
selectedModel={seasonalModel}
groups={seasonalModelGroups}
@@ -49,7 +49,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
useHeroActions(heroActions);
@@ -211,8 +211,13 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
// A model that answers with a broken timestamp must not be able to park an
// unreadable date in `selectedDay`: everything downstream formats it, and a
// date that cannot be formatted takes the page with it.
const switchDay = (date: Date) => {
runDayTransition(() => selectedDay.setTime(date.getTime()));
const time = date?.getTime();
if (!Number.isFinite(time)) return;
runDayTransition(() => selectedDay.setTime(time));
};
onMount(() => {