diff --git a/AGENTS.md b/AGENTS.md index 2bca59b..801dfcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. - Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. +- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. diff --git a/README.md b/README.md index f65a2e4..ec5a98f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazw Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari. +Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej. + ## Stack - Next.js z App Router i TypeScript diff --git a/components/warnings/warnings-panel.tsx b/components/warnings/warnings-panel.tsx index befcbaf..d77b3fb 100644 --- a/components/warnings/warnings-panel.tsx +++ b/components/warnings/warnings-panel.tsx @@ -1,17 +1,64 @@ "use client"; +import { Map, MapPinned } from "lucide-react"; import { useWarnings } from "@/hooks/use-warnings"; import { WarningCard } from "@/components/warnings/warning-card"; import { PageLoadingSkeleton } from "@/components/states/loading-skeleton"; import { EmptyState } from "@/components/states/empty-state"; import { ErrorState } from "@/components/states/error-state"; +import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; +import { formatProvinceName, getProvinceForStation, normalizeProvinceName } from "@/lib/provinces"; +import { useWeatherStore } from "@/lib/store"; +import type { WeatherWarning } from "@/types/imgw"; + +function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) { + return ( +
+ {warnings.map((warning, index) => )} +
+ ); +} export function WarningsPanel() { - const { t } = useI18n(); + const { language, t } = useI18n(); const { data: warnings, isPending, isError, refetch } = useWarnings(); + const selectedStationId = useWeatherStore((state) => state.selectedStationId); + const selectedLocation = useWeatherStore((state) => state.selectedLocation); if (isPending) return ; if (isError) return refetch()} description={t("warnings.error")} />; if (!warnings?.length) return ; - return
{warnings.map((warning, index) => )}
; + + const province = normalizeProvinceName(selectedLocation?.province) + ?? getProvinceForStation(selectedStationId ?? DEFAULT_STATION_ID); + if (!province) return ; + + const provinceLabel = formatProvinceName(province, language); + const localWarnings = warnings.filter((warning) => warning.provinces.includes(province)); + const otherWarnings = warnings.filter((warning) => !warning.provinces.includes(province)); + + return ( +
+
+
+

{t("warnings.myProvince")}

+

{provinceLabel}

+

{t("warnings.myProvinceDescription", { province: provinceLabel })}

+
+ {localWarnings.length + ? + : } +
+ + {otherWarnings.length > 0 && ( +
+
+

{t("warnings.otherRegions")}

+

{t("warnings.otherRegionsDescription")}

+
+ +
+ )} +
+ ); } diff --git a/lib/constants.ts b/lib/constants.ts index 75e7c94..50e1612 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,4 +1,5 @@ export const DEFAULT_STATION_NAME = "Warszawa"; +export const DEFAULT_STATION_ID = "12375"; export const APP_NAME = "wtr."; export const APP_TAGLINE = "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie."; diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 45df055..a30bfca 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -135,6 +135,12 @@ const translations = { "warnings.section": "Komunikaty IMGW", "warnings.title": "Ostrzeżenia", "warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.", + "warnings.myProvince": "Moje województwo", + "warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące województwa {province}, zgodnie z lokalizacją wybraną w pogodzie.", + "warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego województwa", + "warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących województwa {province}.", + "warnings.otherRegions": "Pozostałe regiony", + "warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.", "warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.emptyTitle": "Brak aktywnych ostrzeżeń", "warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.", @@ -296,6 +302,12 @@ const translations = { "warnings.section": "IMGW notices", "warnings.title": "Warnings", "warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.", + "warnings.myProvince": "My province", + "warnings.myProvinceDescription": "IMGW notices for the {province} province are shown first, based on the location selected in weather.", + "warnings.myProvinceEmptyTitle": "No warnings for your province", + "warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for the {province} province.", + "warnings.otherRegions": "Other regions", + "warnings.otherRegionsDescription": "Active IMGW notices for the remaining areas of Poland.", "warnings.error": "Unable to load meteorological or hydrological warnings.", "warnings.emptyTitle": "No active warnings", "warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.", diff --git a/lib/provinces.ts b/lib/provinces.ts new file mode 100644 index 0000000..d7849dd --- /dev/null +++ b/lib/provinces.ts @@ -0,0 +1,133 @@ +import type { Language } from "@/lib/i18n"; +import type { Province } from "@/types/province"; + +const provinceByTerytPrefix: Record = { + "02": "dolnośląskie", + "04": "kujawsko-pomorskie", + "06": "lubelskie", + "08": "lubuskie", + "10": "łódzkie", + "12": "małopolskie", + "14": "mazowieckie", + "16": "opolskie", + "18": "podkarpackie", + "20": "podlaskie", + "22": "pomorskie", + "24": "śląskie", + "26": "świętokrzyskie", + "28": "warmińsko-mazurskie", + "30": "wielkopolskie", + "32": "zachodniopomorskie", +}; + +const provinceByStationId: Record = { + "12100": "zachodniopomorskie", + "12105": "zachodniopomorskie", + "12115": "pomorskie", + "12120": "pomorskie", + "12125": "pomorskie", + "12135": "pomorskie", + "12155": "pomorskie", + "12160": "warmińsko-mazurskie", + "12185": "warmińsko-mazurskie", + "12195": "podlaskie", + "12200": "zachodniopomorskie", + "12205": "zachodniopomorskie", + "12210": "zachodniopomorskie", + "12215": "zachodniopomorskie", + "12230": "wielkopolskie", + "12235": "pomorskie", + "12250": "kujawsko-pomorskie", + "12270": "mazowieckie", + "12272": "warmińsko-mazurskie", + "12280": "warmińsko-mazurskie", + "12285": "mazowieckie", + "12295": "podlaskie", + "12300": "lubuskie", + "12310": "lubuskie", + "12330": "wielkopolskie", + "12345": "wielkopolskie", + "12360": "mazowieckie", + "12375": "mazowieckie", + "12385": "mazowieckie", + "12399": "lubelskie", + "12400": "lubuskie", + "12415": "dolnośląskie", + "12418": "wielkopolskie", + "12424": "dolnośląskie", + "12435": "wielkopolskie", + "12455": "łódzkie", + "12465": "łódzkie", + "12469": "łódzkie", + "12488": "mazowieckie", + "12495": "lubelskie", + "12497": "lubelskie", + "12500": "dolnośląskie", + "12510": "dolnośląskie", + "12520": "dolnośląskie", + "12530": "opolskie", + "12540": "śląskie", + "12550": "śląskie", + "12560": "śląskie", + "12566": "małopolskie", + "12570": "świętokrzyskie", + "12575": "małopolskie", + "12580": "podkarpackie", + "12585": "świętokrzyskie", + "12595": "lubelskie", + "12600": "śląskie", + "12625": "małopolskie", + "12650": "małopolskie", + "12660": "małopolskie", + "12670": "podkarpackie", + "12690": "podkarpackie", + "12695": "podkarpackie", +}; + +const provinceLabels: Record> = { + "dolnośląskie": { pl: "dolnośląskie", en: "Lower Silesian" }, + "kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" }, + "lubelskie": { pl: "lubelskie", en: "Lublin" }, + "lubuskie": { pl: "lubuskie", en: "Lubusz" }, + "łódzkie": { pl: "łódzkie", en: "Łódź" }, + "małopolskie": { pl: "małopolskie", en: "Lesser Poland" }, + "mazowieckie": { pl: "mazowieckie", en: "Masovian" }, + "opolskie": { pl: "opolskie", en: "Opole" }, + "podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" }, + "podlaskie": { pl: "podlaskie", en: "Podlaskie" }, + "pomorskie": { pl: "pomorskie", en: "Pomeranian" }, + "śląskie": { pl: "śląskie", en: "Silesian" }, + "świętokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" }, + "warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" }, + "wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" }, + "zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" }, +}; + +function simplifyProvinceName(value: string) { + return value + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLocaleLowerCase("pl-PL") + .replace(/^wojewodztwo\s+/, "") + .trim(); +} + +const provinceBySimplifiedName = Object.fromEntries( + Object.keys(provinceLabels).map((province) => [simplifyProvinceName(province), province]), +) as Record; + +export function getProvinceFromTeryt(code: string) { + return provinceByTerytPrefix[code.trim().slice(0, 2)] ?? null; +} + +export function getProvinceForStation(stationId: string | null) { + return stationId ? provinceByStationId[stationId] ?? null : null; +} + +export function normalizeProvinceName(value: string | null | undefined) { + return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null; +} + +export function formatProvinceName(province: Province, language: Language) { + return provinceLabels[province][language]; +} diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts index 2f1b4b7..9b71760 100644 --- a/lib/weather-utils.ts +++ b/lib/weather-utils.ts @@ -9,6 +9,7 @@ import type { WarningKind, } from "@/types/imgw"; import { translate, type Language } from "@/lib/i18n"; +import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces"; const locales: Record = { pl: "pl-PL", en: "en-GB" }; @@ -67,10 +68,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null } export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning { + const provinces = [...new Set([ + ...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)), + ...(raw.teryt ?? []).map(getProvinceFromTeryt), + ].filter((province) => province !== null))]; const describedAreas = (raw.obszary ?? []) .map((area) => area.opis?.trim() || area.wojewodztwo?.trim()) .filter((area): area is string => Boolean(area)); - const areas = describedAreas.length ? describedAreas : (raw.teryt ?? []).map((code) => `TERYT ${code}`); + const areas = describedAreas.length ? describedAreas : provinces; const title = raw.zdarzenie?.trim() || raw.nazwa_zdarzenia?.trim() || ""; return { id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`, @@ -84,6 +89,7 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb publishedAt: normalizeDate(raw.opublikowano), probability: toNumber(raw.prawdopodobienstwo), areas, + provinces, office: raw.biuro?.trim() || null, }; } diff --git a/types/imgw.ts b/types/imgw.ts index 5976a12..edb3415 100644 --- a/types/imgw.ts +++ b/types/imgw.ts @@ -112,7 +112,9 @@ export interface WeatherWarning { publishedAt: string | null; probability: number | null; areas: string[]; + provinces: Province[]; office: string | null; } export type WeatherMood = "warm" | "cloudy" | "wind" | "cold" | "night" | "mild"; +import type { Province } from "@/types/province"; diff --git a/types/province.ts b/types/province.ts new file mode 100644 index 0000000..1f08302 --- /dev/null +++ b/types/province.ts @@ -0,0 +1,17 @@ +export type Province = + | "dolnośląskie" + | "kujawsko-pomorskie" + | "lubelskie" + | "lubuskie" + | "łódzkie" + | "małopolskie" + | "mazowieckie" + | "opolskie" + | "podkarpackie" + | "podlaskie" + | "pomorskie" + | "śląskie" + | "świętokrzyskie" + | "warmińsko-mazurskie" + | "wielkopolskie" + | "zachodniopomorskie";