feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s

This commit is contained in:
zv
2026-06-14 15:53:34 +02:00
parent d572c9cc53
commit 2182297adc
45 changed files with 739 additions and 212 deletions

View File

@@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr
import { Button } from "@/components/ui/button";
import { useI18n } from "@/lib/i18n";
import { fetchReverseLocation } from "@/lib/location-api";
import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils";
import { createSelectedLocation, type LocatedSynopStation } from "@/lib/location-utils";
import { useWeatherStore } from "@/lib/store";
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
@@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
setMessage(t("location.gpsUnavailable"));
return;
}
if (!stations.length) {
setMessage(t("location.gpsStationsPending"));
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
async ({ coords }) => {
@@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
name: t("location.gpsFallbackName"),
province: null,
district: null,
country: null,
countryCode: null,
admin1: null,
admin2: null,
timezone: null,
region: "GLOBAL" as const,
}));
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations);
if (!nearest) {
setMessage(t("location.gpsStationsPending"));
return;
}
selectLocation(nearest);
setMessage(t("location.gpsSelected", { location: nearest.name }));
const selected = createSelectedLocation({ ...place, latitude, longitude }, stations);
selectLocation(selected);
setMessage(t("location.gpsSelected", { location: selected.name }));
} catch {
setMessage(t("location.gpsPositionUnavailable"));
} finally {
@@ -89,13 +86,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
}, []);
useEffect(() => {
if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return;
if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
if (permission.state !== "granted" || autoLocated.current) return;
autoLocated.current = true;
locate();
}).catch(() => undefined);
}, [locate, stations.length]);
}, [locate]);
return (
<div className="mt-3 space-y-2">
@@ -108,7 +105,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
<Button type="button" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button>
@@ -120,7 +117,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
)}
{!showPrompt && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}>
<Button type="button" variant="glass" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button>

View File

@@ -2,13 +2,13 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
import { CurrentLocationControl } from "@/components/weather/current-location-control";
import { useLocationSearch } from "@/hooks/use-location-search";
import { useWeatherStore } from "@/lib/store";
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
import { useI18n } from "@/lib/i18n";
import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils";
import { useWeatherStore } from "@/lib/store";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location";
import { CurrentLocationControl } from "@/components/weather/current-location-control";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
const { language, t } = useI18n();
@@ -20,10 +20,10 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
location,
nearest: findNearestSynopStation(location, locatedStations),
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
selected: createSelectedLocation(location, locatedStations),
})), [locatedStations, locations]);
const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0;
const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
function chooseLocation(location: SelectedLocation) {
selectLocation(location);
@@ -32,7 +32,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const firstSuggestion = suggestions[0]?.nearest;
const firstSuggestion = suggestions[0]?.selected;
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
event.preventDefault();
chooseLocation(firstSuggestion);
@@ -66,18 +66,22 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
suggestions.map(({ location, nearest }) => nearest && (
suggestions.map(({ location, selected }) => (
<button
type="button"
key={location.id}
onClick={() => chooseLocation(nearest)}
onClick={() => chooseLocation(selected)}
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span>
<span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
<span className="mt-0.5 block text-xs text-muted">{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span>
</span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
{selected.stationName && selected.distanceKm !== null ? (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{selected.stationName} · {selected.distanceKm} km</strong></span>
) : (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span>
)}
</button>
))}
</div>
@@ -86,7 +90,9 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<CurrentLocationControl stations={locatedStations} />
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
{selectedLocation.stationName && selectedLocation.distanceKm !== null
? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
</p>
)}
<p className="mt-3 px-1 text-[0.68rem] text-muted">

View File

@@ -42,18 +42,19 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
return null;
}
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number }) {
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) {
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
const displayedLocationName = locationName ?? station.name;
const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30;
const displayedStation = currentWeather ? {
...station,
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
@@ -67,7 +68,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) },
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{ icon: Umbrella, label: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
];
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
@@ -116,6 +117,8 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName })
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
@@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
</div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
</div>
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
</div>