diff --git a/.env.example b/.env.example index 1585a33..dfa7f8b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 637aaf3..f440832 100644 --- a/AGENTS.md +++ b/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 @@ -46,7 +47,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - 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. - 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. diff --git a/README.md b/README.md index 7045339..746fe88 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 ` 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 ` 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 diff --git a/package.json b/package.json index 3dc0d67..cb49fd5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/notification-worker.mjs b/scripts/notification-worker.mjs new file mode 100644 index 0000000..0a40a10 --- /dev/null +++ b/scripts/notification-worker.mjs @@ -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);