small bug fixes

This commit is contained in:
Vincent van der Wal
2026-08-01 17:24:00 +02:00
parent 49e011c20b
commit b2c8108c8c
9 changed files with 161 additions and 22 deletions
+5 -1
View File
@@ -203,5 +203,9 @@
"var_visibility_short": "Sicht", "var_visibility_short": "Sicht",
"var_cape": "CAPE", "var_cape": "CAPE",
"var_cape_short": "CAPE", "var_cape_short": "CAPE",
"var_time": "Zeit" "var_time": "Zeit",
"no_data_title": "Für dieses Modell gibt es hier keine Daten",
"no_data_body": "Das gewählte Wettermodell deckt {location} nicht ab - regionale Modelle liefern nur Daten in ihrem eigenen Gebiet.",
"no_data_try_city": "Ort auf {city} ändern",
"no_data_best_match": "Zu „Best match“ wechseln"
} }
+5 -1
View File
@@ -203,5 +203,9 @@
"var_visibility_short": "Vis", "var_visibility_short": "Vis",
"var_cape": "CAPE", "var_cape": "CAPE",
"var_cape_short": "CAPE", "var_cape_short": "CAPE",
"var_time": "Time" "var_time": "Time",
"no_data_title": "No forecast data for this model here",
"no_data_body": "The selected weather model doesn't cover {location} - regional models only provide data inside their own area.",
"no_data_try_city": "Change location to {city}",
"no_data_best_match": "Switch to Best match"
} }
+5 -1
View File
@@ -203,5 +203,9 @@
"var_visibility_short": "Vis.", "var_visibility_short": "Vis.",
"var_cape": "CAPE", "var_cape": "CAPE",
"var_cape_short": "CAPE", "var_cape_short": "CAPE",
"var_time": "Hora" "var_time": "Hora",
"no_data_title": "Este modelo no tiene datos aquí",
"no_data_body": "El modelo seleccionado no cubre {location}: los modelos regionales solo ofrecen datos dentro de su propia área.",
"no_data_try_city": "Cambiar la ubicación a {city}",
"no_data_best_match": "Cambiar a «Best match»"
} }
+5 -1
View File
@@ -203,5 +203,9 @@
"var_visibility_short": "Vis.", "var_visibility_short": "Vis.",
"var_cape": "CAPE", "var_cape": "CAPE",
"var_cape_short": "CAPE", "var_cape_short": "CAPE",
"var_time": "Heure" "var_time": "Heure",
"no_data_title": "Ce modèle n'a pas de données ici",
"no_data_body": "Le modèle sélectionné ne couvre pas {location} : les modèles régionaux ne fournissent des données que dans leur propre zone.",
"no_data_try_city": "Changer de lieu pour {city}",
"no_data_best_match": "Passer à « Best match »"
} }
+5 -1
View File
@@ -203,5 +203,9 @@
"var_visibility_short": "Vis.", "var_visibility_short": "Vis.",
"var_cape": "CAPE", "var_cape": "CAPE",
"var_cape_short": "CAPE", "var_cape_short": "CAPE",
"var_time": "Ora" "var_time": "Ora",
"no_data_title": "Nessun dato per questo modello qui",
"no_data_body": "Il modello selezionato non copre {location}: i modelli regionali forniscono dati solo nella propria area.",
"no_data_try_city": "Cambia località in {city}",
"no_data_best_match": "Passa a «Best match»"
} }
@@ -139,7 +139,35 @@
{/each} {/each}
</nav> </nav>
<!-- About / legal links moved to the page footer --> <!-- On phones the footer sits a long scroll away, so the same about/legal
links get a quiet home at the bottom of the drawer. -->
{#if onMobileClose}
<nav
aria-label={m.legal_nav()}
class="flex flex-wrap gap-x-3 gap-y-1 border-t border-sidebar-border px-4 py-3 text-[11px] text-sidebar-foreground/70"
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/about')}
onclick={onMobileClose}>{m.legal_about()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/imprint')}
onclick={onMobileClose}>{m.legal_imprint()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/privacy')}
onclick={onMobileClose}>{m.legal_privacy()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/terms')}
onclick={onMobileClose}>{m.legal_terms()}</a
>
</nav>
{/if}
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) --> <!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle} {#if onToggle}
+5 -5
View File
@@ -155,13 +155,13 @@
in the cross-fade. Everything else keeps its pixels: the `day-switch` in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */ not so much as flicker while the table, summary and charts swap over. */
.day-region-table { :root.day-switch .day-region-table {
view-transition-name: day-table; view-transition-name: day-table;
} }
.day-region-summary { :root.day-switch .day-region-summary {
view-transition-name: day-summary; view-transition-name: day-summary;
} }
.day-region-charts { :root.day-switch .day-region-charts {
view-transition-name: day-charts; view-transition-name: day-charts;
} }
@@ -169,7 +169,7 @@
captured too: view-transition snapshots all paint in one layer above the captured too: view-transition snapshots all paint in one layer above the
page, so without its own group (and a higher z-index in that layer) the page, so without its own group (and a higher z-index in that layer) the
fading table and charts would slide over the top of it. */ fading table and charts would slide over the top of it. */
.daystrip { :root.day-switch .daystrip {
view-transition-name: daystrip; view-transition-name: daystrip;
} }
::view-transition-group(daystrip) { ::view-transition-group(daystrip) {
@@ -178,7 +178,7 @@
/* Same for the topbar: a captured region's snapshot is painted at its layout /* Same for the topbar: a captured region's snapshot is painted at its layout
position, including the part normally scrolled up behind the chrome. */ position, including the part normally scrolled up behind the chrome. */
.topbar { :root.day-switch .topbar {
view-transition-name: topbar; view-transition-name: topbar;
} }
::view-transition-group(topbar) { ::view-transition-group(topbar) {
+34
View File
@@ -479,3 +479,37 @@ export const ensembleModels: WeatherModel[] = ensembleModelGroups.flatMap((group
export const findEnsembleModel = (value: string): WeatherModel | undefined => export const findEnsembleModel = (value: string): WeatherModel | undefined =>
ensembleModels.find((model) => model.value === value); ensembleModels.find((model) => model.value === value);
/**
* A city inside each regional model's domain, used to offer a way out when the
* chosen model has no data for the current place. Keyed by the model-id prefix
* so new variants of a domain are covered without another entry.
*/
const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
['meteoswiss', { slug: 'zurich', label: 'Zürich' }],
['italia_meteo', { slug: 'rome', label: 'Rome' }],
['icon', { slug: 'berlin', label: 'Berlin' }],
['dwd', { slug: 'berlin', label: 'Berlin' }],
['meteofrance', { slug: 'paris', label: 'Paris' }],
['arpege', { slug: 'paris', label: 'Paris' }],
['arome', { slug: 'paris', label: 'Paris' }],
['ukmo', { slug: 'london', label: 'London' }],
['knmi', { slug: 'amsterdam', label: 'Amsterdam' }],
['dmi', { slug: 'copenhagen', label: 'Copenhagen' }],
['metno', { slug: 'oslo', label: 'Oslo' }],
['ncep', { slug: 'new-york', label: 'New York' }],
['gfs', { slug: 'new-york', label: 'New York' }],
['hrrr', { slug: 'new-york', label: 'New York' }],
['nbm', { slug: 'new-york', label: 'New York' }],
['gem', { slug: 'toronto', label: 'Toronto' }],
['jma', { slug: 'tokyo', label: 'Tokyo' }],
['kma', { slug: 'seoul', label: 'Seoul' }],
['cma', { slug: 'beijing', label: 'Beijing' }],
['bom', { slug: 'sydney', label: 'Sydney' }]
];
/** Where this model definitely has data, or null for a global model. */
export function inDomainCity(model: string): { slug: string; label: string } | null {
const hit = IN_DOMAIN_CITY.find(([prefix]) => model.startsWith(prefix));
return hit ? hit[1] : null;
}
@@ -22,6 +22,8 @@
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { import {
type FriendlyWeatherError, type FriendlyWeatherError,
type WeekForecastResult, type WeekForecastResult,
@@ -30,7 +32,7 @@
} from '$lib/services/weather'; } from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters } from '../../options'; import { defaultParameters, inDomainCity } from '../../options';
import { computeDayNightWeatherCodes } from '../../utils/weather-codes'; import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
import DailyStripSticky from './DailyStripSticky.svelte'; import DailyStripSticky from './DailyStripSticky.svelte';
import DaySummary from './DaySummary.svelte'; import DaySummary from './DaySummary.svelte';
@@ -114,6 +116,14 @@
// for the same place once their range is exhausted. // for the same place once their range is exhausted.
let locationRoute = $derived(buildLocationRoute(location)); let locationRoute = $derived(buildLocationRoute(location));
// When a regional model has no data here, point at a place it does cover
// rather than only offering to abandon the model.
let suggestedCity = $derived.by(() => {
const city = inDomainCity(params.models?.[0] ?? '');
// pointless to offer the place we are already on
return city && city.slug !== locationRoute ? city : null;
});
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state<FriendlyWeatherError | null>(null); let loadError = $state<FriendlyWeatherError | null>(null);
@@ -157,6 +167,36 @@
return fd ? formatZoned(selectedDay, fd.timezone, 'yyyy-MM-dd') : ''; return fd ? formatZoned(selectedDay, fd.timezone, 'yyyy-MM-dd') : '';
}); });
// A model swap can leave the open day outside what the new model covers (a
// short-range model after a 15-day one, say). Rather than showing an empty
// day, fall back to the nearest day that does have data - searching forward
// first, then back.
$effect(() => {
const fd = fetchedDaily;
if (!fd || wantedDay) return;
const days = fd.dailyDates;
if (days.length === 0) return;
const covered = (i: number) => {
const max = fd.daily.temperature_2m_max[i];
return max != null && Number.isFinite(max);
};
const current = days.findIndex(
(d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === selectedDayKey
);
if (current >= 0 && covered(current)) return;
const from = current >= 0 ? current : 0;
for (let step = 0; step < days.length; step++) {
for (const i of [from + step, from - step]) {
if (i >= 0 && i < days.length && covered(i)) {
selectedDay.setTime(days[i].getTime());
return;
}
}
}
});
// Charts intentionally keep their current range: they show the full week // Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll. // unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => { const switchDay = (date: Date) => {
@@ -324,12 +364,20 @@
> >
Try again Try again
</button> </button>
{#if suggestedCity}
<a
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
href={href('/weather/week/[location]', { location: suggestedCity.slug })}
>
{m.no_data_try_city({ city: suggestedCity.label })}
</a>
{/if}
{#if params.models?.[0] !== 'best_match'} {#if params.models?.[0] !== 'best_match'}
<button <button
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted" class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
onclick={resetToBestMatch} onclick={resetToBestMatch}
> >
Switch to Best match {m.no_data_best_match()}
</button> </button>
{/if} {/if}
</div> </div>
@@ -362,19 +410,28 @@
/> />
</svg> </svg>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-semibold">No forecast data for this model here</p> <p class="font-semibold">{m.no_data_title()}</p>
<p class="text-[13px] opacity-90"> <p class="text-[13px] opacity-90">
The selected weather model doesn't cover {location.name} — regional models only provide data {m.no_data_body({ location: location.name ?? '' })}
inside their own area.
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
{#if suggestedCity}
<a
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
href={href('/weather/week/[location]', { location: suggestedCity.slug })}
>
{m.no_data_try_city({ city: suggestedCity.label })}
</a>
{/if}
<button <button
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background" class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
onclick={resetToBestMatch} onclick={resetToBestMatch}
> >
Switch to Best match {m.no_data_best_match()}
</button> </button>
</div> </div>
</div>
{/if} {/if}
<!-- The sticky day strip, the hourly table AND the meteograms share this <!-- The sticky day strip, the hourly table AND the meteograms share this