Compare commits

...

3 Commits

Author SHA1 Message Date
zv
487a480bbd Filter meteo warnings by local county 2026-06-12 19:16:57 +02:00
zv
5eaaff963f Avoid stale IMGW Hybrid current readings 2026-06-12 18:56:45 +02:00
zv
fd2e108205 Add notification scheduler worker 2026-06-12 18:46:25 +02:00
23 changed files with 312 additions and 60 deletions

View File

@@ -2,3 +2,6 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
WEB_PUSH_VAPID_PRIVATE_KEY= WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00

View File

@@ -25,9 +25,10 @@ npm run dev
npm run lint npm run lint
npm run build npm run build
npm run start npm run start
npm run notifications:worker
``` ```
Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani formattera. `npm run build` uruchamia kontrolę TypeScript wykonywaną przez Next.js. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow. Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani formattera. `npm run build` uruchamia kontrolę TypeScript wykonywaną przez Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
## Konwencje kodu ## Konwencje kodu
@@ -38,15 +39,15 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji. - Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`. - Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
- 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. - 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. - 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. - `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. - 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 województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - 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 województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - 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. - 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` i route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`. 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. - 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. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.

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. 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 ## Stack
@@ -43,6 +43,12 @@ npm run build
npm run start npm run start
``` ```
Worker powiadomień uruchamiany obok aplikacji:
```bash
npm run notifications:worker
```
## Źródła danych ## Źródła danych
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW: Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
@@ -67,7 +73,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i
## Ograniczenia API ## Ograniczenia API
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu. Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Jeśli pełny rekord 10-minutowy jest wyraźnie starszy niż pełny rekord godzinowy z tej samej odpowiedzi, aplikacja wybiera świeższy rekord godzinowy zamiast nadpisywać aktualny fallback starymi danymi. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
@@ -131,7 +137,7 @@ public/ manifest, ikony i service worker
Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości. Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości.
Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia, poranny brief i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, `/api/notifications/daily-brief` wysyła raz dziennie krótkie podsumowanie dla subskrypcji z zapisaną lokalizacją, a `/api/notifications/test` wysyła test na wskazany endpoint subskrypcji. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV. Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia, poranny brief i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, `/api/notifications/daily-brief` wysyła raz dziennie krótkie podsumowanie dla subskrypcji z zapisaną lokalizacją, a `/api/notifications/test` wysyła test na wskazany endpoint subskrypcji. Endpointy harmonogramu nie uruchamiają się same: na self-hostingu uruchom `npm run notifications:worker` obok `npm run start`, albo użyj zewnętrznego crona. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV.
Do wysyłki Web Push wymagane są zmienne środowiskowe: Do wysyłki Web Push wymagane są zmienne środowiskowe:
@@ -140,9 +146,12 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
WEB_PUSH_VAPID_PRIVATE_KEY= WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
``` ```
Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron ostrzeżeń powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Drugi cron, ustawiony na około 07:00 czasu polskiego, powinien wywoływać `GET /api/notifications/daily-brief` z tym samym nagłówkiem. Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Worker `npm run notifications:worker` domyślnie odpytuje `http://127.0.0.1:3000`, sprawdza ostrzeżenia co 5 minut i wysyła poranny brief po 07:00 czasu polskiego. Jeśli używasz zewnętrznego crona zamiast workera, cron ostrzeżeń powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Drugi cron, ustawiony na około 07:00 czasu polskiego, powinien wywoływać `GET /api/notifications/daily-brief` z tym samym nagłówkiem.
## Wdrożenie na Vercel ## Wdrożenie na Vercel

View File

@@ -11,6 +11,7 @@ interface RawReverseLocation {
town?: string; town?: string;
village?: string; village?: string;
municipality?: string; municipality?: string;
county?: string;
state?: string; state?: string;
}; };
} }
@@ -56,6 +57,7 @@ export async function GET(request: Request) {
return NextResponse.json({ return NextResponse.json({
name, name,
province: data.address?.state ?? null, province: data.address?.state ?? null,
district: data.address?.county ?? null,
}, { }, {
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, 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 { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store"; import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store";
import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service"; import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
import { warningMatchesCounty } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -44,7 +45,9 @@ export async function GET(request: Request) {
pruneSentWarnings(activeWarningIds); pruneSentWarnings(activeWarningIds);
for (const subscription of subscriptions) { 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) { for (const warning of matchingWarnings) {
if (hasSentWarning(subscription.endpoint, warning.id)) { if (hasSentWarning(subscription.endpoint, warning.id)) {
skipped += 1; skipped += 1;

View File

@@ -59,6 +59,7 @@ export async function GET(request: Request) {
forecast, forecast,
warnings, warnings,
province: subscription.province, province: subscription.province,
countyTeryt: subscription.countyTeryt,
locationName: subscription.locationName ?? "wtr.", locationName: subscription.locationName ?? "wtr.",
language: subscription.language, language: subscription.language,
now, now,

View File

@@ -30,6 +30,7 @@ export async function POST(request: Request) {
latitude?: unknown; latitude?: unknown;
longitude?: unknown; longitude?: unknown;
locationName?: unknown; locationName?: unknown;
countyTeryt?: unknown;
} | null; } | null;
const subscription = body?.subscription; const subscription = body?.subscription;
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); 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, latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : 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, 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, createdAt: now,
updatedAt: now, updatedAt: now,
}); });

View File

@@ -24,8 +24,8 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
const brief = useMemo(() => { const brief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, locationName, language }); return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language });
}, [forecast, language, locationName, province, warnings]); }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />; 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 notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null; const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null; const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
const notificationCountyTeryt = activeLocation?.countyTeryt ?? null;
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
@@ -121,6 +122,7 @@ export function SettingsPage() {
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt,
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -128,7 +130,7 @@ export function SettingsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]); }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]);
const notificationStatusLabel = useMemo(() => { const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) { switch (notificationStatus) {
@@ -185,6 +187,7 @@ export function SettingsPage() {
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt,
}); });
setNotificationsEnabled(true); setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess")); setNotificationMessage(t("settings.notifications.saveSuccess"));
@@ -244,6 +247,7 @@ export function SettingsPage() {
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));

View File

@@ -10,6 +10,7 @@ import { useI18n } from "@/lib/i18n";
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { formatDateTime } from "@/lib/weather-utils"; import { formatDateTime } from "@/lib/weather-utils";
import { warningMatchesLocalSelection } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
const MAX_VISIBLE_WARNINGS = 2; const MAX_VISIBLE_WARNINGS = 2;
@@ -52,6 +53,7 @@ export function DashboardWarnings() {
}, []); }, []);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); 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(() => { const relevantWarnings = useMemo(() => {
if (!warnings || !province || now === null) return []; if (!warnings || !province || now === null) return [];
@@ -59,13 +61,13 @@ export function DashboardWarnings() {
.filter((warning) => { .filter((warning) => {
const validTo = getTimestamp(warning.validTo); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return warning.kind === "meteo"
&& warning.provinces.includes(province) && warningMatchesLocalSelection(warning, province, selectedLocation)
&& (validTo === null || validTo > now); && (validTo === null || validTo > now);
}) })
.sort((a, b) => compareDashboardWarnings(a, b, 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 visibleWarnings = relevantWarnings.slice(0, MAX_VISIBLE_WARNINGS);
const hiddenWarningsCount = relevantWarnings.length - visibleWarnings.length; const hiddenWarningsCount = relevantWarnings.length - visibleWarnings.length;
@@ -85,7 +87,7 @@ export function DashboardWarnings() {
</div> </div>
<div> <div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
IMGW · {formatProvinceName(province, language)} IMGW · {regionLabel}
</p> </p>
<h2 className="mt-1 text-base font-semibold text-foreground"> <h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")} {t("warnings.dashboard.title")}

View File

@@ -10,6 +10,7 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { warningMatchesLocalSelection } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) { function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
@@ -33,20 +34,21 @@ export function WarningsPanel() {
if (!province) return <WarningGrid warnings={warnings} />; if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language); const provinceLabel = formatProvinceName(province, language);
const localWarnings = warnings.filter((warning) => warning.provinces.includes(province)); const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel;
const otherWarnings = warnings.filter((warning) => !warning.provinces.includes(province)); const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation));
const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation));
return ( return (
<div className="space-y-9"> <div className="space-y-9">
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p> <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> <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: provinceLabel })}</p> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p>
</div> </div>
{localWarnings.length {localWarnings.length
? <WarningGrid warnings={localWarnings} /> ? <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> </section>
{otherWarnings.length > 0 && ( {otherWarnings.length > 0 && (

View File

@@ -192,10 +192,10 @@ const translations = {
"warnings.section": "Komunikaty IMGW", "warnings.section": "Komunikaty IMGW",
"warnings.title": "Ostrzeżenia", "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.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.myProvince": "Mój obszar",
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące województwa {province}, zgodnie z lokalizacją wybraną w pogodzie.", "warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego województwa", "warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru",
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących województwa {province}.", "warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
"warnings.otherRegions": "Pozostałe regiony", "warnings.otherRegions": "Pozostałe regiony",
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.", "warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
@@ -423,10 +423,10 @@ const translations = {
"warnings.section": "IMGW notices", "warnings.section": "IMGW notices",
"warnings.title": "Warnings", "warnings.title": "Warnings",
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.", "warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
"warnings.myProvince": "My province", "warnings.myProvince": "My area",
"warnings.myProvinceDescription": "IMGW notices for the {province} province are shown first, based on the location selected in weather.", "warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.",
"warnings.myProvinceEmptyTitle": "No warnings for your province", "warnings.myProvinceEmptyTitle": "No warnings for your area",
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for the {province} province.", "warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
"warnings.otherRegions": "Other regions", "warnings.otherRegions": "Other regions",
"warnings.otherRegionsDescription": "Active IMGW notices for the remaining areas of Poland.", "warnings.otherRegionsDescription": "Active IMGW notices for the remaining areas of Poland.",
"warnings.error": "Unable to load meteorological or hydrological warnings.", "warnings.error": "Unable to load meteorological or hydrological warnings.",

View File

@@ -1,6 +1,14 @@
import { toNumber } from "@/lib/weather-utils"; import { toNumber } from "@/lib/weather-utils";
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current"; import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
interface NormalizedHybridRow {
row: RawImgwHybridWeatherRow;
measuredAt: string;
timestamp: number;
}
function toCelsius(value: unknown) { function toCelsius(value: unknown) {
const temperature = toNumber(value); const temperature = toNumber(value);
if (temperature === null) return null; if (temperature === null) return null;
@@ -19,6 +27,13 @@ function normalizeDate(value: unknown) {
return Number.isNaN(date.getTime()) ? null : date.toISOString(); return Number.isNaN(date.getTime()) ? null : date.toISOString();
} }
function normalizeHybridRow(candidate: RawImgwHybridWeatherRow): NormalizedHybridRow | null {
const measuredAt = normalizeDate(candidate.Date);
if (!measuredAt) return null;
const timestamp = new Date(measuredAt).getTime();
return Number.isNaN(timestamp) ? null : { row: candidate, measuredAt, timestamp };
}
function getWeatherCode(iconCode: unknown) { function getWeatherCode(iconCode: unknown) {
if (typeof iconCode !== "string") return null; if (typeof iconCode !== "string") return null;
const match = iconCode.match(/z(\d{2})/i); const match = iconCode.match(/z(\d{2})/i);
@@ -48,43 +63,58 @@ function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m); return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
} }
function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row));
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
if (!tenMinuteRow) return hourlyRow ?? null;
if (!hourlyRow) return tenMinuteRow;
return hourlyRow.timestamp - tenMinuteRow.timestamp > STALE_TEN_MINUTE_THRESHOLD_MS ? hourlyRow : tenMinuteRow;
}
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row));
if (!precipitationRows.length) return null;
if (!fullRow) return precipitationRows[0];
return precipitationRows.reduce((best, candidate) => (
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
));
}
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const rows = payload.data.Data const rows = payload.data.Data
.filter((candidate): candidate is RawImgwHybridWeatherRow => { .flatMap((candidate): NormalizedHybridRow[] => {
if (!candidate || typeof candidate !== "object") return false; if (!candidate || typeof candidate !== "object") return [];
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null; const row = candidate as RawImgwHybridWeatherRow;
}) if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
.sort((left, right) => String(left.Date).localeCompare(String(right.Date))); const normalizedRow = normalizeHybridRow(row);
const fullRow = rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate)) return normalizedRow ? [normalizedRow] : [];
?? rows.find((candidate) => candidate.Type === "Type_Hour" && isFullWeatherRow(candidate)); });
const precipitationRow = fullRow && hasPrecipitationValue(fullRow) const fullRow = pickFullWeatherRow(rows);
? fullRow const precipitationRow = pickPrecipitationRow(rows, fullRow);
: rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate));
const row = fullRow ?? precipitationRow; const row = fullRow ?? precipitationRow;
if (!row) return null; if (!row) return null;
const measuredAt = normalizeDate(row.Date); const precipitationSource = precipitationRow?.row ?? row.row;
if (!measuredAt) return null;
const precipitationSource = precipitationRow ?? row;
const rainfall10m = toNumber(precipitationSource.Rain10m); const rainfall10m = toNumber(precipitationSource.Rain10m);
const snowfall10m = toNumber(precipitationSource.Snow10m); const snowfall10m = toNumber(precipitationSource.Snow10m);
const weatherCode = getWeatherCode(row.Icon10); const weatherCode = getWeatherCode(row.row.Icon10);
return { return {
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only", coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
measuredAt, measuredAt: row.measuredAt,
temperature: toCelsius(row.Temperature), temperature: toCelsius(row.row.Temperature),
feelsLike: toCelsius(row.Chill), feelsLike: toCelsius(row.row.Chill),
windSpeed: toNumber(row.Wind_Speed), windSpeed: toNumber(row.row.Wind_Speed),
windDirection: toNumber(row.Wind_Dir), windDirection: toNumber(row.row.Wind_Dir),
humidity: toNumber(row.Humidity), humidity: toNumber(row.row.Humidity),
pressure: toHectopascals(row.PressureMSL), pressure: toHectopascals(row.row.PressureMSL),
precipitation10m: toNumber(precipitationSource.Precipitation10m), precipitation10m: toNumber(precipitationSource.Precipitation10m),
rainfall10m, rainfall10m,
snowfall10m, snowfall10m,
cloudCover: toNumber(row.Cloud), cloudCover: toNumber(row.row.Cloud),
weatherCode, weatherCode,
condition: getCondition(weatherCode, rainfall10m, snowfall10m), condition: getCondition(weatherCode, rainfall10m, snowfall10m),
}; };

View File

@@ -1,5 +1,6 @@
import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { LocationSearchResult, SelectedLocation } from "@/types/location"; import type { LocationSearchResult, SelectedLocation } from "@/types/location";
import { getCountyTerytForLocation } from "@/lib/warning-regions";
const stationAliases: Record<string, string> = { const stationAliases: Record<string, string> = {
gdansk: "gdanskrebiechowo", gdansk: "gdanskrebiechowo",
@@ -51,7 +52,7 @@ export function locateSynopStations(stations: SynopStation[], positions: MeteoSt
} }
export function findNearestSynopStation( export function findNearestSynopStation(
location: Pick<LocationSearchResult, "name" | "province" | "latitude" | "longitude">, location: Pick<LocationSearchResult, "name" | "province" | "district" | "latitude" | "longitude">,
stations: LocatedSynopStation[], stations: LocatedSynopStation[],
): SelectedLocation | null { ): SelectedLocation | null {
const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => { const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
@@ -62,6 +63,8 @@ export function findNearestSynopStation(
return { return {
name: location.name, name: location.name,
province: location.province, province: location.province,
district: location.district,
countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name),
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
stationId: nearest.station.id, stationId: nearest.station.id,

View File

@@ -17,6 +17,7 @@ interface SavePushSubscriptionOptions {
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
locationName?: string | null; locationName?: string | null;
countyTeryt?: string | null;
} }
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) { 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, latitude: options.latitude ?? null,
longitude: options.longitude ?? null, longitude: options.longitude ?? null,
locationName: options.locationName ?? null, locationName: options.locationName ?? null,
countyTeryt: options.countyTeryt ?? null,
}), }),
}); });
if (!response.ok) throw new Error("Unable to save push subscription."); 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 { HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import { warningMatchesCounty } from "@/lib/warning-regions";
export type WeatherBriefSeverity = "normal" | "attention" | "warning"; export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -20,6 +21,7 @@ interface BuildWeatherBriefInput {
forecast: WeatherForecast; forecast: WeatherForecast;
warnings: WeatherWarning[]; warnings: WeatherWarning[];
province: Province | null; province: Province | null;
countyTeryt?: string | null;
locationName: string; locationName: string;
language: Language; language: Language;
now?: Date; now?: Date;
@@ -112,10 +114,10 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
}, null); }, 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); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return warning.kind === "meteo"
&& (!province || warning.provinces.includes(province)) && (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
&& (validTo === null || validTo > now); && (validTo === null || validTo > now);
} }
@@ -125,12 +127,12 @@ function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0); 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); const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null; if (!hours.length) return null;
const relevantWarnings = warnings const relevantWarnings = warnings
.filter((warning) => isRelevantMeteoWarning(warning, province, now.getTime())) .filter((warning) => isRelevantMeteoWarning(warning, province, countyTeryt, now.getTime()))
.sort(compareWarnings); .sort(compareWarnings);
const topWarning = relevantWarnings[0] ?? null; const topWarning = relevantWarnings[0] ?? null;
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); 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), publishedAt: normalizeDate(raw.opublikowano),
probability: toNumber(raw.prawdopodobienstwo), probability: toNumber(raw.prawdopodobienstwo),
areas, areas,
terytCodes: Array.isArray(raw.teryt) ? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0) : [],
provinces, provinces,
office: raw.biuro?.trim() || null, office: raw.biuro?.trim() || null,
}; };

View File

@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint ." "lint": "eslint .",
"notifications:worker": "node scripts/notification-worker.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",

View File

@@ -0,0 +1,84 @@
const DEFAULT_APP_URL = "http://127.0.0.1:3000";
const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
const LOOP_INTERVAL_MS = 30_000;
const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME);
let lastWarningCheckAt = 0;
let lastMorningBriefDate = "";
let isRunningTick = false;
function normalizeAppUrl(value) {
return value.replace(/\/+$/, "");
}
function readPositiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function normalizeTime(value) {
return /^\d{2}:\d{2}$/.test(value) ? value : DEFAULT_MORNING_BRIEF_TIME;
}
function getWarsawDateParts(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
return {
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
time: `${part("hour")}:${part("minute")}`,
};
}
function isAtOrAfterTime(currentTime, targetTime) {
return currentTime >= targetTime;
}
async function callNotificationEndpoint(path) {
const response = await fetch(`${appUrl}${path}`, {
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
});
const body = await response.text();
if (!response.ok) throw new Error(`${path} returned ${response.status}: ${body}`);
return body;
}
async function tick() {
if (isRunningTick) return;
isRunningTick = true;
try {
const now = Date.now();
const warningIntervalMs = warningIntervalMinutes * 60_000;
if (now - lastWarningCheckAt >= warningIntervalMs) {
lastWarningCheckAt = now;
await callNotificationEndpoint("/api/notifications/check");
}
const warsawTime = getWarsawDateParts();
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) {
await callNotificationEndpoint("/api/notifications/daily-brief");
lastMorningBriefDate = warsawTime.dateKey;
}
} catch (error) {
console.error(error);
} finally {
isRunningTick = false;
}
}
console.log(`wtr. notification worker: ${appUrl}`);
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime} Europe/Warsaw.`);
void tick();
setInterval(tick, LOOP_INTERVAL_MS);

View File

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

View File

@@ -10,11 +10,14 @@ export interface LocationSearchResult {
export interface ReverseLocationResult { export interface ReverseLocationResult {
name: string; name: string;
province: string | null; province: string | null;
district: string | null;
} }
export interface SelectedLocation { export interface SelectedLocation {
name: string; name: string;
province: string | null; province: string | null;
district: string | null;
countyTeryt: string | null;
latitude: number; latitude: number;
longitude: number; longitude: number;
stationId: string; stationId: string;

View File

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