This commit is contained in:
Vincent van der Wal
2026-08-01 15:07:28 +02:00
parent 9806396476
commit 774afa6c65
45 changed files with 1515 additions and 239 deletions
+3 -1
View File
@@ -11,6 +11,8 @@
import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import './layout.css';
let { children } = $props();
@@ -40,7 +42,7 @@
});
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
let sidebarCollapsed = $state(false);
let mobileMenuOpen = $state(false);
+5 -1
View File
@@ -1,7 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
// bare `/` lands in the locale the strategy resolved (URL, cookie, then the
// browser's own languages), not always English
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad;
+10 -7
View File
@@ -5,6 +5,9 @@
import { storedLocation } from '$lib/stores/settings';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte';
interface Props {
@@ -29,15 +32,15 @@
// load resolves (and any weather page that doesn't carry a location).
let location = $derived($page.data.location ?? $storedLocation);
const SUBTITLES: [string, string][] = [
['/weather/week', '7-day forecast'],
['/weather/compare', 'Model comparison'],
['/weather/14-day', '14-day ensemble forecast'],
['/weather/seasonal', 'Seasonal outlook'],
['/weather/historical', 'Historical weather']
const SUBTITLES: [string, () => string][] = [
['/weather/week', m.page_week_subtitle],
['/weather/compare', m.page_compare_subtitle],
['/weather/14-day', m.page_14day_subtitle],
['/weather/seasonal', m.page_seasonal_subtitle],
['/weather/historical', m.page_historical_subtitle]
];
let subtitle = $derived(
SUBTITLES.find(([prefix]) => $page.url.pathname.startsWith(prefix))?.[1] ?? null
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
</script>
+4 -1
View File
@@ -1,7 +1,10 @@
import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
// keep the visitor in their language when the bare /weather path is hit
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad;
+3 -2
View File
@@ -3,18 +3,19 @@
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';
import { href } from '$lib/i18n';
// 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/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
@@ -9,6 +9,7 @@
import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import {
type DaylightBand,
type EnsembleForecastResult,
@@ -281,7 +282,7 @@
<ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups}
label="Ensemble model"
label={m.model_ensemble()}
onModelChange={(model) => {
params.models = [model];
storedEnsembleModel.set(model);
+3 -2
View File
@@ -3,18 +3,19 @@
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';
import { href } from '$lib/i18n';
// 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/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
+3 -2
View File
@@ -3,18 +3,19 @@
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';
import { href } from '$lib/i18n';
// 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/historical/[location]', {
href('/weather/historical/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
+3 -2
View File
@@ -3,18 +3,19 @@
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';
import { href } from '$lib/i18n';
// 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]', {
href('/weather/seasonal/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
+5 -7
View File
@@ -3,21 +3,19 @@
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';
import { href } from '$lib/i18n';
// 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/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
);
goto(href('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }), {
replaceState: true
});
});
</script>
@@ -9,6 +9,8 @@
storedChartRange
} from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
interface Props {
@@ -22,11 +24,11 @@
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' },
{ value: 'today', label: 'Today', hint: 'Open on the current day' },
{ value: '3d', label: '3 days', hint: 'Open on the next three days' },
{ value: '5d', label: '5 days', hint: 'Open on the next five days' },
{ value: 'all', label: 'All', hint: 'Open on the full forecast' }
{ value: 'auto', label: m.default_range_auto(), hint: m.default_range_auto_hint() },
{ value: 'today', label: m.range_today(), hint: '' },
{ value: '3d', label: m.range_3_days(), hint: '' },
{ value: '5d', label: m.range_5_days(), hint: '' },
{ value: 'all', label: m.range_all(), hint: '' }
];
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
@@ -177,7 +179,7 @@
<div class="rounded-xl border border-border p-3">
<div class="mb-2 flex items-baseline justify-between gap-2">
<span class="text-[11px] font-bold tracking-wider text-primary uppercase">
Default time range
{m.default_range_title()}
</span>
<span class="text-[11px] text-muted-foreground">
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import {
@@ -142,8 +143,8 @@
type="button"
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"
aria-label={m.strip_past_aria()}
title={m.strip_past_aria()}
>
<span class="inline-flex items-baseline gap-0.5">
<svg
@@ -157,16 +158,16 @@
</svg>
<span class="text-sm leading-none font-extrabold">3</span>
</span>
<span class="text-[9px] leading-tight font-semibold">past</span>
<span class="text-[9px] leading-tight font-semibold">{m.strip_past_label()}</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 })}
href={href('/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"
aria-label={m.strip_history_aria()}
title={m.strip_history_title()}
>
<svg
class="h-4 w-4"
@@ -182,7 +183,7 @@
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>
<span class="text-[9px] leading-tight font-semibold">{m.strip_history_label()}</span>
</a>
{/if}
@@ -355,8 +356,8 @@
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"
onclick={onExtend}
aria-label="Show the full 15-day forecast"
title="Show the full 15-day forecast"
aria-label={m.strip_extend_aria()}
title={m.strip_extend_aria()}
>
<span class="inline-flex items-baseline gap-0.5">
<span class="text-sm leading-none font-extrabold">15</span>
@@ -370,16 +371,16 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
<span class="text-[9px] leading-tight font-semibold">days</span>
<span class="text-[9px] leading-tight font-semibold">{m.strip_days_label()}</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 })}
href={href('/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"
aria-label={m.strip_seasonal_aria()}
title={m.strip_seasonal_title()}
>
<svg
class="h-4 w-4"
@@ -391,7 +392,7 @@
<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>
<span class="text-[9px] leading-tight font-semibold">{m.strip_seasonal_label()}</span>
</a>
{/if}
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import {
buildDayNarrative,
moonIllumination,
@@ -28,7 +30,6 @@
daily.dailyDates.findIndex((d) => formatZoned(d, timezone, 'yyyy-MM-dd') === dayKey)
);
let relLabel = $derived(getRelativeDayLabel(selectedDay, timezone));
let isToday = $derived(relLabel === 'Today');
let sentences = $derived(
buildDayNarrative({
@@ -38,8 +39,7 @@
dailyDates: daily.dailyDates,
timezone,
day: selectedDay,
units,
isToday
units
})
);
@@ -70,8 +70,8 @@
let daylight = $derived.by(() => {
if (!finite(daylightSeconds)) return null;
const h = Math.floor(daylightSeconds / 3600);
const m = Math.round((daylightSeconds % 3600) / 60);
return `${h} h ${String(m).padStart(2, '0')} m`;
const min = Math.round((daylightSeconds % 3600) / 60);
return m.daylight_hours({ hours: h, minutes: String(min).padStart(2, '0') });
});
let sunshine = $derived.by(() => {
@@ -98,13 +98,13 @@
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
</script>
<section class="mt-6" aria-label="Written forecast">
<section class="mt-6" aria-label={m.summary_heading()}>
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold">
{formatZoned(selectedDay, timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground">
in words{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
{m.summary_heading()}{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
? ''
: ` (${relLabel})`}
</span>
@@ -118,7 +118,7 @@
</p>
{:else}
<p class="text-[15px] leading-relaxed text-muted-foreground">
No hourly detail available for this day.
{m.summary_no_data()}
</p>
{/if}
@@ -129,7 +129,7 @@
{#if sunrise}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Sunrise
{m.label_sunrise()}
</dt>
<dd class="font-semibold tabular-nums">{sunrise}</dd>
</div>
@@ -137,7 +137,7 @@
{#if sunset}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Sunset
{m.label_sunset()}
</dt>
<dd class="font-semibold tabular-nums">{sunset}</dd>
</div>
@@ -145,12 +145,14 @@
{#if daylight}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Daylight
{m.label_daylight()}
</dt>
<dd class="font-semibold tabular-nums">
{daylight}
{#if sunshine != null}
<span class="font-medium text-muted-foreground">· {sunshine}% sun</span>
<span class="font-medium text-muted-foreground"
>· {m.sunshine_share({ percent: sunshine })}</span
>
{/if}
</dd>
</div>
@@ -158,7 +160,7 @@
{#if finite(uv)}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
UV index
{m.label_uv_index()}
</dt>
<dd class="font-semibold tabular-nums">
{uv.toFixed(1)}
@@ -169,7 +171,7 @@
{#if moonrise}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moonrise
{m.label_moonrise()}
</dt>
<dd class="font-semibold tabular-nums">{moonrise}</dd>
</div>
@@ -177,7 +179,7 @@
{#if moonset}
<div>
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moonset
{m.label_moonset()}
</dt>
<dd class="font-semibold tabular-nums">{moonset}</dd>
</div>
@@ -196,7 +198,7 @@
</svg>
<div class="min-w-0">
<div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Moon
{m.label_moon()}
</div>
<div class="truncate font-semibold">
{moonPhaseName(phase!)}
@@ -12,6 +12,7 @@
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { groupHover, setGroupHover } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
@@ -402,7 +403,7 @@
>
<h3 class="text-base font-bold">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground"> hourly</span>
<span class="font-semibold text-muted-foreground"> {m.hourly_heading()}</span>
<span
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
>
@@ -13,6 +13,7 @@
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getWeatherIconName } from '../../utils/weather-codes';
import ChartCustomizer from './ChartCustomizer.svelte';
@@ -137,11 +138,13 @@
let rangePresets = $derived.by(() => {
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
return [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => showFullRange() }
{ label: m.range_today(), apply: () => setRangeDays(new Date(), 1) },
...(isToday
? []
: [{ label: m.range_selected_day(), apply: () => setRangeDays(selectedDay, 1) }]),
{ label: m.range_3_days(), apply: () => setRangeDays(new Date(), 3) },
{ label: m.range_5_days(), apply: () => setRangeDays(new Date(), 5) },
{ label: m.range_all(), apply: () => showFullRange() }
];
});
@@ -215,7 +218,7 @@
<section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
Meteograms
{m.meteograms_heading()}
<span class="font-semibold text-muted-foreground">
{formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
selectedDay,
@@ -231,12 +234,12 @@
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
+ scroll to zoom
{m.meteograms_zoom_hint_end()}
</span>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
aria-label={m.range_group_aria()}
>
{#each rangePresets as preset (preset.label)}
<button
@@ -262,7 +265,7 @@
>
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
</svg>
Customize
{m.meteograms_customize()}
</button>
<button
type="button"
@@ -316,7 +319,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
{m.reset_zoom()}
</button>
{/if}
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import * as m from '$lib/paraglide/messages';
import { type WeatherModelGroup, modelGroups } from '../../options';
interface Props {
@@ -15,7 +17,7 @@
selectedModel,
onModelChange,
groups = modelGroups,
label = 'Weather model'
label = m.model_weather()
}: Props = $props();
let model = $derived(
@@ -1,11 +1,17 @@
/**
* Turns a day's hourly forecast into a short written summary - the kind of
* sentence a person would actually say about the weather, rather than another
* table of numbers. Everything here is derived from the same data the charts
* plot, so the wording can never disagree with them.
* table of numbers. Everything is derived from the same data the charts plot,
* so the wording can never disagree with them.
*
* Every sentence is a whole message, not a string built from glued-together
* fragments: word order, prepositions and agreement differ per language, so
* each locale owns its full sentence and only receives the values.
*/
import { formatZoned } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import {
type WeatherUnits,
getPrecipUnit,
@@ -30,6 +36,17 @@ const CATEGORY_RANK: Record<Category, number> = {
thunder: 7
};
const CATEGORY_MESSAGE: Record<Category, () => string> = {
clear: m.cond_clear,
fair: m.cond_fair,
cloudy: m.cond_cloudy,
fog: m.cond_fog,
drizzle: m.cond_drizzle,
rain: m.cond_rain,
snow: m.cond_snow,
thunder: m.cond_thunder
};
/** WMO weather code → condition family. */
function categoryOf(code: number): Category {
if (code >= 95) return 'thunder';
@@ -45,29 +62,18 @@ function categoryOf(code: number): Category {
return 'clear';
}
const CATEGORY_PHRASE: Record<Category, string> = {
clear: 'clear',
fair: 'partly cloudy',
cloudy: 'overcast',
fog: 'foggy',
drizzle: 'drizzly',
rain: 'wet',
snow: 'snowy',
thunder: 'stormy'
};
interface Period {
label: string;
message: () => string;
/** Inclusive start hour, exclusive end hour (local). */
from: number;
to: number;
}
const PERIODS: Period[] = [
{ label: 'overnight', from: 0, to: 6 },
{ label: 'this morning', from: 6, to: 12 },
{ label: 'this afternoon', from: 12, to: 18 },
{ label: 'this evening', from: 18, to: 24 }
{ message: m.period_overnight, from: 0, to: 6 },
{ message: m.period_morning, from: 6, to: 12 },
{ message: m.period_afternoon, from: 12, to: 18 },
{ message: m.period_evening, from: 18, to: 24 }
];
export interface NarrativeInput {
@@ -79,8 +85,6 @@ export interface NarrativeInput {
/** The day being described. */
day: Date;
units: WeatherUnits;
/** True when `day` is today, which changes the wording to the present tense. */
isToday: boolean;
}
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
@@ -137,7 +141,7 @@ function capitalise(s: string): string {
* paragraph). Returns an empty list when the day has no usable data.
*/
export function buildDayNarrative(input: NarrativeInput): string[] {
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units, isToday } = input;
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units } = input;
const idx = hoursOfDay(hourlyDates, day, timezone);
if (idx.length === 0) return [];
@@ -151,11 +155,13 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const precipUnit = getPrecipUnit(units);
const at = (arr: number[] | undefined, i: number) => (arr ? arr[i] : undefined);
const hourOf = (i: number) => Number(formatZoned(hourlyDates[i], timezone, 'H'));
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
const sentences: string[] = [];
// ─── How the sky behaves through the day ────────────────────────────────────
const segments: { label: string; category: Category }[] = [];
const segments: { period: Period; category: Category }[] = [];
for (const period of PERIODS) {
const inPeriod = idx.filter((i) => {
const h = hourOf(i);
@@ -163,37 +169,47 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
});
if (inPeriod.length < 2) continue;
const cat = dominantCategory(inPeriod.map((i) => hourly.weather_code?.[i]).filter(finite));
if (cat) segments.push({ label: period.label, category: cat });
if (cat) segments.push({ period, category: cat });
}
if (segments.length > 0) {
// collapse neighbouring periods that share a description
const runs: { labels: string[]; category: Category }[] = [];
const runs: { period: Period; category: Category }[] = [];
for (const seg of segments) {
const last = runs[runs.length - 1];
if (last && last.category === seg.category) last.labels.push(seg.label);
else runs.push({ labels: [seg.label], category: seg.category });
if (!last || last.category !== seg.category) runs.push(seg);
}
const phrase = (r: { category: Category }) => CATEGORY_MESSAGE[r.category]();
if (runs.length === 1) {
sentences.push(capitalise(m.forecast_sky_all_day({ condition: phrase(runs[0]) })));
} else if (runs.length === 2) {
sentences.push(
`${capitalise(CATEGORY_PHRASE[runs[0].category])} ${isToday ? 'all day' : 'throughout the day'}.`
capitalise(
m.forecast_sky_two({
c1: phrase(runs[0]),
p1: runs[0].period.message(),
c2: phrase(runs[1]),
p2: runs[1].period.message()
})
)
);
} else {
// Four clauses is a mouthful; keep the opening, the first change and
// Four clauses is a mouthful: keep the opening, the first change and
// where the day ends up.
const kept = runs.length > 3 ? [runs[0], runs[1], runs[runs.length - 1]] : runs;
const parts = kept.map((run, i) => {
const phrase = CATEGORY_PHRASE[run.category];
const when = run.labels[0];
if (i === 0) return `${capitalise(phrase)} ${when}`;
// only the first change gets a verb; later ones read as a list
if (i > 1) return `then ${phrase} ${when}`;
const prev = kept[i - 1].category;
if (CATEGORY_RANK[run.category] > CATEGORY_RANK[prev]) return `turning ${phrase} ${when}`;
return `${run.category === 'clear' || run.category === 'fair' ? 'clearing to' : 'easing to'} ${phrase} ${when}`;
});
sentences.push(`${parts.join(', ')}.`);
const kept = [runs[0], runs[1], runs[runs.length - 1]];
sentences.push(
capitalise(
m.forecast_sky_three({
c1: phrase(kept[0]),
p1: kept[0].period.message(),
c2: phrase(kept[1]),
p2: kept[1].period.message(),
c3: phrase(kept[2]),
p3: kept[2].period.message()
})
)
);
}
}
@@ -203,15 +219,12 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const high = Math.max(...temps);
const low = Math.min(...temps);
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
let sentence = `Highs near ${high.toFixed(0)}${tempUnit}, down to ${low.toFixed(0)}${tempUnit}`;
if (feels.length > 0) {
const feelsHigh = Math.max(...feels);
const delta = feelsHigh - high;
if (Math.abs(delta) >= 3) {
sentence += `, though it will feel more like ${feelsHigh.toFixed(0)}${tempUnit}`;
}
}
sentences.push(`${sentence}.`);
const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
sentences.push(
feelsHigh != null && Math.abs(feelsHigh - high) >= 3
? m.forecast_temp_feels({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
: m.forecast_temp({ high: temp(high), low: temp(low) })
);
}
// ─── Precipitation ──────────────────────────────────────────────────────────
@@ -225,7 +238,7 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
if (total >= wetThreshold) {
// name the window carrying most of the total
let bestLabel = '';
let bestPeriod: Period | null = null;
let bestAmount = 0;
for (const period of PERIODS) {
const amount = idx
@@ -235,22 +248,19 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
.reduce((a, b) => a + b, 0);
if (amount > bestAmount) {
bestAmount = amount;
bestLabel = period.label;
bestPeriod = period;
}
}
const amountText = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
const share = bestAmount / total;
const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
sentences.push(
bestLabel && share >= 0.5
? `Around ${amountText} of precipitation, most of it ${bestLabel}.`
: `Around ${amountText} of precipitation spread through the day.`
bestPeriod && bestAmount / total >= 0.5
? m.forecast_precip_window({ amount, when: bestPeriod.message() })
: m.forecast_precip_spread({ amount })
);
} else if (peakProb >= 30) {
sentences.push(
`Mostly dry, with up to a ${Math.round(peakProb)}% chance of catching a shower.`
);
sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
} else {
sentences.push('Staying dry.');
sentences.push(m.forecast_precip_dry());
}
// ─── Wind ───────────────────────────────────────────────────────────────────
@@ -260,18 +270,32 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const dir = dayIndex >= 0 ? at(daily.winddirection_10m_dominant, dayIndex) : undefined;
const gusts = idx.map((i) => hourly.wind_gusts_10m?.[i]).filter(finite);
const maxGust = gusts.length > 0 ? Math.max(...gusts) : 0;
const from = finite(dir) ? ` from the ${getWindDirectionLabel(dir)}` : '';
let sentence = `Wind${from} up to ${maxWind.toFixed(0)} ${windUnit}`;
if (maxGust > maxWind * 1.4) sentence += `, gusting ${maxGust.toFixed(0)}`;
sentences.push(`${sentence}.`);
const gusty = maxGust > maxWind * 1.4;
// below ~5 km/h (or the equivalent in other units) there is nothing to say
const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
if (calm && !gusty) {
sentences.push(m.forecast_calm());
} else if (finite(dir)) {
const direction = getWindDirectionLabel(dir);
sentences.push(
gusty
? m.forecast_wind_dir_gusts({ direction, speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind_dir({ direction, speed: speed(maxWind) })
);
} else {
sentences.push(
gusty
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind({ speed: speed(maxWind) })
);
}
}
// ─── UV ─────────────────────────────────────────────────────────────────────
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
if (finite(uv) && uv >= 6) {
sentences.push(
`UV peaks at ${uv.toFixed(0)} - ${uvLabel(uv).toLowerCase()}, so cover up around midday.`
);
sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
}
return sentences;
@@ -279,11 +303,11 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
/** WHO exposure category for a UV index value. */
export function uvLabel(uv: number): string {
if (uv < 3) return 'Low';
if (uv < 6) return 'Moderate';
if (uv < 8) return 'High';
if (uv < 11) return 'Very high';
return 'Extreme';
if (uv < 3) return m.uv_low();
if (uv < 6) return m.uv_moderate();
if (uv < 8) return m.uv_high();
if (uv < 11) return m.uv_very_high();
return m.uv_extreme();
}
/** Tailwind text colour matching the WHO UV bands. */
@@ -298,14 +322,14 @@ export function uvColorClass(uv: number): string {
/** Name of the lunar phase for a 0-1 fraction (0 and 1 are new moon). */
export function moonPhaseName(phase: number): string {
const p = ((phase % 1) + 1) % 1;
if (p < 0.03 || p >= 0.97) return 'New moon';
if (p < 0.22) return 'Waxing crescent';
if (p < 0.28) return 'First quarter';
if (p < 0.47) return 'Waxing gibbous';
if (p < 0.53) return 'Full moon';
if (p < 0.72) return 'Waning gibbous';
if (p < 0.78) return 'Last quarter';
return 'Waning crescent';
if (p < 0.03 || p >= 0.97) return m.moon_new();
if (p < 0.22) return m.moon_waxing_crescent();
if (p < 0.28) return m.moon_first_quarter();
if (p < 0.47) return m.moon_waxing_gibbous();
if (p < 0.53) return m.moon_full();
if (p < 0.72) return m.moon_waning_gibbous();
if (p < 0.78) return m.moon_last_quarter();
return m.moon_waning_crescent();
}
/** Illuminated fraction of the disc, 0 at new moon and 1 at full. */