move settings to one icon mobile

This commit is contained in:
Vincent van der Wal
2026-08-01 12:39:19 +02:00
parent 8ad02ea3f9
commit 5cc9a5428c
23 changed files with 1511 additions and 189 deletions
View File
@@ -23,6 +23,7 @@
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
{ href: resolve('/weather/seasonal'), label: 'Seasonal Outlook' },
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
];
+19 -46
View File
@@ -12,7 +12,10 @@
import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte';
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte';
interface Props {
onMenuToggle?: () => void;
@@ -60,6 +63,8 @@
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) {
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/seasonal')) {
goto(resolve('/weather/seasonal/[location]', { location: locationRoute }));
} else {
goto(resolve('/weather/week/[location]', { location: locationRoute }));
}
@@ -117,11 +122,20 @@
/>
</div>
<!-- Phones only have room for one control, so units, theme and supporter
status collapse into a single settings menu below md. -->
<div class="md:hidden">
<SettingsMenu />
</div>
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex">
<!-- Measurement units -->
<UnitSelector />
<!-- Premium status / unlock -->
<PremiumBadge />
<!-- Supporter status / unlock -->
<SupporterBadge />
<!-- Theme toggle: system → light → dark -->
<button
@@ -130,50 +144,9 @@
title={themeTitles[$storedTheme]}
aria-label={themeTitles[$storedTheme]}
>
{#if $storedTheme === 'light'}
<!-- sun -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
{:else if $storedTheme === 'dark'}
<!-- moon -->
<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"
stroke-linejoin="round"
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
/>
</svg>
{:else}
<!-- monitor (system) -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
</svg>
{/if}
<ThemeIcon theme={$storedTheme} />
</button>
</div>
</header>
<style>
@@ -0,0 +1,123 @@
<script lang="ts">
import { type Theme, storedTheme } from '$lib/stores/settings';
import * as Popover from '$lib/components/ui/popover';
import UnitOptions from '$lib/components/unit-options.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import ThemeIcon from './theme-icon.svelte';
// The topbar has room for one control on a phone, so units, theme and the
// supporter status share this menu instead of each carrying its own trigger.
const THEMES: { value: Theme; label: string }[] = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
];
let open = $state(false);
let unlockOpen = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Settings"
title="Settings"
>
<!-- gear -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="3" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
/>
</svg>
</Popover.Trigger>
<Popover.Content align="end" class="w-72 border-border">
<div class="flex flex-col gap-4">
<UnitOptions />
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
Theme
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each THEMES as option (option.value)}
{@const active = $storedTheme === option.value}
<button
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => storedTheme.set(option.value)}
>
<ThemeIcon theme={option.value} class="h-4 w-4" />
{option.label}
</button>
{/each}
</div>
</div>
<div class="border-t border-border/70 pt-3">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
{#if $isSupporter}
<!-- star -->
<svg
class="h-4.5 w-4.5 shrink-0 text-amber-500"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/>
</svg>
{:else}
<!-- padlock -->
<svg
class="h-4.5 w-4.5 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
aria-hidden="true"
>
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
{/if}
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? 'Manage your access key' : 'Unlock the supporter extras'}
</span>
</span>
</button>
</div>
</div>
</Popover.Content>
</Popover.Root>
<UnlockDialog bind:open={unlockOpen} />
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Theme } from '$lib/stores/settings';
interface Props {
theme: Theme;
class?: string;
}
let { theme, class: className = 'h-4.5 w-4.5' }: Props = $props();
</script>
{#if theme === 'light'}
<!-- sun -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
{:else if theme === 'dark'}
<!-- moon -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
/>
</svg>
{:else}
<!-- monitor (system) -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<rect x="3" y="4" width="18" height="13" rx="2" />
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
</svg>
{/if}
@@ -30,6 +30,12 @@
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
]
},
{
title: 'Seasonal',
url: '/weather/seasonal' as const,
// rising trend line (long-range outlook)
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
},
{
title: 'Historical',
url: '/weather/historical' as const,
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
// each group maps a stored unit key to its selectable options
const UNIT_GROUPS: {
key: keyof UnitPrefs;
label: string;
options: { value: string; label: string }[];
}[] = [
{
key: 'temperature_unit',
label: 'Temperature',
options: [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' }
]
},
{
key: 'wind_speed_unit',
label: 'Wind speed',
options: [
{ value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' },
{ value: 'mph', label: 'mph' },
{ value: 'kn', label: 'kn' }
]
},
{
key: 'precipitation_unit',
label: 'Precipitation',
options: [
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
</script>
<div class="flex flex-col gap-4">
{#each UNIT_GROUPS as group (group.key)}
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{group.label}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)}
{@const active = $storedUnits[group.key] === opt.value}
<button
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => setUnit(group.key, opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
+2 -65
View File
@@ -1,45 +1,6 @@
<script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import * as Popover from '$lib/components/ui/popover';
// each group maps a stored unit key to its selectable options
const UNIT_GROUPS: {
key: keyof UnitPrefs;
label: string;
options: { value: string; label: string }[];
}[] = [
{
key: 'temperature_unit',
label: 'Temperature',
options: [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' }
]
},
{
key: 'wind_speed_unit',
label: 'Wind speed',
options: [
{ value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' },
{ value: 'mph', label: 'mph' },
{ value: 'kn', label: 'kn' }
]
},
{
key: 'precipitation_unit',
label: 'Precipitation',
options: [
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
import UnitOptions from '$lib/components/unit-options.svelte';
</script>
<Popover.Root>
@@ -65,30 +26,6 @@
</svg>
</Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border">
<div class="flex flex-col gap-4">
{#each UNIT_GROUPS as group (group.key)}
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{group.label}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)}
{@const active = $storedUnits[group.key] === opt.value}
<button
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => setUnit(group.key, opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
<UnitOptions />
</Popover.Content>
</Popover.Root>
+8 -8
View File
@@ -2,8 +2,8 @@
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
import { isPremium, premiumState, refreshPremium } from './premium';
import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config';
import { isSupporter, supporterState, refreshSupporter } from './supporter';
interface Props {
/** Short feature name shown in the locked panel headline. */
@@ -14,16 +14,16 @@
let { feature = 'This page', children }: Props = $props();
let unlockOpen = $state(false);
const price = getPremiumPrice();
const price = getSupporterPrice();
// Re-verify the stored key whenever the gate mounts.
onMount(refreshPremium);
onMount(refreshSupporter);
// Show a brief spinner only when we have no cached answer yet and are checking.
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter);
</script>
{#if $isPremium}
{#if $isSupporter}
{@render children()}
{:else if initialChecking}
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
@@ -59,7 +59,7 @@
</p>
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
{#each PREMIUM_PERKS as perk (perk)}
{#each SUPPORTER_PERKS as perk (perk)}
<li class="flex items-start gap-2.5 text-sm">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
@@ -93,7 +93,7 @@
</button>
</div>
{#if $premiumState.status === 'invalid'}
{#if $supporterState.status === 'invalid'}
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
Your saved key is no longer valid or has expired.
</p>
@@ -2,31 +2,31 @@
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isPremium, refreshPremium } from './premium';
import { isSupporter, refreshSupporter } from './supporter';
let open = $state(false);
// Verify once on load so the badge reflects real status site-wide.
onMount(refreshPremium);
onMount(refreshSupporter);
</script>
<button
type="button"
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isPremium
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isSupporter
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => (open = true)}
title={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
aria-label={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
>
{#if $isPremium}
{#if $isSupporter}
<!-- star -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/>
</svg>
<span class="hidden sm:inline">Premium</span>
<span class="hidden sm:inline">Supporter</span>
{:else}
<!-- padlock -->
<svg
@@ -40,7 +40,7 @@
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
<span class="hidden sm:inline">Premium</span>
<span class="hidden sm:inline">Supporter</span>
{/if}
</button>
+9 -9
View File
@@ -6,8 +6,8 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { SIGNUP_URL, getPremiumPrice } from './config';
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
import { SIGNUP_URL, getSupporterPrice } from './config';
import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter';
interface Props {
open?: boolean;
@@ -15,7 +15,7 @@
let { open = $bindable(false) }: Props = $props();
const price = getPremiumPrice();
const price = getSupporterPrice();
// Prefill with the stored key so an existing subscriber sees their key.
let keyInput = $state('');
@@ -50,8 +50,8 @@
}
let expiresLabel = $derived.by(() => {
const exp = $premiumState.expires;
if ($premiumState.status !== 'valid') return null;
const exp = $supporterState.expires;
if ($supporterState.status !== 'valid') return null;
if (!exp) return 'Lifetime access';
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
});
@@ -66,7 +66,7 @@
</Dialog.Description>
</Dialog.Header>
{#if $isPremium && $premiumState.status === 'valid'}
{#if $isSupporter && $supporterState.status === 'valid'}
<div
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
>
@@ -81,7 +81,7 @@
<circle cx="12" cy="12" r="9" />
</svg>
<div>
<p class="font-semibold">Premium is active</p>
<p class="font-semibold">Supporter extras are active</p>
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
</div>
</div>
@@ -102,7 +102,7 @@
{#if localError}
<p class="text-sm text-destructive">{localError}</p>
{:else if $premiumState.status === 'error'}
{:else if $supporterState.status === 'error'}
<p class="text-sm text-destructive">
Couldn't reach the server. Check your connection and try again.
</p>
@@ -114,7 +114,7 @@
</form>
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
{#if $isPremium}
{#if $isSupporter}
<button
type="button"
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+9 -5
View File
@@ -28,9 +28,9 @@ const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CH
const US_ZONES =
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
export type SupporterCurrency = 'EUR' | 'USD' | 'CHF';
export function detectCurrency(): PremiumCurrency {
export function detectCurrency(): SupporterCurrency {
if (typeof Intl === 'undefined') return 'EUR';
let tz = '';
try {
@@ -53,15 +53,19 @@ export function detectCurrency(): PremiumCurrency {
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
export function getPremiumPrice(): string {
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working.
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
if (configured) return configured;
const currency = detectCurrency();
const symbol = CURRENCY_SYMBOL[currency];
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
}
/** Short, human list of what supporting unlocks (shown on the locked panel). */
export const PREMIUM_PERKS = [
export const SUPPORTER_PERKS = [
'Historical weather & climate-normal comparisons',
'Seasonal outlook: months ahead vs the climate normal',
'New supporter extras as they land'
];
@@ -1,5 +1,5 @@
/**
* Premium (subscription) state.
* Supporter (subscription) state.
*
* The user pastes an access key once; it is stored locally and re-verified
* against the self-hosted verify API on load. The last good result is cached so
@@ -16,10 +16,10 @@ import { persisted } from 'svelte-persisted-store';
import { PAYWALL_API_BASE } from './config';
/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', '');
export interface PremiumCache {
export interface SupporterCache {
valid: boolean;
tier?: string;
/** ISO date the subscription lapses, or null for a lifetime key. */
@@ -28,20 +28,40 @@ export interface PremiumCache {
checkedAt: number;
}
const CACHE_KEY = 'supporter_cache_v1';
const LEGACY_CACHE_KEY = 'premium_cache_v1';
/**
* Carries the pre-rename cache over on first load, so supporters who already
* verified aren't shown a locked page while the key re-verifies.
*/
function readLegacyCache(): SupporterCache | null {
if (typeof localStorage === 'undefined') return null;
try {
if (localStorage.getItem(CACHE_KEY)) return null;
const legacy = localStorage.getItem(LEGACY_CACHE_KEY);
if (!legacy) return null;
localStorage.removeItem(LEGACY_CACHE_KEY);
return JSON.parse(legacy) as SupporterCache;
} catch {
return null;
}
}
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
export interface PremiumState {
status: PremiumStatus;
export interface SupporterState {
status: SupporterStatus;
tier?: string;
expires?: string | null;
error?: string;
}
/** Live verification state for the current session. */
export const premiumState = writable<PremiumState>({ status: 'idle' });
export const supporterState = writable<SupporterState>({ status: 'idle' });
function notExpired(expires: string | null | undefined): boolean {
if (!expires) return true; // lifetime key
@@ -50,12 +70,12 @@ function notExpired(expires: string | null | undefined): boolean {
}
/**
* Whether premium content should be shown. A live "valid"/"invalid" result wins;
* otherwise we fall back to the cached result (so a reload or a brief network
* blip doesn't lock a paying user out).
* Whether supporter content should be shown. A live "valid"/"invalid" result
* wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out).
*/
export const isPremium = derived(
[premiumState, storedPremiumCache],
export const isSupporter = derived(
[supporterState, storedSupporterCache],
([$state, $cache]): boolean => {
if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false;
@@ -78,11 +98,11 @@ export interface VerifyResult {
export async function verifyKey(key: string): Promise<VerifyResult> {
const trimmed = key.trim();
if (!trimmed) {
premiumState.set({ status: 'invalid' });
supporterState.set({ status: 'invalid' });
return { valid: false, error: 'Enter your access key.' };
}
premiumState.set({ status: 'checking' });
supporterState.set({ status: 'checking' });
try {
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' }
@@ -91,39 +111,39 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
if (res.ok && data.valid) {
storedLicenseKey.set(trimmed);
storedPremiumCache.set({
storedSupporterCache.set({
valid: true,
tier: data.tier,
expires: data.expires ?? null,
checkedAt: Date.now()
});
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
return { valid: true, tier: data.tier, expires: data.expires ?? null };
}
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
premiumState.set({ status: 'invalid' });
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
supporterState.set({ status: 'invalid' });
return { valid: false };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
premiumState.set({ status: 'error', error: message });
supporterState.set({ status: 'error', error: message });
return { valid: false, error: message };
}
}
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshPremium(): Promise<void> {
export async function refreshSupporter(): Promise<void> {
const key = get(storedLicenseKey);
if (!key) {
premiumState.set({ status: 'idle' });
supporterState.set({ status: 'idle' });
return;
}
await verifyKey(key);
}
/** Forget the key and premium state ("sign out"). */
/** Forget the key and supporter state ("sign out"). */
export function clearLicense(): void {
storedLicenseKey.set('');
storedPremiumCache.set(null);
premiumState.set({ status: 'idle' });
storedSupporterCache.set(null);
supporterState.set({ status: 'idle' });
}
+195
View File
@@ -23,6 +23,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
// ─── Core Helpers ───────────────────────────────────────────────────────────────
@@ -1143,3 +1144,197 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
precipitation_unit: params.precipitation_unit ?? 'mm'
};
}
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
/**
* One daily variable of the seasonal ensemble: every member plus the spread
* statistics the outlook renders (percentile band, mean, extremes).
*/
export interface SeasonalVariableData {
/** Raw members, `members[m][t]`. */
members: number[][];
mean: number[];
min: number[];
max: number[];
p25: number[];
p75: number[];
unit: string;
}
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
dailyVariables?: string[];
/** Lead time in days; the API allows at most 216. */
forecast_days?: number;
}
export interface SeasonalForecastResult {
variables: Record<string, SeasonalVariableData>;
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
timestamps: number[];
/**
* Local wall time (local midnight) expressed as a UTC instant - read these
* with the UTC getters, never with the location's IANA zone. The seasonal API
* keeps ONE offset for the whole series, so a half-year range that crosses a
* DST change would otherwise land two days on the same local date.
*/
dailyDates: Date[];
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
dateKeys: string[];
memberCount: number;
utcOffsetSeconds: number;
timezone: string;
}
/** The API caps the lead time here; the model itself usually stops earlier. */
export const SEASONAL_MAX_DAYS = 216;
/** Requested in this order; the daily block returns variables positionally. */
export const SEASONAL_DAILY_VARS = [
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'precipitation_sum',
'wind_speed_10m_mean',
'cloud_cover_mean'
] as const;
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
/** Linear-interpolated percentile over an already ascending array. */
function percentileSorted(sorted: number[], p: number): number {
if (sorted.length === 0) return NaN;
if (sorted.length === 1) return sorted[0];
const pos = (sorted.length - 1) * p;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
/**
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
* seasonal API. Unlike the medium-range ensemble this is daily data: each
* requested variable comes back once per member, so the members are collapsed
* into the spread statistics the outlook page plots.
*
* The requested lead time is only an upper bound - the model's own horizon is
* shorter, and every day past it comes back empty. Those trailing days are
* trimmed here so callers never plot a flat-lined tail.
*/
export async function fetchSeasonalForecast(
params: SeasonalForecastParams
): Promise<SeasonalForecastResult> {
const dailyVars =
params.dailyVariables && params.dailyVariables.length > 0
? [...new Set(params.dailyVariables)]
: [...SEASONAL_DAILY_VARS];
const apiParams: Record<string, string | number | undefined> = {
latitude: params.latitude,
longitude: params.longitude,
daily: dailyVars.join(','),
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: params.timezone
};
const cleanParams: Record<string, string> = {};
for (const [key, value] of Object.entries(apiParams)) {
if (value !== undefined) cleanParams[key] = String(value);
}
const responses = await fetchWeatherApi(SEASONAL_URL, cleanParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
const dailyBlock = response.daily()!;
const allTimestamps = getTimestamps(dailyBlock);
const timeLength = allTimestamps.length;
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
// var1_member0 …, so the count follows from the totals instead of being
// hard-coded (it differs per seasonal model).
const totalVariables = dailyBlock.variablesLength();
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
const variables: Record<string, SeasonalVariableData> = {};
for (let vi = 0; vi < dailyVars.length; vi++) {
const members: number[][] = [];
let unitStr = '';
for (let mi = 0; mi < memberCount; mi++) {
const variable = dailyBlock.variables(vi * memberCount + mi);
if (!variable) continue;
members.push(getValues(variable));
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
}
const mean = new Array<number>(timeLength).fill(NaN);
const min = new Array<number>(timeLength).fill(NaN);
const max = new Array<number>(timeLength).fill(NaN);
const p25 = new Array<number>(timeLength).fill(NaN);
const p75 = new Array<number>(timeLength).fill(NaN);
for (let t = 0; t < timeLength; t++) {
const values: number[] = [];
for (const memberValues of members) {
const val = memberValues[t];
if (val != null && Number.isFinite(val)) values.push(val);
}
if (values.length === 0) continue;
values.sort((a, b) => a - b);
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
min[t] = values[0];
max[t] = values[values.length - 1];
p25[t] = percentileSorted(values, 0.25);
p75[t] = percentileSorted(values, 0.75);
}
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
}
// Past the model's horizon every member is empty (or padded to a constant
// zero); cut the axis at the last day that carries real spread.
const sentinel = variables[dailyVars[0]];
let validLength = timeLength;
if (sentinel) {
let last = 0;
for (let t = 0; t < timeLength; t++) {
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
}
validLength = last || timeLength;
}
if (validLength < timeLength) {
for (const data of Object.values(variables)) {
data.members = data.members.map((m) => m.slice(0, validLength));
data.mean = data.mean.slice(0, validLength);
data.min = data.min.slice(0, validLength);
data.max = data.max.slice(0, validLength);
data.p25 = data.p25.slice(0, validLength);
data.p75 = data.p75.slice(0, validLength);
}
}
const timestamps = allTimestamps.slice(0, validLength);
// Shifted by the response's single offset (not the IANA zone) so each day
// carries the exact local date the API labelled it with.
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
return {
variables,
timestamps,
dailyDates,
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
memberCount,
utcOffsetSeconds,
timezone
};
}
@@ -13,7 +13,7 @@
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isPremium } from '$lib/paywall/premium';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
@@ -97,7 +97,7 @@
const s = startDate;
const e = endDate;
const vars = hourlyVars;
if (!mounted || !$isPremium || !loc || !s || !e) return;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion;
loading = true;
@@ -138,7 +138,7 @@
$effect(() => {
const key = normalsKey;
const loc = location;
if (!mounted || !$isPremium || !loc) return;
if (!mounted || !$isSupporter || !loc) return;
const version = ++normalsVersion;
normals = null;
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/seasonal/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
);
});
</script>
@@ -0,0 +1,296 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
import { getPrecipUnit } from '../../week/[location]/types';
import SeasonalCharts from './SeasonalCharts.svelte';
import SeasonalMonths from './SeasonalMonths.svelte';
import { buildMonthOutlooks, sliceSeasonal } from './outlook';
import type { CanvasChart } from '$lib/charts';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// 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 params = $state({ ...defaultParameters });
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// Only the variables the outlook actually renders: every extra one costs a
// full member set (50+ series) over half a year of days.
const SEASONAL_VARS = [
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'precipitation_sum'
];
// ─── Display state (no refetch) ─────────────────────────────────────────────
const RANGES = [
{ label: '3 months', days: 92 },
{ label: '6 months', days: 183 },
{ label: 'Full range', days: Infinity }
];
let rangeIndex = $state(1);
let showLegend = $state(true);
let chartComponents: CanvasChart[] = $state([]);
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Fetch state ────────────────────────────────────────────────────────────
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let result = $state<SeasonalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
onMount(() => {
mounted = true;
});
// The full horizon is fetched once per location/units; the range buttons only
// reslice it.
$effect(() => {
const loc = location;
const tempUnit = params.temperature_unit;
const precipUnit = params.precipitation_unit;
if (!mounted || !$isSupporter || !loc) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchSeasonalForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
dailyVariables: SEASONAL_VARS,
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: precipUnit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((r) => {
if (version !== requestVersion) return;
result = r;
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// Normals are the whole point of a seasonal outlook (everything is shown as a
// departure from them), but a failure only drops the comparison.
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(() => {
if (version === normalsVersion) normals = null;
});
void key;
});
// ─── Derived view ───────────────────────────────────────────────────────────
let visible = $derived(result ? sliceSeasonal(result, RANGES[rangeIndex].days) : null);
// 1 mm and 0.04 in are the same "measurable rain" threshold in either unit.
let wetDayThreshold = $derived(getPrecipUnit(params) === 'in' ? 0.04 : 1);
let months = $derived(visible ? buildMonthOutlooks(visible, normals, { wetDayThreshold }) : []);
let horizonDays = $derived(result?.timestamps.length ?? 0);
let lastDay = $derived(
visible && visible.dailyDates.length > 0
? visible.dailyDates[visible.dailyDates.length - 1]
: null
);
// dailyDates are local wall time held as UTC instants (see the service), so
// the label has to be formatted in UTC to read back the local date.
let lastDayLabel = $derived(
lastDay
? lastDay.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
})
: ''
);
</script>
<svelte:head>
<title>Drizz.li | Seasonal forecast</title>
<meta
name="description"
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
/>
</svelte:head>
<!-- Page hero -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<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">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>Seasonal outlook
</p>
</div>
</div>
{#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="Outlook range"
>
{#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}
</div>
<!-- What a seasonal forecast is (and is not): without this the daily-looking
charts invite over-reading. -->
<div
class="mb-4 flex items-start gap-2.5 rounded-md border border-border bg-muted/40 px-3.5 py-2.5 text-sm"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01" />
<circle cx="12" cy="12" r="9" />
</svg>
<p class="text-muted-foreground">
A seasonal forecast shows how a whole month is likely to
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
wiggles.
{#if lastDayLabel}
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
{/if}
</p>
</div>
<PaywallGate feature="The seasonal outlook">
{#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 the seasonal outlook: {loadError}
</div>
{/if}
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
<div class="mt-6">
<SeasonalCharts
result={visible}
{normals}
units={params}
{loading}
{showLegend}
bind:charts={chartComponents}
/>
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>Show legend</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
</div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</PaywallGate>
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/seasonal/',
event
});
return { location };
};
@@ -0,0 +1,211 @@
<script lang="ts">
import { ChartContainer } from '$lib/components/charts';
import { CHART_COLORS, CanvasChart, type ChartSeries } from '$lib/charts';
import {
type ClimateNormals,
type SeasonalForecastResult,
monthDayToOrdinal
} from '$lib/services/weather';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
import { rollingMean, rollingSum } from './outlook';
interface Props {
result: SeasonalForecastResult;
normals: ClimateNormals | null;
units: WeatherUnits;
loading?: boolean;
showLegend?: boolean;
/** Smoothing window in days, shared by both charts. */
smoothing?: number;
charts?: CanvasChart[];
}
let {
result,
normals,
units,
loading = false,
showLegend = true,
smoothing = 7,
charts = $bindable([])
}: Props = $props();
const CHART_GROUP = 'seasonal-outlook';
const SPREAD_COLOR = 'rgba(115, 192, 222, 0.75)';
const BAND_COLOR = 'rgba(56, 132, 189, 0.9)';
const NORMAL_COLOR = '#9ca3af';
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
// CanvasChart works in epoch seconds. The axis is fed local wall time (and
// labelled in UTC) because the seasonal series carries a single fixed offset:
// resolving it against the IANA zone would slide every date past a DST change.
let timestamps = $derived(result.dailyDates.map((d) => d.getTime() / 1000));
// Climate normal for each forecast day, so it can be drawn as a reference
// line on the same axis as the ensemble.
let ordinals = $derived(
result.dateKeys.map((key) =>
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
)
);
let normalTemp = $derived(normals ? ordinals.map((o) => normals.tmean[o]) : null);
let normalPrecip = $derived(normals ? ordinals.map((o) => normals.precip[o]) : null);
const fmt =
(unit: string, digits = 1) =>
(v: number) =>
`${v.toFixed(digits)} ${unit}`;
let tempSeries = $derived.by((): ChartSeries[] => {
const v = result.variables['temperature_2m_mean'];
if (!v) return [];
const smooth = (xs: number[]) => rollingMean(xs, smoothing);
const series: ChartSeries[] = [
{
name: 'Full spread',
type: 'line',
color: SPREAD_COLOR,
data: smooth(v.max),
width: 0,
fill: true,
fillOpacity: 0.16,
bandTo: smooth(v.min),
shortName: 'spread',
format: fmt(tempUnit)
},
{
name: 'Likely range (25-75%)',
type: 'line',
color: BAND_COLOR,
data: smooth(v.p75),
width: 0,
fill: true,
fillOpacity: 0.3,
bandTo: smooth(v.p25),
shortName: 'likely',
format: fmt(tempUnit)
},
{
name: 'Ensemble mean',
type: 'line',
color: CHART_COLORS.average,
data: smooth(v.mean),
width: 3,
outline: true,
format: fmt(tempUnit)
}
];
if (normalTemp) {
series.push({
name: 'Climate normal',
type: 'line',
color: NORMAL_COLOR,
data: smooth(normalTemp),
width: 2,
dashed: true,
shortName: 'normal',
format: fmt(tempUnit)
});
}
return series;
});
let precipSeries = $derived.by((): ChartSeries[] => {
const v = result.variables['precipitation_sum'];
if (!v) return [];
const total = (xs: number[]) => rollingSum(xs, smoothing);
const series: ChartSeries[] = [
{
name: 'Likely range (25-75%)',
type: 'line',
color: BAND_COLOR,
data: total(v.p75),
width: 0,
fill: true,
fillOpacity: 0.28,
bandTo: total(v.p25),
shortName: 'likely',
format: fmt(precipUnit)
},
{
name: 'Ensemble mean',
type: 'line',
color: CHART_COLORS.average,
data: total(v.mean),
width: 3,
outline: true,
format: fmt(precipUnit)
}
];
if (normalPrecip) {
series.push({
name: 'Climate normal',
type: 'line',
color: NORMAL_COLOR,
data: total(normalPrecip),
width: 2,
dashed: true,
shortName: 'normal',
format: fmt(precipUnit)
});
}
return series;
});
let chartDefs = $derived(
[
{
title: 'Temperature outlook',
subtitle: `${smoothing}-day smoothed daily mean · ${result.memberCount} ensemble members`,
unit: tempUnit,
series: tempSeries,
zeroBaseLeft: false,
showCredit: false
},
{
title: 'Precipitation outlook',
subtitle: `${smoothing}-day rolling total (${precipUnit})`,
unit: precipUnit,
series: precipSeries,
zeroBaseLeft: true,
showCredit: true
}
].filter((def) => def.series.length > 0)
);
</script>
<!-- full-bleed graphs until lg / contained card on lg+ (matches the 14-day page) -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (def.title)}
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={charts[i]}
{timestamps}
timezone="UTC"
series={def.series}
unit={def.unit}
zeroBaseLeft={def.zeroBaseLeft}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
@@ -0,0 +1,150 @@
<script lang="ts">
import { getColor } from '../../utils/colors';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
import type { ClimateNormals } from '$lib/services/weather';
import type { MonthOutlook } from './outlook';
interface Props {
months: MonthOutlook[];
units: WeatherUnits;
normals: ClimateNormals | null;
}
let { months, units, normals }: Props = $props();
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '');
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)}`;
// Members agreeing on the sign of the anomaly is the honest confidence signal
// for a seasonal outlook: a big anomaly that only half the members share means
// nothing. Below this the card says so instead of implying a trend.
const AGREEMENT_FLOOR = 0.6;
function anomalyLabel(delta: number): string {
const a = Math.abs(delta);
if (a < 0.3) return 'near normal';
const word = a < 1 ? 'slightly' : a < 2.5 ? '' : 'well';
return `${word} ${delta > 0 ? 'above' : 'below'} normal`.replace(/\s+/g, ' ').trim();
}
</script>
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each months as month (month.key)}
{@const agree = Math.max(month.warmerShare, 1 - month.warmerShare)}
{@const confident = agree >= AGREEMENT_FLOOR}
<article class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-base font-bold tracking-tight">{month.label}</h3>
<span class="text-xs text-muted-foreground">
{month.days}
{month.days === 1 ? 'day' : 'days'}{month.partial ? ' (partial)' : ''}
</span>
</div>
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
<div class="mt-2.5 flex items-end gap-3">
<div>
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
<p class="text-2xl leading-tight font-bold tabular-nums">
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
>
</p>
</div>
<div class="ml-auto text-right">
{#if finite(month.anomaly)}
<p
class="text-lg leading-tight font-bold tabular-nums"
class:text-red-600={month.anomaly >= 0}
class:text-blue-600={month.anomaly < 0}
class:dark:text-red-400={month.anomaly >= 0}
class:dark:text-blue-400={month.anomaly < 0}
>
{fmtSigned(month.anomaly)}
</p>
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
{:else if normals}
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
{:else}
<p class="text-[11px] text-muted-foreground">normal loading…</p>
{/if}
</div>
</div>
<!-- day / night means, coloured on the same temperature scale as the strip -->
<div class="mt-2.5 flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
style="background:{getColor(month.tMax, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
<span class="text-muted-foreground">day</span>
</span>
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
style="background:{getColor(month.tMin, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
<span class="text-muted-foreground">night</span>
</span>
</div>
<!-- precipitation against its own normal, as a share bar -->
<div class="mt-3 border-t border-border/60 pt-2.5">
<div class="flex items-baseline justify-between gap-2 text-xs">
<span class="font-medium text-muted-foreground">Precipitation</span>
<span class="font-semibold tabular-nums">
{fmtPrecip(month.precip)}
{precipUnit}
{#if finite(month.precipNormal)}
<span class="font-medium text-muted-foreground">
/ normal {fmtPrecip(month.precipNormal)}
</span>
{/if}
</span>
</div>
{#if finite(month.precipShare)}
{@const pct = Math.min(200, month.precipShare * 100)}
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full"
style="width:{pct / 2}%;background:{month.precipShare >= 1
? 'var(--color-sky-500, #0ea5e9)'
: 'var(--color-amber-500, #f59e0b)'}"
></div>
</div>
<p class="mt-1 text-[11px] text-muted-foreground">
{Math.round(month.precipShare * 100)}% of normal · {month.wetDays} wet days
</p>
{/if}
</div>
<!-- ensemble agreement: the actual confidence in the anomaly above -->
<div class="mt-2.5 flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full"
class:bg-primary={confident}
class:bg-muted-foreground={!confident}
style="width:{Math.round(agree * 100)}%"
></div>
</div>
<span class="text-[11px] whitespace-nowrap text-muted-foreground">
{#if confident}
{Math.round(agree * 100)}% of members {month.warmerShare >= 0.5 ? 'warmer' : 'colder'}
{:else}
members split - low confidence
{/if}
</span>
</div>
</article>
{/each}
</div>
@@ -0,0 +1,199 @@
/**
* Turns the raw seasonal ensemble into the per-calendar-month outlook the page
* shows. Seasonal models carry no day-to-day skill, so everything here is an
* aggregate: monthly means, the departure from the climate normal, and how much
* of the ensemble actually agrees on that departure.
*/
import {
type ClimateNormals,
type SeasonalForecastResult,
monthDayToOrdinal
} from '$lib/services/weather';
export interface MonthOutlook {
/** `YYYY-MM` in the location's local calendar. */
key: string;
/** Month name and year, e.g. "August 2026". */
label: string;
/** Forecast days covered (a leading/trailing month is usually incomplete). */
days: number;
partial: boolean;
tMean: number;
tMax: number;
tMin: number;
/** Mean temperature minus the climate normal, or null without normals. */
anomaly: number | null;
precip: number;
precipNormal: number | null;
/** Forecast precipitation as a share of normal (1 = exactly normal). */
precipShare: number | null;
wetDays: number;
/** Share of ensemble members whose monthly mean is above the normal. */
warmerShare: number;
}
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
// The month key already carries the local calendar month, so the label is
// formatted in UTC - anything zone-aware would just reintroduce the shift.
const MONTH_LABEL = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
timeZone: 'UTC'
});
const mean = (xs: number[]): number =>
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
/** Days in a calendar month; `month` is 1-based. */
function daysInMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
interface BuildOptions {
/** Daily total counted as a wet day, in the active precipitation unit. */
wetDayThreshold: number;
}
export function buildMonthOutlooks(
result: SeasonalForecastResult,
normals: ClimateNormals | null,
{ wetDayThreshold }: BuildOptions
): MonthOutlook[] {
const tMeanVar = result.variables['temperature_2m_mean'];
const tMaxVar = result.variables['temperature_2m_max'];
const tMinVar = result.variables['temperature_2m_min'];
const precipVar = result.variables['precipitation_sum'];
if (!tMeanVar) return [];
const { dateKeys } = result;
// Day indices grouped by calendar month, plus the normals lookup per day.
// Both read the API's own local dates: deriving them from the instants would
// double-count the day a DST change falls on.
const groups = new Map<string, number[]>();
const ordinals = dateKeys.map((key) =>
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
);
for (let i = 0; i < dateKeys.length; i++) {
const key = dateKeys[i].slice(0, 7);
const bucket = groups.get(key);
if (bucket) bucket.push(i);
else groups.set(key, [i]);
}
const months: MonthOutlook[] = [];
for (const [key, indices] of groups) {
const pick = (values: number[] | undefined): number =>
values ? mean(indices.map((i) => values[i]).filter(finite)) : NaN;
const [year, month] = key.split('-').map(Number);
// Normals are per day-of-year, so the comparison uses exactly the days the
// forecast covers - a half month is never compared against a full one.
let normalMean = NaN;
let normalPrecip = NaN;
if (normals) {
normalMean = mean(indices.map((i) => normals.tmean[ordinals[i]]).filter(finite));
const np = indices.map((i) => normals.precip[ordinals[i]]).filter(finite);
if (np.length) normalPrecip = np.reduce((a, b) => a + b, 0);
}
const tMean = pick(tMeanVar.mean);
// Per-member monthly means decide the agreement share: a member counts as
// warmer only if it beats the same normal the anomaly is measured against.
let warmer = 0;
let counted = 0;
if (finite(normalMean)) {
for (const member of tMeanVar.members) {
const memberMean = mean(indices.map((i) => member[i]).filter(finite));
if (!finite(memberMean)) continue;
counted++;
if (memberMean > normalMean) warmer++;
}
}
const precipDays = precipVar ? indices.map((i) => precipVar.mean[i]).filter(finite) : [];
const precip = precipDays.reduce((a, b) => a + b, 0);
months.push({
key,
label: MONTH_LABEL.format(Date.UTC(year, month - 1, 1)),
days: indices.length,
partial: indices.length < daysInMonth(year, month),
tMean,
tMax: pick(tMaxVar?.mean),
tMin: pick(tMinVar?.mean),
anomaly: finite(normalMean) && finite(tMean) ? tMean - normalMean : null,
precip,
precipNormal: finite(normalPrecip) ? normalPrecip : null,
precipShare: finite(normalPrecip) && normalPrecip > 0 ? precip / normalPrecip : null,
wetDays: precipDays.filter((p) => p >= wetDayThreshold).length,
warmerShare: counted > 0 ? warmer / counted : 0.5
});
}
return months;
}
/**
* Narrows an outlook to its first `days` days. The fetch always asks for the
* model's full horizon, so the range buttons only reslice what is already in
* memory instead of issuing another (large) request.
*/
export function sliceSeasonal(
result: SeasonalForecastResult,
days: number
): SeasonalForecastResult {
if (days >= result.timestamps.length) return result;
const variables: SeasonalForecastResult['variables'] = {};
for (const [name, data] of Object.entries(result.variables)) {
variables[name] = {
members: data.members.map((m) => m.slice(0, days)),
mean: data.mean.slice(0, days),
min: data.min.slice(0, days),
max: data.max.slice(0, days),
p25: data.p25.slice(0, days),
p75: data.p75.slice(0, days),
unit: data.unit
};
}
return {
...result,
variables,
timestamps: result.timestamps.slice(0, days),
dailyDates: result.dailyDates.slice(0, days),
dateKeys: result.dateKeys.slice(0, days)
};
}
/**
* Centered rolling mean. Seasonal ensembles are far too noisy to read day by
* day; smoothing shows the trend the model actually claims to resolve. Windows
* shrink at the edges instead of dropping data.
*/
export function rollingMean(values: number[], window: number): number[] {
const half = Math.floor(window / 2);
return values.map((_, i) => {
let sum = 0;
let count = 0;
for (let k = i - half; k <= i + half; k++) {
const v = values[k];
if (finite(v)) {
sum += v;
count++;
}
}
return count > 0 ? sum / count : NaN;
});
}
/** Rolling total over the same window (used for precipitation). */
export function rollingSum(values: number[], window: number): number[] {
return rollingMean(values, window).map((v) => (finite(v) ? v * window : NaN));
}
@@ -12,6 +12,8 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { ChartContainer } from '$lib/components/charts';
import {
@@ -76,6 +78,10 @@
storedLocation.set(data.location);
});
// Lets the strip's side buttons hand over to the archive / seasonal outlook
// for the same place once their range is exhausted.
let locationRoute = $derived(buildLocationRoute(location));
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<FriendlyWeatherError | null>(null);
@@ -318,6 +324,7 @@
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
{locationRoute}
/>
{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
@@ -23,6 +25,11 @@
onExtend?: () => void;
canExtendPast?: boolean;
onExtendPast?: () => void;
/**
* Location segment for the hand-off links that replace the side buttons
* once their range is exhausted. Omit to leave the slots empty.
*/
locationRoute?: string;
}
let {
@@ -33,7 +40,8 @@
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
onExtendPast,
locationRoute
}: Props = $props();
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
@@ -82,10 +90,16 @@
let scrolledForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
scrolledForRef = d;
const scroll = stripScrollEl;
const wrap = daysWrapEl;
if (!canExtendPast) {
// the past button has been replaced by the history link, which is a
// destination rather than a control: it stays in view instead of parked
scroll.scrollLeft = 0;
return;
}
// the delta form is self-correcting (a no-op once right), so apply after
// layout and once more after late-loading CSS/fonts settle — otherwise a
// sliver of the past button can stay visible on first paint
@@ -110,7 +124,7 @@
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
class:js-snap={needsSnapFallback}
class:compact
class:stuck
@@ -122,7 +136,7 @@
match the 15-days button on the other end -->
<button
type="button"
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtendPast}
aria-label="Load the past 3 days"
title="Load the past 3 days"
@@ -141,6 +155,31 @@
</span>
<span class="text-[9px] leading-tight font-semibold">past</span>
</button>
{:else if locationRoute}
<!-- the past days are already in the strip: the only way further back
is the archive, so the button hands over to it -->
<a
href={resolve('/weather/historical/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the historical weather archive"
title="Historical weather: any past date back to 1940"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
/>
</svg>
<span class="text-[9px] leading-tight font-semibold">history</span>
</a>
{/if}
<div class="strip-days flex" bind:this={daysWrapEl}>
@@ -322,6 +361,27 @@
</span>
<span class="text-[9px] leading-tight font-semibold">days</span>
</button>
{:else if locationRoute}
<!-- the strip is at the model's limit: anything further out is a
seasonal outlook, so the button hands over to it -->
<a
href={resolve('/weather/seasonal/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the seasonal outlook"
title="Seasonal outlook: monthly trends for the months ahead"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
</svg>
<span class="text-[9px] leading-tight font-semibold">seasonal</span>
</a>
{/if}
</div>
{/if}
@@ -514,7 +574,7 @@
(Browsers too old to register --strip-p simply switch instantly.) */
.daystrip.js-snap {
transition:
--strip-p 0.28s ease,
--strip-p 0.32s ease,
--stuck 0.15s ease;
}
.daystrip.js-snap.compact {
@@ -527,7 +587,7 @@
/* larger cards need a touch longer to feel smooth */
.daystrip.js-snap {
transition:
--strip-p 0.55s ease,
--strip-p 0.42s ease,
--stuck 0.15s ease;
}
}
@@ -549,13 +609,14 @@
}
/* Scroll containers clip at the PADDING edge, so a parked past button would
always leak a sliver across the row's left padding. Extending the days
group's left edge (only while the past button exists) moves max-scroll so
the button parks fully beyond the clip edge. */
.strip-side + .strip-days {
group's left edge (only while the parked past button exists) moves
max-scroll so the button parks fully beyond the clip edge. The history
link that replaces it is never parked, so it keeps the normal gap. */
.strip-park + .strip-days {
padding-left: calc(12px - var(--gap-min));
}
@media (min-width: 1024px) {
.strip-side + .strip-days {
.strip-park + .strip-days {
padding-left: calc(32px - var(--gap-min));
}
}