feat: redesign dashboard around place search

This commit is contained in:
zv
2026-06-01 19:05:31 +02:00
parent 6c2e731c60
commit 0632c67beb
18 changed files with 374 additions and 139 deletions

View File

@@ -6,6 +6,8 @@
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
## Stack
- Next.js z App Router i TypeScript
@@ -48,6 +50,8 @@ Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
Do wyszukiwania nazw miejscowości, bez pobierania danych pogodowych, używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić geokoder własnym dostawcą.
Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content.
## Ograniczenia API

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
interface RawLocation {
id?: number;
name?: string;
latitude?: number;
longitude?: number;
admin1?: string;
admin2?: string;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query")?.trim() ?? "";
const language = searchParams.get("language") === "en" ? "en" : "pl";
if (query.length < 2 || query.length > 80) return NextResponse.json([]);
const params = new URLSearchParams({
name: query,
count: "8",
language,
format: "json",
countryCode: "PL",
});
try {
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
const data = await response.json() as { results?: RawLocation[] };
const results = (data.results ?? []).flatMap((location) => {
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
return [{
id: location.id,
name: location.name,
latitude: location.latitude as number,
longitude: location.longitude as number,
province: location.admin1 ?? null,
district: location.admin2 ?? null,
}];
});
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });
} catch {
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
}
}

View File

@@ -5,26 +5,32 @@ import { useWeatherStore } from "@/lib/store";
import { useWeatherStations } from "@/hooks/use-weather-stations";
import { WeatherHero } from "@/components/weather/weather-hero";
import { FavoritesSection } from "@/components/weather/favorites-section";
import { StationsExplorer } from "@/components/weather/stations-explorer";
import { LocationSearch } from "@/components/weather/location-search";
import { FeaturedStationsSection } from "@/components/weather/featured-stations-section";
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
import { ErrorState } from "@/components/states/error-state";
import { useI18n } from "@/lib/i18n";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
export function DashboardPage() {
const { t } = useI18n();
const { data: stations, isPending, isError, refetch } = useWeatherStations();
const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
if (isPending) return <PageLoadingSkeleton />;
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
const selectedStation = stations.find((station) => station.id === selectedStationId)
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
?? stations[0];
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
return (
<div className="space-y-10">
<WeatherHero station={selectedStation} />
<LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<FavoritesSection stations={stations} />
<StationsExplorer stations={stations} />
<FeaturedStationsSection stations={stations} />
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { MapPinned } from "lucide-react";
import { useWeatherStore } from "@/lib/store";
import { formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils";
import { useI18n } from "@/lib/i18n";
import type { SynopStation } from "@/types/imgw";
import { WeatherIcon } from "@/components/weather/weather-icon";
import { cn } from "@/lib/utils";
const featuredNames = ["Warszawa", "Kraków", "Gdańsk", "Wrocław", "Poznań", "Zakopane"];
export function FeaturedStationsSection({ stations }: { stations: SynopStation[] }) {
const { language, t } = useI18n();
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectStation = useWeatherStore((state) => state.selectStation);
const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []);
return (
<section className="space-y-3">
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><MapPinned className="size-4" />{t("featured.label")}</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
{featured.map((station) => {
const active = selectedStationId === station.id;
return (
<button
type="button"
key={station.id}
onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-2xl p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10", active && "border-sky-400/60 bg-white/60 dark:bg-white/15")}
>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-slate-600 dark:text-slate-300">{station.name}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
</span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
</button>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useMemo, useState } from "react";
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
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 type { MeteoStationPosition, SynopStation } from "@/types/imgw";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
const { language, t } = useI18n();
const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
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]);
const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0;
return (
<section className="relative z-30">
<div className="glass rounded-[1.75rem] p-3 sm:p-4">
<div className="flex items-center gap-2 px-1 pb-3">
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
</div>
<label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
placeholder={t("location.placeholder")}
autoComplete="off"
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10"
/>
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
</label>
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
</p>
)}
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a>
</p>
</div>
{showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
suggestions.map(({ location, nearest }) => nearest && (
<button
type="button"
key={location.id}
onClick={() => {
selectLocation(nearest);
setQuery("");
setIsFocused(false);
}}
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"
>
<span>
<span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
</span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
</button>
))}
</div>
)}
</section>
);
}

View File

@@ -1,13 +0,0 @@
"use client";
import type { SynopStation } from "@/types/imgw";
import { StationCard } from "@/components/weather/station-card";
import { EmptyState } from "@/components/states/empty-state";
import { SearchX } from "lucide-react";
import { useI18n } from "@/lib/i18n";
export function StationGrid({ stations }: { stations: SynopStation[] }) {
const { t } = useI18n();
if (!stations.length) return <EmptyState icon={SearchX} title={t("stations.emptyTitle")} description={t("stations.emptyDescription")} />;
return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{stations.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>;
}

View File

@@ -1,39 +0,0 @@
"use client";
import { Search, SlidersHorizontal } from "lucide-react";
import type { StationFilter, StationSort } from "@/components/weather/stations-explorer";
import { useI18n } from "@/lib/i18n";
export function StationSearch({ query, onQueryChange, sort, onSortChange, filter, onFilterChange }: { query: string; onQueryChange: (value: string) => void; sort: StationSort; onSortChange: (value: StationSort) => void; filter: StationFilter; onFilterChange: (value: StationFilter) => void }) {
const { t } = useI18n();
return (
<div className="glass grid gap-3 rounded-[1.75rem] p-3 sm:grid-cols-[1fr_auto_auto]">
<label className="relative">
<span className="sr-only">{t("stations.searchLabel")}</span>
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
<input value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder={t("stations.searchPlaceholder")} className="w-full rounded-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/5" />
</label>
<label className="relative">
<span className="sr-only">{t("stations.sortLabel")}</span>
<select value={sort} onChange={(event) => onSortChange(event.target.value as StationSort)} className="w-full appearance-none rounded-2xl border border-white/40 bg-white/45 py-3 pl-4 pr-9 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-slate-900/60">
<option value="alphabetical">{t("stations.sortAlphabetical")}</option>
<option value="temperature-desc">{t("stations.sortTemperatureDesc")}</option>
<option value="temperature-asc">{t("stations.sortTemperatureAsc")}</option>
<option value="humidity-desc">{t("stations.sortHumidityDesc")}</option>
<option value="pressure-desc">{t("stations.sortPressureDesc")}</option>
</select>
<SlidersHorizontal className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
</label>
<label>
<span className="sr-only">{t("stations.filterLabel")}</span>
<select value={filter} onChange={(event) => onFilterChange(event.target.value as StationFilter)} className="w-full rounded-2xl border border-white/40 bg-white/45 px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-slate-900/60">
<option value="all">{t("stations.filterAll")}</option>
<option value="warmest">{t("stations.filterWarmest")}</option>
<option value="coldest">{t("stations.filterColdest")}</option>
<option value="windy">{t("stations.filterWindy")}</option>
<option value="rainy">{t("stations.filterRainy")}</option>
</select>
</label>
</div>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import type { SynopStation } from "@/types/imgw";
import { StationGrid } from "@/components/weather/station-grid";
import { StationSearch } from "@/components/weather/station-search";
import { useI18n } from "@/lib/i18n";
export type StationSort = "alphabetical" | "temperature-desc" | "temperature-asc" | "humidity-desc" | "pressure-desc";
export type StationFilter = "all" | "warmest" | "coldest" | "windy" | "rainy";
function compareNumbers(a: number | null, b: number | null, direction: "asc" | "desc") {
if (a === null) return 1;
if (b === null) return -1;
return direction === "asc" ? a - b : b - a;
}
export function StationsExplorer({ stations }: { stations: SynopStation[] }) {
const { locale, t } = useI18n();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<StationSort>("alphabetical");
const [filter, setFilter] = useState<StationFilter>("all");
const visibleStations = useMemo(() => {
const searched = stations.filter((station) => station.name.toLocaleLowerCase(locale).includes(query.trim().toLocaleLowerCase(locale)));
const sorted = [...searched].sort((a, b) => {
if (sort === "temperature-desc") return compareNumbers(a.temperature, b.temperature, "desc");
if (sort === "temperature-asc") return compareNumbers(a.temperature, b.temperature, "asc");
if (sort === "humidity-desc") return compareNumbers(a.humidity, b.humidity, "desc");
if (sort === "pressure-desc") return compareNumbers(a.pressure, b.pressure, "desc");
return a.name.localeCompare(b.name, locale);
});
if (filter === "all") return sorted;
const key = { warmest: "temperature", coldest: "temperature", windy: "windSpeed", rainy: "rainfall" }[filter] as keyof SynopStation;
return [...sorted].sort((a, b) => compareNumbers(a[key] as number | null, b[key] as number | null, filter === "coldest" ? "asc" : "desc")).slice(0, 12);
}, [filter, locale, query, sort, stations]);
return (
<section className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("stations.section")}</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight">{t("stations.title")}</h2>
</div>
<StationSearch query={query} onQueryChange={setQuery} sort={sort} onSortChange={setSort} filter={filter} onFilterChange={setFilter} />
<StationGrid stations={visibleStations} />
</section>
);
}

View File

@@ -18,7 +18,7 @@ import type { SynopStation } from "@/types/imgw";
import { WeatherIcon } from "@/components/weather/weather-icon";
import { useI18n } from "@/lib/i18n";
export function WeatherHero({ station }: { station: SynopStation }) {
export function WeatherHero({ station, locationName, distanceKm }: { station: SynopStation; locationName?: string; distanceKm?: number }) {
const { language, t } = useI18n();
const mood = getWeatherMoodFromData(station);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
@@ -40,7 +40,8 @@ export function WeatherHero({ station }: { station: SynopStation }) {
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
<div className="relative">
<div className="flex flex-wrap items-center gap-3">
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{station.name}</span>
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{locationName ?? station.name}</span>
{locationName && <span className="text-xs text-white/65">{t("location.heroSource", { station: station.name, distance: distanceKm ?? 0 })}</span>}
</div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<div>

View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchLocations } from "@/lib/location-api";
import type { Language } from "@/lib/i18n";
export function useLocationSearch(query: string, language: Language) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedQuery(query.trim()), 300);
return () => window.clearTimeout(timeout);
}, [query]);
return useQuery({
queryKey: ["location-search", debouncedQuery, language],
queryFn: ({ signal }) => fetchLocations(debouncedQuery, language, signal),
enabled: debouncedQuery.length >= 2,
staleTime: 24 * 60 * 60 * 1000,
retry: 1,
});
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchMeteoStationPositions } from "@/lib/imgw-api";
import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
export function useMeteoStationPositions() {
return useQuery({
queryKey: ["meteo-station-positions"],
queryFn: ({ signal }) => fetchMeteoStationPositions(signal),
staleTime: QUERY_STALE_TIME,
gcTime: QUERY_GC_TIME,
retry: 2,
});
}

View File

@@ -25,6 +25,19 @@ const translations = {
"error.title": "Nie udało się pobrać danych",
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"location.label": "Twoja lokalizacja",
"location.searchLabel": "Szukaj miejscowości w Polsce",
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
"location.clear": "Wyczyść wyszukiwanie",
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
"location.nearest": "Najbliższa stacja IMGW",
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
"location.heroSource": "stacja IMGW: {station} · około {distance} km",
"location.attribution": "Wyszukiwanie miejscowości:",
"featured.label": "Szybki wybór",
"featured.title": "Wybrane stacje IMGW",
"favorites.title": "Ulubione lokalizacje",
"favorites.empty": "Dodaj stacje do ulubionych, aby mieć ich odczyty pod ręką.",
"favorites.addStation": "Dodaj {name} do ulubionych",
@@ -53,24 +66,7 @@ const translations = {
"weather.windDirectionDetail": "Kierunek napływu wiatru",
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
"weather.temperatureDetail": "Temperatura powietrza",
"stations.section": "Stacje synoptyczne",
"stations.title": "Pogoda w Polsce",
"stations.searchLabel": "Szukaj stacji synoptycznej",
"stations.searchPlaceholder": "Szukaj stacji IMGW…",
"stations.sortLabel": "Sortowanie stacji",
"stations.filterLabel": "Filtr stacji",
"stations.sortAlphabetical": "Alfabetycznie",
"stations.sortTemperatureDesc": "Temperatura: najwyższa",
"stations.sortTemperatureAsc": "Temperatura: najniższa",
"stations.sortHumidityDesc": "Wilgotność: najwyższa",
"stations.sortPressureDesc": "Ciśnienie: najwyższe",
"stations.filterAll": "Wszystkie stacje",
"stations.filterWarmest": "Najcieplejsze",
"stations.filterColdest": "Najzimniejsze",
"stations.filterWindy": "Największy wiatr",
"stations.filterRainy": "Największy opad",
"stations.emptyTitle": "Brak pasujących stacji",
"stations.emptyDescription": "Zmień wyszukiwanie lub wybierz inny filtr.",
"station.all": "Wszystkie stacje",
"station.label": "Stacja {name}",
"station.parameters": "Aktualne parametry",
@@ -139,6 +135,19 @@ const translations = {
"error.title": "Unable to load data",
"error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.",
"location.label": "Your location",
"location.searchLabel": "Search places in Poland",
"location.placeholder": "Enter a place, e.g. Piaseczno…",
"location.clear": "Clear search",
"location.error": "Unable to search for places. Try again.",
"location.preparing": "Preparing the nearest IMGW stations…",
"location.empty": "No matching place was found in Poland.",
"location.nearest": "Nearest IMGW station",
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
"location.attribution": "Place search:",
"featured.label": "Quick select",
"featured.title": "Selected IMGW stations",
"favorites.title": "Favourite locations",
"favorites.empty": "Add stations to favourites to keep their readings close at hand.",
"favorites.addStation": "Add {name} to favourites",
@@ -167,24 +176,7 @@ const translations = {
"weather.windDirectionDetail": "Direction the wind is coming from",
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
"weather.temperatureDetail": "Air temperature",
"stations.section": "Synoptic stations",
"stations.title": "Weather in Poland",
"stations.searchLabel": "Search synoptic stations",
"stations.searchPlaceholder": "Search IMGW stations…",
"stations.sortLabel": "Sort stations",
"stations.filterLabel": "Filter stations",
"stations.sortAlphabetical": "Alphabetically",
"stations.sortTemperatureDesc": "Temperature: highest",
"stations.sortTemperatureAsc": "Temperature: lowest",
"stations.sortHumidityDesc": "Humidity: highest",
"stations.sortPressureDesc": "Pressure: highest",
"stations.filterAll": "All stations",
"stations.filterWarmest": "Warmest",
"stations.filterColdest": "Coldest",
"stations.filterWindy": "Strongest wind",
"stations.filterRainy": "Highest rainfall",
"stations.emptyTitle": "No matching stations",
"stations.emptyDescription": "Adjust your search or select a different filter.",
"station.all": "All stations",
"station.label": "Station {name}",
"station.parameters": "Current parameters",

View File

@@ -5,7 +5,9 @@ import {
} from "@/lib/weather-utils";
import type {
HydroStation,
MeteoStationPosition,
RawHydroStation,
RawMeteoStation,
RawSynopStation,
RawWarning,
SynopStation,
@@ -39,6 +41,16 @@ export async function fetchHydroStations(signal?: AbortSignal): Promise<HydroSta
return rows.map(normalizeHydroStation).filter((station): station is HydroStation => station !== null);
}
export async function fetchMeteoStationPositions(signal?: AbortSignal): Promise<MeteoStationPosition[]> {
const rows = await getJson<RawMeteoStation[]>("meteo", signal);
return rows.flatMap((row) => {
const latitude = Number(row.lat);
const longitude = Number(row.lon);
if (!row.nazwa_stacji?.trim() || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
return [{ name: row.nazwa_stacji, latitude, longitude }];
});
}
async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Promise<WeatherWarning[]> {
const rows = await getJson<RawWarning[]>(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];

9
lib/location-api.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Language } from "@/lib/i18n";
import type { LocationSearchResult } from "@/types/location";
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
const params = new URLSearchParams({ query, language });
const response = await fetch(`/api/locations/search?${params}`, { signal });
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
return response.json() as Promise<LocationSearchResult[]>;
}

66
lib/location-utils.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
const stationAliases: Record<string, string> = {
gdansk: "gdanskrebiechowo",
gorzow: "gorzowwielkopolski",
katowice: "katowicemuchowiec",
kielce: "kielcesukow",
kolo: "koloradoszewice",
kolobrzeg: "kolobrzegdzwirzyno",
krakow: "krakowbalice",
lublin: "lublinradawiec",
lodz: "lodzlublinek",
poznan: "poznanlawica",
resko: "reskosmolsko",
rzeszow: "rzeszowjasionka",
warszawa: "warszawaokecie",
};
function normalizeName(value: string) {
return value
.replace(/[Łł]/g, "l")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]/g, "")
.toLowerCase();
}
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
const earthRadiusKm = 6371;
const toRadians = (value: number) => value * Math.PI / 180;
const latitudeDistance = toRadians(latitudeB - latitudeA);
const longitudeDistance = toRadians(longitudeB - longitudeA);
const a = Math.sin(latitudeDistance / 2) ** 2 +
Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export interface LocatedSynopStation extends SynopStation {
latitude: number;
longitude: number;
}
export function locateSynopStations(stations: SynopStation[], positions: MeteoStationPosition[]) {
const positionsByName = new Map(positions.map((position) => [normalizeName(position.name), position]));
return stations.flatMap<LocatedSynopStation>((station) => {
const normalizedStation = normalizeName(station.name);
const position = positionsByName.get(stationAliases[normalizedStation] ?? normalizedStation);
return position ? [{ ...station, latitude: position.latitude, longitude: position.longitude }] : [];
});
}
export function findNearestSynopStation(location: LocationSearchResult, stations: LocatedSynopStation[]): SelectedLocation | null {
const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
}, null);
if (!nearest) return null;
return {
name: location.name,
province: location.province,
stationId: nearest.station.id,
stationName: nearest.station.name,
distanceKm: Math.round(nearest.distanceKm),
};
}

View File

@@ -2,12 +2,15 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { SelectedLocation } from "@/types/location";
interface WeatherStore {
favorites: string[];
selectedStationId: string | null;
selectedLocation: SelectedLocation | null;
toggleFavorite: (id: string) => void;
selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void;
}
export const useWeatherStore = create<WeatherStore>()(
@@ -15,13 +18,15 @@ export const useWeatherStore = create<WeatherStore>()(
(set) => ({
favorites: [],
selectedStationId: null,
selectedLocation: null,
toggleFavorite: (id) =>
set((state) => ({
favorites: state.favorites.includes(id)
? state.favorites.filter((favoriteId) => favoriteId !== id)
: [...state.favorites, id],
})),
selectStation: (id) => set({ selectedStationId: id }),
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
}),
{ name: "wtr:preferences" },
),

View File

@@ -23,6 +23,19 @@ export interface SynopStation {
pressure: number | null;
}
export interface RawMeteoStation {
kod_stacji?: string | null;
nazwa_stacji?: string | null;
lon?: string | null;
lat?: string | null;
}
export interface MeteoStationPosition {
name: string;
latitude: number;
longitude: number;
}
export interface RawHydroStation {
id_stacji?: string | null;
stacja?: string | null;

16
types/location.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface LocationSearchResult {
id: number;
name: string;
latitude: number;
longitude: number;
province: string | null;
district: string | null;
}
export interface SelectedLocation {
name: string;
province: string | null;
stationId: string;
stationName: string;
distanceKm: number;
}