From f66e259e3251a46dc70843317e95d5036120e842 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Mon, 3 Aug 2026 09:57:33 +0200 Subject: [PATCH 01/12] add seasonal --- src/routes/about/content/de.svelte | 3 ++- src/routes/about/content/en.svelte | 4 ++-- src/routes/about/content/es.svelte | 3 ++- src/routes/about/content/fr.svelte | 3 ++- src/routes/about/content/it.svelte | 3 ++- src/routes/legal/terms/content/de.svelte | 5 +++-- src/routes/legal/terms/content/en.svelte | 4 ++-- src/routes/legal/terms/content/es.svelte | 5 +++-- src/routes/legal/terms/content/fr.svelte | 4 ++-- src/routes/legal/terms/content/it.svelte | 5 +++-- 10 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/routes/about/content/de.svelte b/src/routes/about/content/de.svelte index 12177d8..fa57f69 100644 --- a/src/routes/about/content/de.svelte +++ b/src/routes/about/content/de.svelte @@ -29,7 +29,8 @@ Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches - Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen. + Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate + sowie neue Funktionen, sobald sie erscheinen.

Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine diff --git a/src/routes/about/content/en.svelte b/src/routes/about/content/en.svelte index 12c8fa9..e7b3fd6 100644 --- a/src/routes/about/content/en.svelte +++ b/src/routes/about/content/en.svelte @@ -25,8 +25,8 @@

Drizz.li is free to use. If you find it useful, you can support it with a small monthly contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a - thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and - new supporter features as they land. + thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the + seasonal outlook for the months ahead, and new supporter features as they land.

Contributions are handled by a simple bank transfer - no payment processor, no stored card diff --git a/src/routes/about/content/es.svelte b/src/routes/about/content/es.svelte index 2a034d7..4a9098e 100644 --- a/src/routes/about/content/es.svelte +++ b/src/routes/about/content/es.svelte @@ -29,7 +29,8 @@ Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual (desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente - a las normales climáticas y las nuevas funciones que vayan llegando. + a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas + funciones que vayan llegando.

Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y diff --git a/src/routes/about/content/fr.svelte b/src/routes/about/content/fr.svelte index 85e7b11..845f679 100644 --- a/src/routes/about/content/fr.svelte +++ b/src/routes/about/content/fr.svelte @@ -31,7 +31,8 @@ Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo - historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. + historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir, + ainsi que les nouvelles fonctions à venir.

Les contributions passent par un simple virement bancaire : aucun prestataire de paiement, diff --git a/src/routes/about/content/it.svelte b/src/routes/about/content/it.svelte index 94ecfe6..261f879 100644 --- a/src/routes/about/content/it.svelte +++ b/src/routes/about/content/it.svelte @@ -29,7 +29,8 @@ Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3 € al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali - climatiche e le nuove funzioni man mano che arrivano. + climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che + arrivano.

I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun diff --git a/src/routes/legal/terms/content/de.svelte b/src/routes/legal/terms/content/de.svelte index d991bbf..2328068 100644 --- a/src/routes/legal/terms/content/de.svelte +++ b/src/routes/legal/terms/content/de.svelte @@ -15,8 +15,9 @@

1. Was Sie erhalten

Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit - historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie - erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei. + historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die + kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von + Drizz.li bleiben für alle frei.

2. Ablauf

diff --git a/src/routes/legal/terms/content/en.svelte b/src/routes/legal/terms/content/en.svelte index 1b3e869..c624ccf 100644 --- a/src/routes/legal/terms/content/en.svelte +++ b/src/routes/legal/terms/content/en.svelte @@ -14,8 +14,8 @@

1. What you get

A supporter contribution unlocks the supporter extras for the paid period: currently historical - weather with climate-normal comparisons, plus new supporter features as they land. The free - parts of Drizz.li stay free for everyone. + weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new + supporter features as they land. The free parts of Drizz.li stay free for everyone.

2. How it works

diff --git a/src/routes/legal/terms/content/es.svelte b/src/routes/legal/terms/content/es.svelte index d9e355d..1946dce 100644 --- a/src/routes/legal/terms/content/es.svelte +++ b/src/routes/legal/terms/content/es.svelte @@ -17,8 +17,9 @@

1. Qué obtienes

Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico - con comparación frente a las normales climáticas, además de las nuevas funciones que vayan - llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos. + con comparación frente a las normales climáticas y la perspectiva estacional de los próximos + meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li + siguen siendo gratuitas para todos.

2. Cómo funciona

diff --git a/src/routes/legal/terms/content/fr.svelte b/src/routes/legal/terms/content/fr.svelte index 2f1ec5f..39544ec 100644 --- a/src/routes/legal/terms/content/fr.svelte +++ b/src/routes/legal/terms/content/fr.svelte @@ -14,8 +14,8 @@

1. Ce que vous obtenez

Une contribution débloque les bonus pour la période payée : actuellement la météo historique - avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les - parties gratuites de Drizz.li le restent pour tout le monde. + avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les + nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.

2. Fonctionnement

diff --git a/src/routes/legal/terms/content/it.svelte b/src/routes/legal/terms/content/it.svelte index 4b4111c..281f280 100644 --- a/src/routes/legal/terms/content/it.svelte +++ b/src/routes/legal/terms/content/it.svelte @@ -14,8 +14,9 @@

1. Cosa ottieni

Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con - confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le - parti gratuite di Drizz.li restano gratuite per tutti. + confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire, + oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite + per tutti.

2. Come funziona

From 9fc17e74174772c6024e7df53e8868cad29eb107 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Mon, 3 Aug 2026 11:05:34 +0200 Subject: [PATCH 02/12] fix incons --- messages/de.json | 30 +++- messages/en.json | 30 +++- messages/es.json | 30 +++- messages/fr.json | 30 +++- messages/it.json | 30 +++- .../[location]/ModelPictogramTimeline.svelte | 11 +- .../[location]/HistoricalDaily.svelte | 5 +- .../weather/utils/weather-codes.spec.ts | 129 ++++++++++++++++++ src/routes/weather/utils/weather-codes.ts | 106 ++++++++++---- .../weather/week/[location]/DailyCards.svelte | 4 +- .../week/[location]/DailyStripSticky.svelte | 4 +- .../week/[location]/HourlyTable.svelte | 11 +- .../week/[location]/NearbyCities.svelte | 3 +- 13 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 src/routes/weather/utils/weather-codes.spec.ts diff --git a/messages/de.json b/messages/de.json index 84e1fb1..8ae4807 100644 --- a/messages/de.json +++ b/messages/de.json @@ -335,5 +335,33 @@ "cadence_varies": "variiert", "model_group_automatic": "Automatisch", "model_group_reanalysis": "ECMWF-Reanalyse", - "model_group_regional_reanalysis": "Regionale Reanalyse" + "model_group_regional_reanalysis": "Regionale Reanalyse", + "wmo_0": "Klarer Himmel", + "wmo_1": "Überwiegend klar", + "wmo_2": "Teils bewölkt", + "wmo_3": "Bedeckt", + "wmo_45": "Nebel", + "wmo_48": "Gefrierender Nebel", + "wmo_51": "Leichter Nieselregen", + "wmo_53": "Mäßiger Nieselregen", + "wmo_55": "Starker Nieselregen", + "wmo_56": "Leichter gefrierender Nieselregen", + "wmo_57": "Starker gefrierender Nieselregen", + "wmo_61": "Leichter Regen", + "wmo_63": "Mäßiger Regen", + "wmo_65": "Starker Regen", + "wmo_66": "Leichter gefrierender Regen", + "wmo_67": "Starker gefrierender Regen", + "wmo_71": "Leichter Schneefall", + "wmo_73": "Mäßiger Schneefall", + "wmo_75": "Starker Schneefall", + "wmo_77": "Schneegriesel", + "wmo_80": "Leichte Regenschauer", + "wmo_81": "Mäßige Regenschauer", + "wmo_82": "Heftige Regenschauer", + "wmo_85": "Leichte Schneeschauer", + "wmo_86": "Starke Schneeschauer", + "wmo_95": "Gewitter", + "wmo_96": "Gewitter mit leichtem Hagel", + "wmo_99": "Gewitter mit starkem Hagel" } diff --git a/messages/en.json b/messages/en.json index a8b8526..536c18c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -335,5 +335,33 @@ "cadence_varies": "varies", "model_group_automatic": "Automatic", "model_group_reanalysis": "ECMWF reanalysis", - "model_group_regional_reanalysis": "Regional reanalysis" + "model_group_regional_reanalysis": "Regional reanalysis", + "wmo_0": "Clear sky", + "wmo_1": "Mainly clear", + "wmo_2": "Partly cloudy", + "wmo_3": "Overcast", + "wmo_45": "Fog", + "wmo_48": "Depositing rime fog", + "wmo_51": "Light drizzle", + "wmo_53": "Moderate drizzle", + "wmo_55": "Dense drizzle", + "wmo_56": "Light freezing drizzle", + "wmo_57": "Dense freezing drizzle", + "wmo_61": "Slight rain", + "wmo_63": "Moderate rain", + "wmo_65": "Heavy rain", + "wmo_66": "Light freezing rain", + "wmo_67": "Heavy freezing rain", + "wmo_71": "Slight snowfall", + "wmo_73": "Moderate snowfall", + "wmo_75": "Heavy snowfall", + "wmo_77": "Snow grains", + "wmo_80": "Slight rain showers", + "wmo_81": "Moderate rain showers", + "wmo_82": "Violent rain showers", + "wmo_85": "Slight snow showers", + "wmo_86": "Heavy snow showers", + "wmo_95": "Thunderstorm", + "wmo_96": "Thunderstorm with slight hail", + "wmo_99": "Thunderstorm with heavy hail" } diff --git a/messages/es.json b/messages/es.json index 7b4924c..fc4aff5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -335,5 +335,33 @@ "cadence_varies": "variable", "model_group_automatic": "Automático", "model_group_reanalysis": "Reanálisis del ECMWF", - "model_group_regional_reanalysis": "Reanálisis regional" + "model_group_regional_reanalysis": "Reanálisis regional", + "wmo_0": "Cielo despejado", + "wmo_1": "Mayormente despejado", + "wmo_2": "Parcialmente nublado", + "wmo_3": "Cubierto", + "wmo_45": "Niebla", + "wmo_48": "Niebla engelante", + "wmo_51": "Llovizna débil", + "wmo_53": "Llovizna moderada", + "wmo_55": "Llovizna intensa", + "wmo_56": "Llovizna engelante débil", + "wmo_57": "Llovizna engelante intensa", + "wmo_61": "Lluvia débil", + "wmo_63": "Lluvia moderada", + "wmo_65": "Lluvia intensa", + "wmo_66": "Lluvia engelante débil", + "wmo_67": "Lluvia engelante intensa", + "wmo_71": "Nevada débil", + "wmo_73": "Nevada moderada", + "wmo_75": "Nevada intensa", + "wmo_77": "Granos de nieve", + "wmo_80": "Chubascos débiles", + "wmo_81": "Chubascos moderados", + "wmo_82": "Chubascos violentos", + "wmo_85": "Chubascos de nieve débiles", + "wmo_86": "Chubascos de nieve intensos", + "wmo_95": "Tormenta", + "wmo_96": "Tormenta con granizo ligero", + "wmo_99": "Tormenta con granizo fuerte" } diff --git a/messages/fr.json b/messages/fr.json index 30c7e72..1b7a88f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -335,5 +335,33 @@ "cadence_varies": "variable", "model_group_automatic": "Automatique", "model_group_reanalysis": "Réanalyse ECMWF", - "model_group_regional_reanalysis": "Réanalyse régionale" + "model_group_regional_reanalysis": "Réanalyse régionale", + "wmo_0": "Ciel dégagé", + "wmo_1": "Plutôt dégagé", + "wmo_2": "Partiellement nuageux", + "wmo_3": "Couvert", + "wmo_45": "Brouillard", + "wmo_48": "Brouillard givrant", + "wmo_51": "Bruine faible", + "wmo_53": "Bruine modérée", + "wmo_55": "Bruine dense", + "wmo_56": "Bruine verglaçante faible", + "wmo_57": "Bruine verglaçante dense", + "wmo_61": "Pluie faible", + "wmo_63": "Pluie modérée", + "wmo_65": "Pluie forte", + "wmo_66": "Pluie verglaçante faible", + "wmo_67": "Pluie verglaçante forte", + "wmo_71": "Neige faible", + "wmo_73": "Neige modérée", + "wmo_75": "Neige forte", + "wmo_77": "Grésil", + "wmo_80": "Averses faibles", + "wmo_81": "Averses modérées", + "wmo_82": "Averses violentes", + "wmo_85": "Averses de neige faibles", + "wmo_86": "Averses de neige fortes", + "wmo_95": "Orage", + "wmo_96": "Orage avec grêle légère", + "wmo_99": "Orage avec forte grêle" } diff --git a/messages/it.json b/messages/it.json index ad8ece2..c43c69c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -335,5 +335,33 @@ "cadence_varies": "variabile", "model_group_automatic": "Automatico", "model_group_reanalysis": "Rianalisi ECMWF", - "model_group_regional_reanalysis": "Rianalisi regionale" + "model_group_regional_reanalysis": "Rianalisi regionale", + "wmo_0": "Cielo sereno", + "wmo_1": "Prevalentemente sereno", + "wmo_2": "Parzialmente nuvoloso", + "wmo_3": "Coperto", + "wmo_45": "Nebbia", + "wmo_48": "Nebbia gelata", + "wmo_51": "Pioviggine debole", + "wmo_53": "Pioviggine moderata", + "wmo_55": "Pioviggine intensa", + "wmo_56": "Pioviggine congelantesi debole", + "wmo_57": "Pioviggine congelantesi intensa", + "wmo_61": "Pioggia debole", + "wmo_63": "Pioggia moderata", + "wmo_65": "Pioggia forte", + "wmo_66": "Pioggia congelantesi debole", + "wmo_67": "Pioggia congelantesi forte", + "wmo_71": "Nevicata debole", + "wmo_73": "Nevicata moderata", + "wmo_75": "Nevicata forte", + "wmo_77": "Granuli di neve", + "wmo_80": "Rovesci deboli", + "wmo_81": "Rovesci moderati", + "wmo_82": "Rovesci violenti", + "wmo_85": "Rovesci di neve deboli", + "wmo_86": "Rovesci di neve forti", + "wmo_95": "Temporale", + "wmo_96": "Temporale con grandine leggera", + "wmo_99": "Temporale con grandine forte" } diff --git a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte index ef64421..88aaa20 100644 --- a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte +++ b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte @@ -3,7 +3,7 @@ import * as m from '$lib/paraglide/messages'; - import { getWeatherIconName } from '../../utils/weather-codes'; + import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes'; interface Props { timestamps: number[]; @@ -62,8 +62,9 @@ let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`])); -{#snippet weatherIcon(name: string, size: number = 24)} +{#snippet weatherIcon(name: string, size: number = 24, description: string = '')} + {#if description}{description}{/if} {/snippet} @@ -135,7 +136,11 @@ ? 'border-l-2 border-l-primary/20' : ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}" > - {@render weatherIcon(getWeatherIconName(code, day))} + {@render weatherIcon( + getWeatherIconName(code, day), + 24, + getWeatherDescription(code) + )} {/each} diff --git a/src/routes/weather/historical/[location]/HistoricalDaily.svelte b/src/routes/weather/historical/[location]/HistoricalDaily.svelte index 1d5f989..3c7d27e 100644 --- a/src/routes/weather/historical/[location]/HistoricalDaily.svelte +++ b/src/routes/weather/historical/[location]/HistoricalDaily.svelte @@ -9,7 +9,7 @@ } from '$lib/services/weather'; import { getColor, getTempStyle } from '../../utils/colors'; - import { getWeatherIconName } from '../../utils/weather-codes'; + import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes'; import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types'; interface Props { @@ -202,7 +202,8 @@ >{formatZoned(date, timezone, 'd MMM')} -
+ {getWeatherDescription(dayCode)} + {getWeatherDescription(nightCode)} + {#if description}{description}{/if} {/snippet} @@ -678,7 +679,11 @@ !cellData[i + 1].isDaytime} > {#if finite(wCode)} - {@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)} + {@render weatherIcon( + getWeatherIconName(wCode, cell.isDaytime), + iconPx, + getWeatherDescription(wCode) + )} {/if} {/each} diff --git a/src/routes/weather/week/[location]/NearbyCities.svelte b/src/routes/weather/week/[location]/NearbyCities.svelte index 8c5bb53..442d87c 100644 --- a/src/routes/weather/week/[location]/NearbyCities.svelte +++ b/src/routes/weather/week/[location]/NearbyCities.svelte @@ -9,7 +9,7 @@ import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities'; import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather'; - import { getWeatherIconName } from '../../utils/weather-codes'; + import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes'; import type { UnitPrefs } from '$lib/stores/settings'; @@ -113,6 +113,7 @@ > {#if day} + {getWeatherDescription(day.weatherCode)} p.variables.length > 0).length); - // The hourly table is a header row plus one row per enabled variable, so the - // placeholder below reserves exactly that and nothing under it jumps when the - // real table arrives. - const TABLE_ROW_PX = 57; + // The hourly table is a header row, the time/daylight row, then one row per + // enabled variable, so the placeholder below reserves exactly that and + // nothing under it jumps when the real table arrives. Rows are shorter below + // lg, where the cells sit tighter - measured, not guessed: 36px against 55px, + // and the time row is 48px at every width. + let compactRows = $state(false); + onMount(() => { + const mq = window.matchMedia('(max-width: 1023px)'); + const apply = () => (compactRows = mq.matches); + apply(); + mq.addEventListener('change', apply); + return () => mq.removeEventListener('change', apply); + }); + let tableRowPx = $derived(compactRows ? 36 : 55); + const TABLE_TIME_ROW_PX = 48; let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length); // Request only the hourly variables the table rows and meteograms actually @@ -490,8 +501,13 @@
+ +
+
+
+
{#each { length: enabledTableRows } as _, i (i)} -
+
@@ -505,10 +521,16 @@
{:else} - +
-
+
{/if} @@ -526,14 +548,27 @@
-
+ +
- + +
{/if} diff --git a/src/routes/weather/week/[location]/DaySummary.svelte b/src/routes/weather/week/[location]/DaySummary.svelte index 538131d..100cac1 100644 --- a/src/routes/weather/week/[location]/DaySummary.svelte +++ b/src/routes/weather/week/[location]/DaySummary.svelte @@ -96,6 +96,35 @@ return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`; }); let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null); + + // ─── Narrative clamp (phones only) ────────────────────────────────────────── + // The narrative runs anywhere from two to nine lines depending on the day, + // which on a phone means the whole page below it moves as soon as the data + // lands. Below md the text is clamped to five lines and gets a toggle, and + // the block reserves those five lines plus the toggle row whatever the + // length - so the card is the same height before and after the fetch, and the + // skeleton can match it exactly. From md up nothing is clamped. + + let expanded = $state(false); + let textEl = $state(); + let overflows = $state(false); + + $effect(() => { + sentences; // re-measure when the day (and so the text) changes + const el = textEl; + if (!el || expanded) return; // measuring while open would always say "fits" + + const measure = () => { + // only clamped below md, where the toggle is the only way to see the rest + const clamped = window.matchMedia('(max-width: 767px)').matches; + overflows = clamped && el.scrollHeight - el.clientHeight > 1; + }; + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + });
@@ -115,15 +144,35 @@
- {#if sentences.length > 0} -

- {sentences.join(' ')} -

- {:else} -

- {m.summary_no_data()} -

- {/if} +
+ {#if sentences.length > 0} +

+ {sentences.join(' ')} +

+ {:else} +

+ {m.summary_no_data()} +

+ {/if} + + + +
+ + From 025623aaa67497a3cfa6e512448952f3b0853b15 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Mon, 3 Aug 2026 13:26:30 +0200 Subject: [PATCH 04/12] rm unused urlparam --- src/lib/utils/url-state.ts | 24 +++++++++++++++++++ .../weather/14-day/[location]/+page.svelte | 10 ++++---- .../weather/compare/[location]/+page.svelte | 19 +++++++++++---- .../weather/week/[location]/+page.svelte | 4 ++-- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/lib/utils/url-state.ts b/src/lib/utils/url-state.ts index 52397be..09ecd1f 100644 --- a/src/lib/utils/url-state.ts +++ b/src/lib/utils/url-state.ts @@ -39,6 +39,30 @@ export function syncSearchParams(url: URL, updates: Record { const model = params.models?.[0]; if (!mounted || !model) return; - syncSearchParams(get(page).url, { model }); + syncSearchParams(get(page).url, { model: unlessDefault(model, DEFAULT_MODEL) }); }); // components persist across refetches; entries are null while unmounted @@ -296,7 +298,7 @@ {#snippet heroActions()}
{ diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index bf115ec..e6cf6f7 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -9,7 +9,7 @@ import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { formatZoned } from '$lib/utils/date'; - import { readList, syncSearchParams } from '$lib/utils/url-state'; + import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state'; import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { Checkbox } from '$lib/components/ui/checkbox'; @@ -71,10 +71,19 @@ storedLocation.set(data.location); }); + const DEFAULT_VARS = ['temperature_2m', 'rain', 'wind_speed_10m']; + const DEFAULT_MODELS = [ + 'ecmwf_ifs', + 'meteofrance_seamless', + 'ukmo_seamless', + 'icon_seamless', + 'gfs_seamless' + ]; + let params = $state({ ...defaultParameters, - hourly: ['temperature_2m', 'rain', 'wind_speed_10m'], - models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless'] + hourly: [...DEFAULT_VARS], + models: [...DEFAULT_MODELS] }); // units live in a persisted store; mirror them into params so a change @@ -125,8 +134,8 @@ const vars = params.hourly; if (!mounted) return; syncSearchParams(get(page).url, { - models: models?.length ? models.join(',') : null, - vars: vars?.length ? vars.join(',') : null + models: listUnlessDefault(models, DEFAULT_MODELS), + vars: listUnlessDefault(vars, DEFAULT_VARS) }); }); diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 06573f8..352fbbd 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -18,7 +18,7 @@ import { formatZoned } from '$lib/utils/date'; import { daySwap, runDayTransition } from '$lib/utils/day-swap'; import { buildLocationRoute } from '$lib/utils/location'; - import { syncSearchParams } from '$lib/utils/url-state'; + import { syncSearchParams, unlessDefault } from '$lib/utils/url-state'; import { ChartContainer } from '$lib/components/charts'; @@ -269,7 +269,7 @@ : false; syncSearchParams(get(page).url, { day: isToday ? null : dayKey, - model: model && model !== 'best_match' ? model : null + model: unlessDefault(model, 'best_match') }); }); From f4a02a208cdb524b4b9692d3ca65ebe59e29d023 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Thu, 6 Aug 2026 19:08:00 +0200 Subject: [PATCH 05/12] overlay --- .gitignore | 13 +++ README.md | 11 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + src/app.html | 5 + src/lib/components/navigation/header.svelte | 20 ++-- src/lib/utils/date.ts | 54 +++++++-- src/lib/utils/day-swap.ts | 12 +- src/lib/utils/location.ts | 24 ++++ src/routes/+layout.svelte | 108 +++++++++++++++--- src/routes/weather/+layout.svelte | 11 +- .../weather/14-day/[location]/+page.svelte | 2 +- .../weather/compare/[location]/+page.svelte | 2 +- .../historical/[location]/+page.svelte | 10 +- .../weather/seasonal/[location]/+page.svelte | 66 ++++++----- .../weather/week/[location]/+page.svelte | 9 +- static/favicon.svg | 46 ++++++++ svelte.config.js | 16 ++- 21 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 static/favicon.svg diff --git a/.gitignore b/.gitignore index 3b87454..39548b6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,16 @@ AGENTS.md # Claude Code scratch: throwaway probe scripts, never committed /.scratch + +# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it +# runs. They are not project files and keep sneaking into commits via `git add -A`. +/.bash_profile +/.bashrc +/.profile +/.zprofile +/.zshrc +/.gitconfig +/.gitmodules +/.ripgreprc +/.idea +/.mcp.json diff --git a/README.md b/README.md index 6e7136a..0790d6e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like and resolves the location client-side. Configure the server to serve `404.html` for unknown paths. +Serve it as an **internal rewrite (200)**, not as an error page. `error_page +404 /404.html` sends the right body with a 404 status: the page works, but +every hard reload of an unprerendered URL logs a 404 in the network panel and +tells crawlers the page does not exist. `try_files` with a URI as its last +argument does an internal redirect instead, and answers 200. + ### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map) The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses @@ -83,11 +89,12 @@ drizzli.example.com { server { server_name drizzli.example.com; root /srv/drizzli; - error_page 404 /404.html; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; location / { - try_files $uri $uri/ =404; + # the trailing /404.html is a URI, so nginx rewrites internally and + # answers 200 - `error_page 404 /404.html` would answer 404 instead + try_files $uri $uri/index.html /404.html; } } ``` diff --git a/messages/de.json b/messages/de.json index 3c55855..ac30e78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -260,6 +260,7 @@ "table_interval_aria": "Stundenintervall", "table_now": "Jetzt", "interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln", + "page_loading": "Wird geladen…", "charts_loading": "Diagramme werden geladen…", "chart_download": "Meteogramm als PNG-Bild herunterladen", "chart_credit_viz": "Visualisierung von", diff --git a/messages/en.json b/messages/en.json index 60958e8..1475299 100644 --- a/messages/en.json +++ b/messages/en.json @@ -260,6 +260,7 @@ "table_interval_aria": "Hourly interval", "table_now": "Now", "interval_toggle": "Toggle between 1-hour and 3-hour intervals", + "page_loading": "Loading…", "charts_loading": "Loading charts...", "chart_download": "Download meteogram as PNG image", "chart_credit_viz": "visualisation by", diff --git a/messages/es.json b/messages/es.json index c6e0efe..5186393 100644 --- a/messages/es.json +++ b/messages/es.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervalo horario", "table_now": "Ahora", "interval_toggle": "Alternar entre intervalos de 1 y 3 horas", + "page_loading": "Cargando…", "charts_loading": "Cargando gráficos…", "chart_download": "Descargar el meteograma como imagen PNG", "chart_credit_viz": "visualización de", diff --git a/messages/fr.json b/messages/fr.json index e057603..830dd9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervalle horaire", "table_now": "Maintenant", "interval_toggle": "Basculer entre les intervalles de 1 h et 3 h", + "page_loading": "Chargement…", "charts_loading": "Chargement des graphiques…", "chart_download": "Télécharger le météogramme en PNG", "chart_credit_viz": "visualisation par", diff --git a/messages/it.json b/messages/it.json index fe570ba..3990888 100644 --- a/messages/it.json +++ b/messages/it.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervallo orario", "table_now": "Ora", "interval_toggle": "Alterna tra intervalli di 1 e 3 ore", + "page_loading": "Caricamento…", "charts_loading": "Caricamento dei grafici…", "chart_download": "Scarica il meteogramma come immagine PNG", "chart_credit_viz": "visualizzazione di", diff --git a/src/app.html b/src/app.html index cb618ef..c833eb1 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,11 @@ + + - + + +{#if loadingOverlay} +
+
+ + {m.page_loading()} +
+
+{/if} +
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index ebe9a9a..e440a6f 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -44,7 +44,7 @@ let { data }: { data: PageData } = $props(); // the page cross-fade waits for this before revealing the new page - reportPageReady(() => fetchedData != null); + reportPageReady(() => fetchedData != null || loadError != null); useHeroActions(heroActions); diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index e6cf6f7..6e4d55f 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -59,7 +59,7 @@ let { data }: { data: PageData } = $props(); // the page cross-fade waits for this before revealing the new page - reportPageReady(() => fetchedData != null); + reportPageReady(() => fetchedData != null || loadError != null); useHeroActions(heroActions); diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte index ff359c7..a106540 100644 --- a/src/routes/weather/historical/[location]/+page.svelte +++ b/src/routes/weather/historical/[location]/+page.svelte @@ -39,8 +39,10 @@ let { data }: { data: PageData } = $props(); - // the page cross-fade waits for this before revealing the new page - reportPageReady(() => result != null); + // The page cross-fade waits for this before revealing the new page - and so + // does the loading overlay, so a visitor without a key has to count as ready: + // the paywall is the finished page here, nothing is on its way. + reportPageReady(() => result != null || loadError != null || !$isSupporter); useHeroActions(heroActions); @@ -213,7 +215,9 @@ ); function switchDay(date: Date) { - selectedDay.setTime(date.getTime()); + // see the week page: an unformattable selected day breaks every consumer + const time = date?.getTime(); + if (Number.isFinite(time)) selectedDay.setTime(time); } diff --git a/src/routes/weather/seasonal/[location]/+page.svelte b/src/routes/weather/seasonal/[location]/+page.svelte index f57368b..80f76b6 100644 --- a/src/routes/weather/seasonal/[location]/+page.svelte +++ b/src/routes/weather/seasonal/[location]/+page.svelte @@ -33,8 +33,10 @@ let { data }: { data: PageData } = $props(); - // the page cross-fade waits for this before revealing the new page - reportPageReady(() => result != null); + // The page cross-fade waits for this before revealing the new page - and so + // does the loading overlay, so a visitor without a key has to count as ready: + // the paywall is the finished page here, nothing is on its way. + reportPageReady(() => result != null || loadError != null || !$isSupporter); useHeroActions(heroActions); @@ -189,33 +191,39 @@ {#snippet heroActions()} -
- {#if result} - -
- {#each RANGES as range, i (range.label)} - {@const disabled = range.days !== Infinity && range.days > horizonDays} - - {/each} -
- {/if} + +
+ +
+ {#each RANGES as range, i (range.label)} + {@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)} + + {/each} +
fetchedDaily != null && fetchedHourly != null); + reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null); useHeroActions(heroActions); @@ -211,8 +211,13 @@ // Charts intentionally keep their current range: they show the full week // unless the user narrows it via the range presets or Ctrl+scroll. + // A model that answers with a broken timestamp must not be able to park an + // unreadable date in `selectedDay`: everything downstream formats it, and a + // date that cannot be formatted takes the page with it. const switchDay = (date: Date) => { - runDayTransition(() => selectedDay.setTime(date.getTime())); + const time = date?.getTime(); + if (!Number.isFinite(time)) return; + runDayTransition(() => selectedDay.setTime(time)); }; onMount(() => { diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..3a99f1f --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/svelte.config.js b/svelte.config.js index d615f22..3bdead3 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -45,12 +45,26 @@ const config = { ]; const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`)); + // Every per-location route, not just the week page: an unprerendered + // path is served by the SPA fallback, which the host answers with a + // 404 status. The page still works, but it costs a bogus 404 on every + // hard reload (and tells crawlers the page does not exist). + const cityRoutes = [ + '/weather/week', + '/weather/compare', + '/weather/14-day', + '/weather/seasonal', + '/weather/historical' + ]; + try { const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json'); const raw = fs.readFileSync(citiesPath, 'utf-8'); const cities = JSON.parse(raw); if (Array.isArray(cities)) { - const cityEntries = cities.map((c) => `/en/weather/week/${c}`); + const cityEntries = cities.flatMap((c) => + cityRoutes.map((route) => `/en${route}/${c}`) + ); // Keep the default wildcard to include other routes return ['*', ...localized, ...cityEntries]; } From 1c5ea2b52eeeaa26324f71ca9406f9da2e7c5f91 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Thu, 6 Aug 2026 19:24:22 +0200 Subject: [PATCH 06/12] beter crossfade --- src/lib/utils/skeleton-fade.ts | 44 ++++ .../weather/14-day/[location]/+page.svelte | 82 ++++---- .../weather/compare/[location]/+page.svelte | 80 ++++---- .../historical/[location]/+page.svelte | 6 +- .../weather/seasonal/[location]/+page.svelte | 74 +++---- .../weather/week/[location]/+page.svelte | 189 +++++++++--------- 6 files changed, 276 insertions(+), 199 deletions(-) create mode 100644 src/lib/utils/skeleton-fade.ts diff --git a/src/lib/utils/skeleton-fade.ts b/src/lib/utils/skeleton-fade.ts new file mode 100644 index 0000000..851aede --- /dev/null +++ b/src/lib/utils/skeleton-fade.ts @@ -0,0 +1,44 @@ +import type { TransitionConfig } from 'svelte/transition'; + +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** + * Fades a loading placeholder out *over* the content that replaces it. + * + * A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the + * layout for the length of the transition, so the real content stacks below the + * placeholder and the whole page jumps up the moment the placeholder finally + * unmounts. That is why these placeholders only ever faded in - the swap was + * abrupt, but at least nothing moved. + * + * This pins the placeholder to the box it already occupies and takes it out of + * flow for the fade instead. The content settles into its final position + * immediately and the skeleton dissolves on top of it. + * + * Requirements at the call site: the placeholder's parent must be positioned + * (`class="relative"`) and must be the same element that renders the real + * content, or the overlay lands somewhere else on the page. + */ +export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig { + if (prefersReducedMotion()) return { duration: 0 }; + + // measured while the node is still in flow, which is when Svelte builds the + // transition - the values below are what freeze it in place + const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node; + + return { + duration, + css: (t) => ` + opacity: ${t}; + position: absolute; + top: ${offsetTop}px; + left: ${offsetLeft}px; + width: ${offsetWidth}px; + height: ${offsetHeight}px; + margin: 0; + pointer-events: none; + z-index: 5; + ` + }; +} diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index e440a6f..743053a 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -1,12 +1,14 @@ {#if subtitle && location} @@ -60,11 +70,11 @@

{location.name} + · {subtitle}

-

- {#if region}{region}·{/if}{subtitle} -

+ {#if region} +

{region}

+ {/if}
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 3235980..a4be3c6 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -298,7 +298,7 @@ {#snippet heroActions()} -
+
chart != null)); - // ─── Zoom range controls (mirrors the 7-day meteograms) ───────────────────── + // ─── Zoom range controls (mirrors the week-page meteograms) ───────────────────── const SECONDS_PER_DAY = 24 * 3600; @@ -321,9 +321,62 @@ }); - + {#snippet heroActions()} - + +
+ +
+ +