Filter meteo warnings by local county

This commit is contained in:
zv
2026-06-12 19:16:57 +02:00
parent 5eaaff963f
commit 487a480bbd
19 changed files with 154 additions and 30 deletions

View File

@@ -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.",

View File

@@ -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<string, string> = {
gdansk: "gdanskrebiechowo",
@@ -51,7 +52,7 @@ export function locateSynopStations(stations: SynopStation[], positions: MeteoSt
}
export function findNearestSynopStation(
location: Pick<LocationSearchResult, "name" | "province" | "latitude" | "longitude">,
location: Pick<LocationSearchResult, "name" | "province" | "district" | "latitude" | "longitude">,
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,

View File

@@ -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.");

95
lib/warning-regions.ts Normal file
View File

@@ -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<string, string> = {
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;
}

View File

@@ -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));

View File

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