diff --git a/README.md b/README.md index 250b999..a3296b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/locations/search/route.ts b/app/api/locations/search/route.ts new file mode 100644 index 0000000..0ab0aac --- /dev/null +++ b/app/api/locations/search/route.ts @@ -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 }); + } +} diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index 98a446f..aa8aa65 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -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 ; if (isError || !stations?.length) return 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 (
- + + - +
); } diff --git a/components/weather/featured-stations-section.tsx b/components/weather/featured-stations-section.tsx new file mode 100644 index 0000000..022940a --- /dev/null +++ b/components/weather/featured-stations-section.tsx @@ -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 ( +
+
+

{t("featured.label")}

+

{t("featured.title")}

+
+
+ {featured.map((station) => { + const active = selectedStationId === station.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/components/weather/location-search.tsx b/components/weather/location-search.tsx new file mode 100644 index 0000000..2e2737f --- /dev/null +++ b/components/weather/location-search.tsx @@ -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 ( +
+
+
+ +

{t("location.label")}

+
+ + {selectedLocation && ( +

+ {t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} +

+ )} +

+ {t("location.attribution")} Open-Meteo / GeoNames +

+
+ {showSuggestions && ( +
+ {isPreparingStations ?

{t("location.preparing")}

: + isError ?

{t("location.error")}

: + !isFetching && !suggestions.length ?

{t("location.empty")}

: + suggestions.map(({ location, nearest }) => nearest && ( + + ))} +
+ )} +
+ ); +} diff --git a/components/weather/station-grid.tsx b/components/weather/station-grid.tsx deleted file mode 100644 index 352eacb..0000000 --- a/components/weather/station-grid.tsx +++ /dev/null @@ -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 ; - return
{stations.map((station, index) => )}
; -} diff --git a/components/weather/station-search.tsx b/components/weather/station-search.tsx deleted file mode 100644 index 2f139e3..0000000 --- a/components/weather/station-search.tsx +++ /dev/null @@ -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 ( -
- - - -
- ); -} diff --git a/components/weather/stations-explorer.tsx b/components/weather/stations-explorer.tsx deleted file mode 100644 index 3bf82b0..0000000 --- a/components/weather/stations-explorer.tsx +++ /dev/null @@ -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("alphabetical"); - const [filter, setFilter] = useState("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 ( -
-
-

{t("stations.section")}

-

{t("stations.title")}

-
- - -
- ); -} diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 6be395b..044ae6f 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -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 }) {
- {station.name} + {locationName ?? station.name} + {locationName && {t("location.heroSource", { station: station.name, distance: distanceKm ?? 0 })}}
diff --git a/hooks/use-location-search.ts b/hooks/use-location-search.ts new file mode 100644 index 0000000..f55dee5 --- /dev/null +++ b/hooks/use-location-search.ts @@ -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, + }); +} diff --git a/hooks/use-meteo-stations.ts b/hooks/use-meteo-stations.ts new file mode 100644 index 0000000..32dc4ba --- /dev/null +++ b/hooks/use-meteo-stations.ts @@ -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, + }); +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index bdb627c..0d47edf 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -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", diff --git a/lib/imgw-api.ts b/lib/imgw-api.ts index 69418c2..5cc6233 100644 --- a/lib/imgw-api.ts +++ b/lib/imgw-api.ts @@ -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 station !== null); } +export async function fetchMeteoStationPositions(signal?: AbortSignal): Promise { + const rows = await getJson("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 { const rows = await getJson(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal); return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : []; diff --git a/lib/location-api.ts b/lib/location-api.ts new file mode 100644 index 0000000..fd0b941 --- /dev/null +++ b/lib/location-api.ts @@ -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 { + 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; +} diff --git a/lib/location-utils.ts b/lib/location-utils.ts new file mode 100644 index 0000000..7898ec5 --- /dev/null +++ b/lib/location-utils.ts @@ -0,0 +1,66 @@ +import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; +import type { LocationSearchResult, SelectedLocation } from "@/types/location"; + +const stationAliases: Record = { + 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((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), + }; +} diff --git a/lib/store.ts b/lib/store.ts index 7a0fad5..106997d 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -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()( @@ -15,13 +18,15 @@ export const useWeatherStore = create()( (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" }, ), diff --git a/types/imgw.ts b/types/imgw.ts index a0fe2b9..7784deb 100644 --- a/types/imgw.ts +++ b/types/imgw.ts @@ -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; diff --git a/types/location.ts b/types/location.ts new file mode 100644 index 0000000..768391d --- /dev/null +++ b/types/location.ts @@ -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; +}