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;
}