From 487a480bbdfb2a6d1ff73b1cdc2eba42f59f9162 Mon Sep 17 00:00:00 2001 From: zv Date: Fri, 12 Jun 2026 19:16:57 +0200 Subject: [PATCH] Filter meteo warnings by local county --- AGENTS.md | 6 +- README.md | 2 +- app/api/locations/reverse/route.ts | 2 + app/api/notifications/check/route.ts | 5 +- app/api/notifications/daily-brief/route.ts | 1 + app/api/notifications/subscriptions/route.ts | 2 + components/dashboard/weather-brief-card.tsx | 4 +- components/settings/settings-page.tsx | 6 +- components/warnings/dashboard-warnings.tsx | 10 ++- components/warnings/warnings-panel.tsx | 12 +-- lib/i18n.tsx | 16 ++-- lib/location-utils.ts | 5 +- lib/notification-api.ts | 2 + lib/warning-regions.ts | 95 ++++++++++++++++++++ lib/weather-brief.ts | 10 ++- lib/weather-utils.ts | 1 + types/imgw.ts | 1 + types/location.ts | 3 + types/notifications.ts | 1 + 19 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 lib/warning-regions.ts diff --git a/AGENTS.md b/AGENTS.md index 5bde5b9..f15066d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,9 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. - Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, 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. -- Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. -- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. +- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. +- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. +- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. - Powiadomienia Web Push o ostrzeżeniach meteo i porannym briefie konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - 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. diff --git a/README.md b/README.md index d9980e7..9344a8f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. 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. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego województwa. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych. +Widok ostrzeżeń priorytetyzuje komunikaty dla obszaru wynikającego z miejscowości lub stacji wybranej w pogodzie. Dla ostrzeżeń meteorologicznych, gdy aplikacja rozpozna powiat wybranej miejscowości, filtrowanie odbywa się po kodzie TERYT powiatu; w przeciwnym razie pozostaje fallback wojewódzki. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. 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. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego obszaru. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych. ## Stack diff --git a/app/api/locations/reverse/route.ts b/app/api/locations/reverse/route.ts index 38a445d..4a43dfd 100644 --- a/app/api/locations/reverse/route.ts +++ b/app/api/locations/reverse/route.ts @@ -11,6 +11,7 @@ interface RawReverseLocation { town?: string; village?: string; municipality?: string; + county?: string; state?: string; }; } @@ -56,6 +57,7 @@ export async function GET(request: Request) { return NextResponse.json({ name, province: data.address?.state ?? null, + district: data.address?.county ?? null, }, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, }); diff --git a/app/api/notifications/check/route.ts b/app/api/notifications/check/route.ts index e2eee98..f0e7820 100644 --- a/app/api/notifications/check/route.ts +++ b/app/api/notifications/check/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { fetchMeteoWarnings } from "@/lib/server-warnings"; import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store"; import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service"; +import { warningMatchesCounty } from "@/lib/warning-regions"; import type { WeatherWarning } from "@/types/imgw"; export const runtime = "nodejs"; @@ -44,7 +45,9 @@ export async function GET(request: Request) { pruneSentWarnings(activeWarningIds); for (const subscription of subscriptions) { - const matchingWarnings = warnings.filter((warning) => warning.provinces.includes(subscription.province)); + const matchingWarnings = warnings.filter((warning) => ( + subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province) + )); for (const warning of matchingWarnings) { if (hasSentWarning(subscription.endpoint, warning.id)) { skipped += 1; diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts index 03b5034..9aec50f 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -59,6 +59,7 @@ export async function GET(request: Request) { forecast, warnings, province: subscription.province, + countyTeryt: subscription.countyTeryt, locationName: subscription.locationName ?? "wtr.", language: subscription.language, now, diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index 95c1d36..abe9ede 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -30,6 +30,7 @@ export async function POST(request: Request) { latitude?: unknown; longitude?: unknown; locationName?: unknown; + countyTeryt?: unknown; } | null; const subscription = body?.subscription; const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); @@ -50,6 +51,7 @@ export async function POST(request: Request) { latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null, longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null, locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null, + countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null, createdAt: now, updatedAt: now, }); diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index c55f6d3..f568c23 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -24,8 +24,8 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu const brief = useMemo(() => { if (!forecast) return null; - return buildWeatherBrief({ forecast, warnings, province, locationName, language }); - }, [forecast, language, locationName, province, warnings]); + return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { return ; diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 3a14f49..ec6419e 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -86,6 +86,7 @@ export function SettingsPage() { const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null; const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null; const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null; + const notificationCountyTeryt = activeLocation?.countyTeryt ?? null; const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); @@ -121,6 +122,7 @@ export function SettingsPage() { latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + countyTeryt: notificationCountyTeryt, }).catch(() => undefined); } @@ -128,7 +130,7 @@ export function SettingsPage() { return () => { cancelled = true; }; - }, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]); + }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]); const notificationStatusLabel = useMemo(() => { switch (notificationStatus) { @@ -185,6 +187,7 @@ export function SettingsPage() { latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + countyTeryt: notificationCountyTeryt, }); setNotificationsEnabled(true); setNotificationMessage(t("settings.notifications.saveSuccess")); @@ -244,6 +247,7 @@ export function SettingsPage() { latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + countyTeryt: notificationCountyTeryt, }); } catch { setNotificationMessage(t("settings.notifications.saveError")); diff --git a/components/warnings/dashboard-warnings.tsx b/components/warnings/dashboard-warnings.tsx index 6979c07..ade514f 100644 --- a/components/warnings/dashboard-warnings.tsx +++ b/components/warnings/dashboard-warnings.tsx @@ -10,6 +10,7 @@ import { useI18n } from "@/lib/i18n"; import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; import { formatDateTime } from "@/lib/weather-utils"; +import { warningMatchesLocalSelection } from "@/lib/warning-regions"; import type { WeatherWarning } from "@/types/imgw"; const MAX_VISIBLE_WARNINGS = 2; @@ -52,6 +53,7 @@ export function DashboardWarnings() { }, []); const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null; const relevantWarnings = useMemo(() => { if (!warnings || !province || now === null) return []; @@ -59,13 +61,13 @@ export function DashboardWarnings() { .filter((warning) => { const validTo = getTimestamp(warning.validTo); return warning.kind === "meteo" - && warning.provinces.includes(province) + && warningMatchesLocalSelection(warning, province, selectedLocation) && (validTo === null || validTo > now); }) .sort((a, b) => compareDashboardWarnings(a, b, now)); - }, [now, province, warnings]); + }, [now, province, selectedLocation, warnings]); - if (!province || !relevantWarnings.length || now === null) return null; + if (!province || !regionLabel || !relevantWarnings.length || now === null) return null; const visibleWarnings = relevantWarnings.slice(0, MAX_VISIBLE_WARNINGS); const hiddenWarningsCount = relevantWarnings.length - visibleWarnings.length; @@ -85,7 +87,7 @@ export function DashboardWarnings() {

- IMGW · {formatProvinceName(province, language)} + IMGW · {regionLabel}

{t("warnings.dashboard.title")} diff --git a/components/warnings/warnings-panel.tsx b/components/warnings/warnings-panel.tsx index e54d347..076a870 100644 --- a/components/warnings/warnings-panel.tsx +++ b/components/warnings/warnings-panel.tsx @@ -10,6 +10,7 @@ import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; +import { warningMatchesLocalSelection } from "@/lib/warning-regions"; import type { WeatherWarning } from "@/types/imgw"; function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) { @@ -33,20 +34,21 @@ export function WarningsPanel() { 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)); + const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel; + const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation)); + const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation)); return (

{t("warnings.myProvince")}

-

{provinceLabel}

-

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

+

{localLabel}

+

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

{localWarnings.length ? - : } + : }
{otherWarnings.length > 0 && ( diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 32484b5..888b38c 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -192,10 +192,10 @@ 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.myProvince": "Mój obszar", + "warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.", + "warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru", + "warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {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.", @@ -423,10 +423,10 @@ 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.myProvince": "My area", + "warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.", + "warnings.myProvinceEmptyTitle": "No warnings for your area", + "warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {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.", diff --git a/lib/location-utils.ts b/lib/location-utils.ts index 45143e9..a6071bb 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -1,5 +1,6 @@ import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { LocationSearchResult, SelectedLocation } from "@/types/location"; +import { getCountyTerytForLocation } from "@/lib/warning-regions"; const stationAliases: Record = { gdansk: "gdanskrebiechowo", @@ -51,7 +52,7 @@ export function locateSynopStations(stations: SynopStation[], positions: MeteoSt } export function findNearestSynopStation( - location: Pick, + location: Pick, stations: LocatedSynopStation[], ): SelectedLocation | null { const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => { @@ -62,6 +63,8 @@ export function findNearestSynopStation( return { name: location.name, province: location.province, + district: location.district, + countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name), latitude: location.latitude, longitude: location.longitude, stationId: nearest.station.id, diff --git a/lib/notification-api.ts b/lib/notification-api.ts index 9470ae7..5ff52fd 100644 --- a/lib/notification-api.ts +++ b/lib/notification-api.ts @@ -17,6 +17,7 @@ interface SavePushSubscriptionOptions { latitude?: number | null; longitude?: number | null; locationName?: string | null; + countyTeryt?: string | null; } export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) { @@ -32,6 +33,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi latitude: options.latitude ?? null, longitude: options.longitude ?? null, locationName: options.locationName ?? null, + countyTeryt: options.countyTeryt ?? null, }), }); if (!response.ok) throw new Error("Unable to save push subscription."); diff --git a/lib/warning-regions.ts b/lib/warning-regions.ts new file mode 100644 index 0000000..af3592c --- /dev/null +++ b/lib/warning-regions.ts @@ -0,0 +1,95 @@ +import { getProvinceForSelection } from "@/lib/provinces"; +import type { WeatherWarning } from "@/types/imgw"; +import type { SelectedLocation } from "@/types/location"; +import type { Province } from "@/types/province"; + +const mazowieckieCountyTerytByName: Record = { + bialobrzeski: "1401", + ciechanowski: "1402", + garwolinski: "1403", + gostyninski: "1404", + grodziski: "1405", + grojecki: "1406", + kozienicki: "1407", + legionowski: "1408", + lipski: "1409", + losicki: "1410", + makowski: "1411", + minski: "1412", + mlawski: "1413", + nowodworski: "1414", + ostrolecki: "1415", + ostrowski: "1416", + otwocki: "1417", + piaseczynski: "1418", + plocki: "1419", + plonski: "1420", + pruszkowski: "1421", + przasnyski: "1422", + przysuski: "1423", + pultuski: "1424", + radomski: "1425", + siedlecki: "1426", + sierpecki: "1427", + sochaczewski: "1428", + sokolowski: "1429", + szydlowiecki: "1430", + warszawski: "1431", + warszawski_zachodni: "1432", + wegrowski: "1433", + wolominski: "1434", + wyszkowski: "1435", + zwolenski: "1436", + zurominski: "1437", + zyrardowski: "1438", + ostroleka: "1461", + plock: "1462", + radom: "1463", + siedlce: "1464", + warszawa: "1465", +}; + +function normalizeRegionName(value: string | null | undefined) { + return value + ?.replace(/[Łł]/g, "l") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase("pl-PL") + .replace(/^powiat\s+/, "") + .replace(/^m\.\s*/, "") + .replace(/^miasto\s+/, "") + .replace(/\s+powiat$/, "") + .replace(/\s+/g, "_") + .replace(/[^a-z0-9_]/g, "") + .replace(/^warszawa_zachodnia$/, "warszawski_zachodni") + .trim() ?? ""; +} + +function normalizeTerytCountyCode(value: string) { + const code = value.trim(); + return /^\d{4}/.test(code) ? code.slice(0, 4) : null; +} + +export function getCountyTerytForLocation(province: string | null | undefined, district: string | null | undefined, locationName: string | null | undefined) { + const normalizedProvince = getProvinceForSelection(province, null); + if (normalizedProvince !== "mazowieckie") return null; + + const normalizedDistrict = normalizeRegionName(district); + const districtMatch = normalizedDistrict ? mazowieckieCountyTerytByName[normalizedDistrict] : null; + if (districtMatch) return districtMatch; + + const normalizedLocation = normalizeRegionName(locationName); + return normalizedLocation ? mazowieckieCountyTerytByName[normalizedLocation] ?? null : null; +} + +export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: string | null | undefined) { + if (!countyTeryt || !warning.terytCodes.length) return false; + return warning.terytCodes.some((code) => normalizeTerytCountyCode(code) === countyTeryt); +} + +export function warningMatchesLocalSelection(warning: WeatherWarning, province: Province | null, selectedLocation?: SelectedLocation | null) { + if (warning.kind === "meteo" && selectedLocation?.countyTeryt) { + return warningMatchesCounty(warning, selectedLocation.countyTeryt); + } + return province ? warning.provinces.includes(province) : false; +} diff --git a/lib/weather-brief.ts b/lib/weather-brief.ts index 1aa902e..91dddfb 100644 --- a/lib/weather-brief.ts +++ b/lib/weather-brief.ts @@ -4,6 +4,7 @@ import { formatProvinceName } from "@/lib/provinces"; import type { HourlyForecast, WeatherForecast } from "@/types/forecast"; import type { WeatherWarning } from "@/types/imgw"; import type { Province } from "@/types/province"; +import { warningMatchesCounty } from "@/lib/warning-regions"; export type WeatherBriefSeverity = "normal" | "attention" | "warning"; @@ -20,6 +21,7 @@ interface BuildWeatherBriefInput { forecast: WeatherForecast; warnings: WeatherWarning[]; province: Province | null; + countyTeryt?: string | null; locationName: string; language: Language; now?: Date; @@ -112,10 +114,10 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) = }, null); } -function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, now: number) { +function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, now: number) { const validTo = getTimestamp(warning.validTo); return warning.kind === "meteo" - && (!province || warning.provinces.includes(province)) + && (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) && (validTo === null || validTo > now); } @@ -125,12 +127,12 @@ function compareWarnings(a: WeatherWarning, b: WeatherWarning) { return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0); } -export function buildWeatherBrief({ forecast, warnings, province, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { +export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { const hours = getUpcomingHours(forecast.hourly, now, 24); if (!hours.length) return null; const relevantWarnings = warnings - .filter((warning) => isRelevantMeteoWarning(warning, province, now.getTime())) + .filter((warning) => isRelevantMeteoWarning(warning, province, countyTeryt, now.getTime())) .sort(compareWarnings); const topWarning = relevantWarnings[0] ?? null; const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts index 6306105..03e3b4b 100644 --- a/lib/weather-utils.ts +++ b/lib/weather-utils.ts @@ -90,6 +90,7 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb publishedAt: normalizeDate(raw.opublikowano), probability: toNumber(raw.prawdopodobienstwo), areas, + terytCodes: Array.isArray(raw.teryt) ? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0) : [], provinces, office: raw.biuro?.trim() || null, }; diff --git a/types/imgw.ts b/types/imgw.ts index edb3415..f11d8c7 100644 --- a/types/imgw.ts +++ b/types/imgw.ts @@ -112,6 +112,7 @@ export interface WeatherWarning { publishedAt: string | null; probability: number | null; areas: string[]; + terytCodes: string[]; provinces: Province[]; office: string | null; } diff --git a/types/location.ts b/types/location.ts index a8bb2cb..d57b3e7 100644 --- a/types/location.ts +++ b/types/location.ts @@ -10,11 +10,14 @@ export interface LocationSearchResult { export interface ReverseLocationResult { name: string; province: string | null; + district: string | null; } export interface SelectedLocation { name: string; province: string | null; + district: string | null; + countyTeryt: string | null; latitude: number; longitude: number; stationId: string; diff --git a/types/notifications.ts b/types/notifications.ts index d1b4a48..608d9c2 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -22,6 +22,7 @@ export interface WarningPushSubscription { latitude: number | null; longitude: number | null; locationName: string | null; + countyTeryt: string | null; createdAt: string; updatedAt: string; }