From 7c3706c3f6c4be0c4258c2ecfd4466cb240fae4e Mon Sep 17 00:00:00 2001 From: zv Date: Fri, 12 Jun 2026 22:10:22 +0200 Subject: [PATCH] feat: add tomorrow weather brief notifications --- .env.example | 1 + AGENTS.md | 4 +- README.md | 7 +- app/api/notifications/subscriptions/route.ts | 2 + app/api/notifications/tomorrow-brief/route.ts | 104 ++++++++++ components/dashboard/weather-brief-card.tsx | 20 +- components/settings/settings-page.tsx | 45 ++++- lib/i18n.tsx | 10 +- lib/notification-api.ts | 2 + lib/push-service.ts | 10 + lib/push-store.ts | 14 ++ lib/store.ts | 4 + lib/weather-brief.ts | 186 ++++++++++++++++++ scripts/notification-worker.mjs | 19 +- types/notifications.ts | 1 + 15 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 app/api/notifications/tomorrow-brief/route.ts diff --git a/.env.example b/.env.example index dfa7f8b..1037f43 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ NOTIFICATIONS_CRON_SECRET= WTR_APP_URL=http://127.0.0.1:3000 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 +NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 diff --git a/AGENTS.md b/AGENTS.md index 736f132..9f6c743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,8 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera. - Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. -- Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. -- Powiadomienia Web Push o ostrzeżeniach meteo i porannym briefie konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. +- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW. +- Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro 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 d2cd6b1..27dbadd 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjaln 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`. -Brief dnia jest deterministycznym podsumowaniem danych, a nie wywołaniem modelu AI. Moduł `lib/weather-brief.ts` analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i nadchodzące ostrzeżenia meteo IMGW dla województwa użytkownika. Na tej podstawie tworzy dłuższy opis na dashboard oraz krótszą wersję do powiadomienia. Dzięki temu funkcja nie wymaga klucza OpenAI API i działa przewidywalnie na małym serwerze, np. Raspberry Pi. +Brief dnia jest deterministycznym podsumowaniem danych, a nie wywołaniem modelu AI. Moduł `lib/weather-brief.ts` analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i nadchodzące ostrzeżenia meteo IMGW dla obszaru użytkownika. Ten sam moduł przygotowuje osobny brief na jutro z pełnego jutrzejszego dnia prognozy, wykrywając m.in. burze po kodach pogodowych modelu także wtedy, gdy IMGW nie opublikowało jeszcze oficjalnego ostrzeżenia. Na tej podstawie tworzy dłuższy opis na dashboard oraz krótsze wersje do powiadomień. Dzięki temu funkcja nie wymaga klucza OpenAI API i działa przewidywalnie na małym serwerze, np. Raspberry Pi. Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu. @@ -142,7 +142,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. 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. +Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia, poranny brief, wieczorny brief na jutro 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ą, `/api/notifications/tomorrow-brief` wysyła wieczorem prognozę na kolejny dzień, 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: @@ -154,9 +154,10 @@ NOTIFICATIONS_CRON_SECRET= WTR_APP_URL=http://127.0.0.1:3000 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 +NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 ``` -Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Worker `npm run notifications:worker` wczytuje `.env` i `.env.local`, domyślnie odpytuje `http://127.0.0.1:3000`, więc `npm run start` musi działać równolegle na tym samym hoście albo `WTR_APP_URL` musi wskazywać właściwy adres aplikacji. Worker 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. +Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Worker `npm run notifications:worker` wczytuje `.env` i `.env.local`, domyślnie odpytuje `http://127.0.0.1:3000`, więc `npm run start` musi działać równolegle na tym samym hoście albo `WTR_APP_URL` musi wskazywać właściwy adres aplikacji. Worker sprawdza ostrzeżenia co 5 minut, wysyła poranny brief po 07:00 i wieczorny brief na jutro po 18: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`, a trzeci cron około 18:00 `GET /api/notifications/tomorrow-brief` z tym samym nagłówkiem. ## Wdrożenie na Vercel diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index abe9ede..952aeb3 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -27,6 +27,7 @@ export async function POST(request: Request) { language?: unknown; enabled?: unknown; morningBriefEnabled?: unknown; + tomorrowBriefEnabled?: unknown; latitude?: unknown; longitude?: unknown; locationName?: unknown; @@ -48,6 +49,7 @@ export async function POST(request: Request) { language, enabled: body?.enabled !== false, morningBriefEnabled: body?.morningBriefEnabled === true, + tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true, 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, diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts new file mode 100644 index 0000000..655ee6e --- /dev/null +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { fetchServerForecast } from "@/lib/server-forecast"; +import { fetchMeteoWarnings } from "@/lib/server-warnings"; +import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store"; +import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service"; +import { buildTomorrowWeatherBrief } from "@/lib/weather-brief"; + +export const runtime = "nodejs"; + +function isCronAuthorized(request: Request) { + const secret = process.env.NOTIFICATIONS_CRON_SECRET; + if (!secret) return process.env.NODE_ENV !== "production"; + const authHeader = request.headers.get("authorization"); + const secretHeader = request.headers.get("x-cron-secret"); + return authHeader === `Bearer ${secret}` || secretHeader === secret; +} + +function getWarsawDateKey(date = new Date(), dayOffset = 0) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Warsaw", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; + if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`; + + const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12)); + const shiftedParts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Warsaw", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(shiftedDate); + const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? ""; + return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`; +} + +export async function GET(request: Request) { + if (!isCronAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + if (!isWebPushConfigured()) { + return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); + } + + const now = new Date(); + const targetDateKey = getWarsawDateKey(now, 1); + const subscriptions = getPushSubscriptions().filter((subscription) => ( + subscription.enabled + && subscription.tomorrowBriefEnabled + && Number.isFinite(subscription.latitude) + && Number.isFinite(subscription.longitude) + )); + let warningsUnavailable = false; + const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { + warningsUnavailable = true; + return []; + }); + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const subscription of subscriptions) { + if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) { + skipped += 1; + continue; + } + + try { + const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); + const brief = buildTomorrowWeatherBrief({ + forecast, + warnings, + province: subscription.province, + countyTeryt: subscription.countyTeryt, + locationName: subscription.locationName ?? "wtr.", + language: subscription.language, + now, + }); + if (!brief) { + skipped += 1; + continue; + } + await sendTomorrowBriefNotification(subscription, brief); + markTomorrowBriefSent(subscription.endpoint, targetDateKey); + sent += 1; + } catch (error) { + failed += 1; + const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; + if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); + } + } + + return NextResponse.json({ + ok: true, + date: targetDateKey, + subscriptions: subscriptions.length, + warningsUnavailable, + sent, + skipped, + failed, + }); +} diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index f568c23..8989283 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -12,7 +12,7 @@ import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; import { getProvinceForSelection } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; -import { buildWeatherBrief } from "@/lib/weather-brief"; +import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief"; export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { const { language, t } = useI18n(); @@ -26,6 +26,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu if (!forecast) return null; return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); + const tomorrowBrief = useMemo(() => { + if (!forecast) return null; + return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { return ; @@ -72,6 +76,20 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu ))} + {tomorrowBrief && ( +
+

{t("brief.tomorrowLabel")}

+

{tomorrowBrief.headline}

+
    + {tomorrowBrief.body.slice(0, 3).map((line) => ( +
  • + {line} +
  • + ))} +
+
+ )} +