Compare commits
3 Commits
49265e7c51
...
487a480bbd
| Author | SHA1 | Date | |
|---|---|---|---|
| 487a480bbd | |||
| 5eaaff963f | |||
| fd2e108205 |
@@ -2,3 +2,6 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||
NOTIFICATIONS_CRON_SECRET=
|
||||
WTR_APP_URL=http://127.0.0.1:3000
|
||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -25,9 +25,10 @@ npm run dev
|
||||
npm run lint
|
||||
npm run build
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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` 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.
|
||||
- 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.
|
||||
|
||||
17
README.md
17
README.md
@@ -10,7 +10,7 @@ Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazw
|
||||
|
||||
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
||||
|
||||
Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego województwa. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych.
|
||||
Widok ostrzeżeń priorytetyzuje komunikaty dla obszaru wynikającego z miejscowości lub stacji wybranej w pogodzie. Dla ostrzeżeń meteorologicznych, gdy aplikacja rozpozna powiat wybranej miejscowości, filtrowanie odbywa się po kodzie TERYT powiatu; w przeciwnym razie pozostaje fallback wojewódzki. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego obszaru. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -43,6 +43,12 @@ npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
Worker powiadomień uruchamiany obok aplikacji:
|
||||
|
||||
```bash
|
||||
npm run notifications:worker
|
||||
```
|
||||
|
||||
## Źródła danych
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -140,9 +146,12 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||
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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
16
lib/i18n.tsx
16
lib/i18n.tsx
@@ -192,10 +192,10 @@ const translations = {
|
||||
"warnings.section": "Komunikaty IMGW",
|
||||
"warnings.title": "Ostrzeżenia",
|
||||
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
|
||||
"warnings.myProvince": "Moje województwo",
|
||||
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące województwa {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego województwa",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących województwa {province}.",
|
||||
"warnings.myProvince": "Mój obszar",
|
||||
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
|
||||
"warnings.otherRegions": "Pozostałe regiony",
|
||||
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
|
||||
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||
@@ -423,10 +423,10 @@ const translations = {
|
||||
"warnings.section": "IMGW notices",
|
||||
"warnings.title": "Warnings",
|
||||
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
|
||||
"warnings.myProvince": "My province",
|
||||
"warnings.myProvinceDescription": "IMGW notices for the {province} province are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceEmptyTitle": "No warnings for your province",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for the {province} province.",
|
||||
"warnings.myProvince": "My area",
|
||||
"warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceEmptyTitle": "No warnings for your area",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
|
||||
"warnings.otherRegions": "Other regions",
|
||||
"warnings.otherRegionsDescription": "Active IMGW notices for the remaining areas of Poland.",
|
||||
"warnings.error": "Unable to load meteorological or hydrological warnings.",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { toNumber } from "@/lib/weather-utils";
|
||||
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) {
|
||||
const temperature = toNumber(value);
|
||||
if (temperature === null) return null;
|
||||
@@ -19,6 +27,13 @@ function normalizeDate(value: unknown) {
|
||||
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) {
|
||||
if (typeof iconCode !== "string") return null;
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||
|
||||
const rows = payload.data.Data
|
||||
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
|
||||
})
|
||||
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
|
||||
const fullRow = rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate))
|
||||
?? rows.find((candidate) => candidate.Type === "Type_Hour" && isFullWeatherRow(candidate));
|
||||
const precipitationRow = fullRow && hasPrecipitationValue(fullRow)
|
||||
? fullRow
|
||||
: rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate));
|
||||
.flatMap((candidate): NormalizedHybridRow[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as RawImgwHybridWeatherRow;
|
||||
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||
const normalizedRow = normalizeHybridRow(row);
|
||||
return normalizedRow ? [normalizedRow] : [];
|
||||
});
|
||||
const fullRow = pickFullWeatherRow(rows);
|
||||
const precipitationRow = pickPrecipitationRow(rows, fullRow);
|
||||
const row = fullRow ?? precipitationRow;
|
||||
if (!row) return null;
|
||||
|
||||
const measuredAt = normalizeDate(row.Date);
|
||||
if (!measuredAt) return null;
|
||||
const precipitationSource = precipitationRow ?? row;
|
||||
const precipitationSource = precipitationRow?.row ?? row.row;
|
||||
const rainfall10m = toNumber(precipitationSource.Rain10m);
|
||||
const snowfall10m = toNumber(precipitationSource.Snow10m);
|
||||
const weatherCode = getWeatherCode(row.Icon10);
|
||||
const weatherCode = getWeatherCode(row.row.Icon10);
|
||||
|
||||
return {
|
||||
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||
measuredAt,
|
||||
temperature: toCelsius(row.Temperature),
|
||||
feelsLike: toCelsius(row.Chill),
|
||||
windSpeed: toNumber(row.Wind_Speed),
|
||||
windDirection: toNumber(row.Wind_Dir),
|
||||
humidity: toNumber(row.Humidity),
|
||||
pressure: toHectopascals(row.PressureMSL),
|
||||
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||
measuredAt: row.measuredAt,
|
||||
temperature: toCelsius(row.row.Temperature),
|
||||
feelsLike: toCelsius(row.row.Chill),
|
||||
windSpeed: toNumber(row.row.Wind_Speed),
|
||||
windDirection: toNumber(row.row.Wind_Dir),
|
||||
humidity: toNumber(row.row.Humidity),
|
||||
pressure: toHectopascals(row.row.PressureMSL),
|
||||
precipitation10m: toNumber(precipitationSource.Precipitation10m),
|
||||
rainfall10m,
|
||||
snowfall10m,
|
||||
cloudCover: toNumber(row.Cloud),
|
||||
cloudCover: toNumber(row.row.Cloud),
|
||||
weatherCode,
|
||||
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
95
lib/warning-regions.ts
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"notifications:worker": "node scripts/notification-worker.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
|
||||
84
scripts/notification-worker.mjs
Normal file
84
scripts/notification-worker.mjs
Normal 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);
|
||||
@@ -112,6 +112,7 @@ export interface WeatherWarning {
|
||||
publishedAt: string | null;
|
||||
probability: number | null;
|
||||
areas: string[];
|
||||
terytCodes: string[];
|
||||
provinces: Province[];
|
||||
office: string | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WarningPushSubscription {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
locationName: string | null;
|
||||
countyTeryt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user