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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <LoadingSkeleton className="h-48" />;

View File

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

View File

@@ -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() {
</div>
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
IMGW · {formatProvinceName(province, language)}
IMGW · {regionLabel}
</p>
<h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")}

View File

@@ -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 <WarningGrid warnings={warnings} />;
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 (
<div className="space-y-9">
<section className="space-y-4">
<div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p>
</div>
{localWarnings.length
? <WarningGrid warnings={localWarnings} />
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: provinceLabel })} />}
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />}
</section>
{otherWarnings.length > 0 && (

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

View File

@@ -112,6 +112,7 @@ export interface WeatherWarning {
publishedAt: string | null;
probability: number | null;
areas: string[];
terytCodes: string[];
provinces: Province[];
office: string | null;
}

View File

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

View File

@@ -22,6 +22,7 @@ export interface WarningPushSubscription {
latitude: number | null;
longitude: number | null;
locationName: string | null;
countyTeryt: string | null;
createdAt: string;
updatedAt: string;
}