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/+layout.svelte b/src/routes/weather/+layout.svelte
index 2ac586c..518ae36 100644
--- a/src/routes/weather/+layout.svelte
+++ b/src/routes/weather/+layout.svelte
@@ -42,6 +42,11 @@
let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
+
+ // Joined here rather than in the markup: Svelte trims the whitespace around a
+ // line break, so a separator written as "{admin1},\n{country}" renders as
+ // "Canton of Schwyz,Switzerland".
+ let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
{#if subtitle && location}
@@ -57,10 +62,8 @@
{location.name}
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()}
-
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];
}