feat: add tomorrow weather brief notifications

This commit is contained in:
zv
2026-06-12 22:10:22 +02:00
parent 3309e03acb
commit 7c3706c3f6
15 changed files with 414 additions and 15 deletions

View File

@@ -5,3 +5,4 @@ NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00

View File

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

@@ -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`. 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. 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. 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: 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 WTR_APP_URL=http://127.0.0.1:3000
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 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 <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` 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 <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`, a trzeci cron około 18:00 `GET /api/notifications/tomorrow-brief` z tym samym nagłówkiem.
## Wdrożenie na Vercel ## Wdrożenie na Vercel

View File

@@ -27,6 +27,7 @@ export async function POST(request: Request) {
language?: unknown; language?: unknown;
enabled?: unknown; enabled?: unknown;
morningBriefEnabled?: unknown; morningBriefEnabled?: unknown;
tomorrowBriefEnabled?: unknown;
latitude?: unknown; latitude?: unknown;
longitude?: unknown; longitude?: unknown;
locationName?: unknown; locationName?: unknown;
@@ -48,6 +49,7 @@ export async function POST(request: Request) {
language, language,
enabled: body?.enabled !== false, enabled: body?.enabled !== false,
morningBriefEnabled: body?.morningBriefEnabled === true, morningBriefEnabled: body?.morningBriefEnabled === true,
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
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,

View File

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

View File

@@ -12,7 +12,7 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { getProvinceForSelection } from "@/lib/provinces"; import { getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; 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 }) { export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
@@ -26,6 +26,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
if (!forecast) return null; if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language });
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); }, [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) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />; return <LoadingSkeleton className="h-48" />;
@@ -72,6 +76,20 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
))} ))}
</ul> </ul>
{tomorrowBrief && (
<div className="mt-5 border-t border-border/65 pt-4">
<p className="section-kicker">{t("brief.tomorrowLabel")}</p>
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
<ul className="mt-3 space-y-2">
{tomorrowBrief.body.slice(0, 3).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
{line}
</li>
))}
</ul>
</div>
)}
<p className="mt-4 flex items-center gap-2 text-xs leading-5 text-muted"> <p className="mt-4 flex items-center gap-2 text-xs leading-5 text-muted">
<BellRing className="size-3.5 text-accent" aria-hidden="true" /> <BellRing className="size-3.5 text-accent" aria-hidden="true" />
{t("brief.pushHint")} {t("brief.pushHint")}

View File

@@ -69,10 +69,12 @@ export function SettingsPage() {
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled); const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled); const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled);
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
@@ -119,6 +121,7 @@ export function SettingsPage() {
if (!subscription || cancelled) return; if (!subscription || cancelled) return;
await savePushSubscription(subscription, province, language, { await savePushSubscription(subscription, province, language, {
morningBriefEnabled, morningBriefEnabled,
tomorrowBriefEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
@@ -130,7 +133,7 @@ export function SettingsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]); }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey]);
const notificationStatusLabel = useMemo(() => { const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) { switch (notificationStatus) {
@@ -184,6 +187,7 @@ export function SettingsPage() {
}); });
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled, morningBriefEnabled,
tomorrowBriefEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
@@ -211,6 +215,7 @@ export function SettingsPage() {
} }
setNotificationsEnabled(false); setNotificationsEnabled(false);
setMorningBriefEnabled(false); setMorningBriefEnabled(false);
setTomorrowBriefEnabled(false);
setNotificationMessage(t("settings.notifications.disableSuccess")); setNotificationMessage(t("settings.notifications.disableSuccess"));
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -244,6 +249,27 @@ export function SettingsPage() {
if (!subscription) return; if (!subscription) return;
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled: enabled, morningBriefEnabled: enabled,
tomorrowBriefEnabled,
latitude: notificationLatitude,
longitude: notificationLongitude,
locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt,
});
} catch {
setNotificationMessage(t("settings.notifications.saveError"));
}
}
async function updateTomorrowBriefPreference(enabled: boolean) {
setTomorrowBriefEnabled(enabled);
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
try {
const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return;
await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled,
tomorrowBriefEnabled: enabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
@@ -368,6 +394,23 @@ export function SettingsPage() {
</span> </span>
</label> </label>
<label className="mt-3 flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
<input
type="checkbox"
checked={tomorrowBriefEnabled}
disabled={!notificationsEnabled}
onChange={(event) => updateTomorrowBriefPreference(event.target.checked)}
className="mt-1 accent-accent"
/>
<span>
<span className="flex items-center gap-2 font-medium">
<Clock3 className="size-3.5 text-accent" aria-hidden="true" />
{t("settings.notifications.tomorrowBrief")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.tomorrowBriefDescription")}</span>
</span>
</label>
<div className="mt-4 flex flex-col gap-2 sm:flex-row"> <div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}> <Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
<Bell className="size-4" /> <Bell className="size-4" />

View File

@@ -38,6 +38,8 @@ const translations = {
"settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", "settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.",
"settings.notifications.morningBrief": "Brief poranny o 7:00", "settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.", "settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
"settings.notifications.tomorrowBriefDescription": "Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
"settings.notifications.enableDevice": "Włącz na tym urządzeniu", "settings.notifications.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu", "settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.saving": "Zapisuję…", "settings.notifications.saving": "Zapisuję…",
@@ -66,7 +68,8 @@ const translations = {
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.", "dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"brief.label": "Brief dnia", "brief.label": "Brief dnia",
"brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.", "brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
"brief.pushHint": "Krótka wersja może być wysłana jako poranne powiadomienie po włączeniu jej w ustawieniach.", "brief.tomorrowLabel": "Jutro",
"brief.pushHint": "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
"brief.error": "Nie udało się przygotować briefu dnia.", "brief.error": "Nie udało się przygotować briefu dnia.",
"brief.emptyTitle": "Brak briefu", "brief.emptyTitle": "Brak briefu",
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.", "brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
@@ -269,6 +272,8 @@ const translations = {
"settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.", "settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.",
"settings.notifications.morningBrief": "Morning brief at 7:00", "settings.notifications.morningBrief": "Morning brief at 7:00",
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.", "settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.",
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
"settings.notifications.tomorrowBriefDescription": "Sends a short evening forecast for the next day, including storm and rain signals from the model.",
"settings.notifications.enableDevice": "Enable on this device", "settings.notifications.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device", "settings.notifications.disable": "Disable on this device",
"settings.notifications.saving": "Saving…", "settings.notifications.saving": "Saving…",
@@ -297,7 +302,8 @@ const translations = {
"dashboard.error": "Unable to load the IMGW synoptic station list.", "dashboard.error": "Unable to load the IMGW synoptic station list.",
"brief.label": "Daily brief", "brief.label": "Daily brief",
"brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.", "brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.",
"brief.pushHint": "A short version can be sent as a morning notification after enabling it in settings.", "brief.tomorrowLabel": "Tomorrow",
"brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.",
"brief.error": "Unable to prepare the daily brief.", "brief.error": "Unable to prepare the daily brief.",
"brief.emptyTitle": "Brief unavailable", "brief.emptyTitle": "Brief unavailable",
"brief.emptyDescription": "The forecast does not contain enough data for the next day.", "brief.emptyDescription": "The forecast does not contain enough data for the next day.",

View File

@@ -14,6 +14,7 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
interface SavePushSubscriptionOptions { interface SavePushSubscriptionOptions {
morningBriefEnabled?: boolean; morningBriefEnabled?: boolean;
tomorrowBriefEnabled?: boolean;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
locationName?: string | null; locationName?: string | null;
@@ -30,6 +31,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
language, language,
enabled: true, enabled: true,
morningBriefEnabled: options.morningBriefEnabled === true, morningBriefEnabled: options.morningBriefEnabled === true,
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
latitude: options.latitude ?? null, latitude: options.latitude ?? null,
longitude: options.longitude ?? null, longitude: options.longitude ?? null,
locationName: options.locationName ?? null, locationName: options.locationName ?? null,

View File

@@ -93,3 +93,13 @@ export async function sendMorningBriefNotification(preference: WarningPushSubscr
}); });
await webPush.sendNotification(preference.subscription as PushSubscription, payload); await webPush.sendNotification(preference.subscription as PushSubscription, payload);
} }
export async function sendTomorrowBriefNotification(preference: WarningPushSubscription, brief: WeatherBrief) {
ensureWebPushConfigured();
const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: jutro" : "wtr.: tomorrow",
body: brief.pushBody,
url: "/",
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
}

View File

@@ -4,6 +4,7 @@ interface PushStore {
subscriptions: Map<string, WarningPushSubscription>; subscriptions: Map<string, WarningPushSubscription>;
sentWarningIds: Map<string, Set<string>>; sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>; sentMorningBriefDates: Map<string, Set<string>>;
sentTomorrowBriefDates: Map<string, Set<string>>;
} }
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore }; const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore };
@@ -14,6 +15,7 @@ function getStore() {
subscriptions: new Map(), subscriptions: new Map(),
sentWarningIds: new Map(), sentWarningIds: new Map(),
sentMorningBriefDates: new Map(), sentMorningBriefDates: new Map(),
sentTomorrowBriefDates: new Map(),
}; };
} }
return globalStore.__wtrPushStore; return globalStore.__wtrPushStore;
@@ -28,6 +30,7 @@ export function removePushSubscription(endpoint: string) {
store.subscriptions.delete(endpoint); store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint); store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint); store.sentMorningBriefDates.delete(endpoint);
store.sentTomorrowBriefDates.delete(endpoint);
} }
export function getPushSubscriptions() { export function getPushSubscriptions() {
@@ -67,3 +70,14 @@ export function markMorningBriefSent(endpoint: string, dateKey: string) {
sentDates.add(dateKey); sentDates.add(dateKey);
store.sentMorningBriefDates.set(endpoint, sentDates); store.sentMorningBriefDates.set(endpoint, sentDates);
} }
export function hasSentTomorrowBrief(endpoint: string, dateKey: string) {
return getStore().sentTomorrowBriefDates.get(endpoint)?.has(dateKey) ?? false;
}
export function markTomorrowBriefSent(endpoint: string, dateKey: string) {
const store = getStore();
const sentDates = store.sentTomorrowBriefDates.get(endpoint) ?? new Set<string>();
sentDates.add(dateKey);
store.sentTomorrowBriefDates.set(endpoint, sentDates);
}

View File

@@ -13,6 +13,7 @@ interface WeatherStore {
selectedLocation: SelectedLocation | null; selectedLocation: SelectedLocation | null;
warningNotificationsEnabled: boolean; warningNotificationsEnabled: boolean;
morningBriefNotificationsEnabled: boolean; morningBriefNotificationsEnabled: boolean;
tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null; warningNotificationProvince: Province | null;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
@@ -20,6 +21,7 @@ interface WeatherStore {
selectLocation: (location: SelectedLocation) => void; selectLocation: (location: SelectedLocation) => void;
setWarningNotificationsEnabled: (enabled: boolean) => void; setWarningNotificationsEnabled: (enabled: boolean) => void;
setMorningBriefNotificationsEnabled: (enabled: boolean) => void; setMorningBriefNotificationsEnabled: (enabled: boolean) => void;
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void; setWarningNotificationProvince: (province: Province | null) => void;
} }
@@ -32,6 +34,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectedLocation: null, selectedLocation: null,
warningNotificationsEnabled: false, warningNotificationsEnabled: false,
morningBriefNotificationsEnabled: false, morningBriefNotificationsEnabled: false,
tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected", warningNotificationProvinceMode: "selected",
warningNotificationProvince: null, warningNotificationProvince: null,
toggleFavorite: (id) => toggleFavorite: (id) =>
@@ -44,6 +47,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }), selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }), setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }),
setMorningBriefNotificationsEnabled: (enabled) => set({ morningBriefNotificationsEnabled: enabled }), setMorningBriefNotificationsEnabled: (enabled) => set({ morningBriefNotificationsEnabled: enabled }),
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
}), }),

View File

@@ -41,6 +41,10 @@ function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`; return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
} }
function formatWindKmh(value: number, language: Language) {
return `${formatNumber(value * 3.6, language)} km/h`;
}
function formatRainfall(value: number, language: Language) { function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`; return `${formatNumber(value, language, 1)} mm`;
} }
@@ -68,6 +72,27 @@ function getWarsawHour(date: Date) {
return `${part("year")}-${part("month")}-${part("day")}T${part("hour")}:00`; return `${part("year")}-${part("month")}-${part("day")}T${part("hour")}:00`;
} }
function getWarsawDateKey(date: 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")}`;
}
function parseForecastHour(time: string) { function parseForecastHour(time: string) {
const match = time.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); const match = time.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!match) return null; if (!match) return null;
@@ -86,6 +111,10 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
return upcoming.slice(0, limit); return upcoming.slice(0, limit);
} }
function getForecastHoursForDate(hours: HourlyForecast[], dateKey: string) {
return hours.filter((hour) => hour.time.startsWith(`${dateKey}T`));
}
function getAvailableNumbers(values: Array<number | null>) { function getAvailableNumbers(values: Array<number | null>) {
return values.filter((value): value is number => value !== null && Number.isFinite(value)); return values.filter((value): value is number => value !== null && Number.isFinite(value));
} }
@@ -121,12 +150,63 @@ function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | nu
&& (validTo === null || validTo > now); && (validTo === null || validTo > now);
} }
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
const validFromKey = warning.validFrom ? getWarsawDateKey(new Date(warning.validFrom)) : null;
const validToKey = warning.validTo ? getWarsawDateKey(new Date(warning.validTo)) : null;
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
}
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) {
return warning.kind === "meteo"
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
&& warningOverlapsWarsawDate(warning, dateKey);
}
function compareWarnings(a: WeatherWarning, b: WeatherWarning) { function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
const levelDifference = (b.level ?? 0) - (a.level ?? 0); const levelDifference = (b.level ?? 0) - (a.level ?? 0);
if (levelDifference) return levelDifference; if (levelDifference) return levelDifference;
return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0); return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0);
} }
function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => boolean) {
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
}
function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | null, maximumProbability: number | null, language: Language) {
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
const hasDrizzle = hasWeatherCode(hours, (code) => code >= 51 && code <= 57);
const hasFog = hasWeatherCode(hours, (code) => code === 45 || code === 48);
const hasClouds = hasWeatherCode(hours, (code) => code === 3);
const hasPartialClouds = hasWeatherCode(hours, (code) => code === 1 || code === 2);
const rainfall = rainfallTotal ?? 0;
const probability = maximumProbability ?? 0;
if (hasThunderstorm) {
return language === "pl" ? "burze" : "thunderstorms";
}
if (hasSnow) {
return language === "pl" ? "opady śniegu" : "snow";
}
if (rainfall >= 5 || hasRain) {
return language === "pl" ? "opady deszczu" : "rain";
}
if (rainfall >= 0.5 || probability >= 35 || hasDrizzle) {
return language === "pl" ? "możliwy deszcz" : "possible rain";
}
if (hasFog) {
return language === "pl" ? "mgła" : "fog";
}
if (hasClouds) {
return language === "pl" ? "pochmurno" : "cloudy";
}
if (hasPartialClouds) {
return language === "pl" ? "częściowe zachmurzenie" : "partly cloudy";
}
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
}
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, 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;
@@ -219,3 +299,109 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
validUntil: hours.at(-1)?.time ?? null, validUntil: hours.at(-1)?.time ?? null,
}; };
} }
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null;
const relevantWarnings = warnings
.filter((warning) => isRelevantMeteoWarningForDate(warning, province, countyTeryt, targetDateKey))
.sort(compareWarnings);
const topWarning = relevantWarnings[0] ?? null;
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal = hasThunderstorm
|| (rainfallTotal ?? 0) >= 0.5
|| (maximumProbability ?? 0) >= 35
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasWindSignal = (maximumWind ?? 0) >= 8;
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}`
: null;
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning
? language === "pl"
? `Jutro: IMGW ${topWarning.title || "ostrzeżenie meteorologiczne"}`
: `Tomorrow: IMGW ${topWarning.title || "weather warning"}`
: hasThunderstorm
? language === "pl"
? "Jutro możliwe burze"
: "Thunderstorms are possible tomorrow"
: hasRainSignal
? language === "pl"
? "Jutro opady w prognozie"
: "Rain is in tomorrow's forecast"
: hasWindSignal
? language === "pl"
? "Jutro wyraźniejszy wiatr"
: "Wind stands out tomorrow"
: language === "pl"
? "Jutro bez istotnych opadów"
: "No significant precipitation tomorrow";
const body: string[] = [];
if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
: "";
body.push(language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
}
if (temperatureRange) {
body.push(language === "pl"
? `Temperatura jutro: ${temperatureRange}.`
: `Temperature tomorrow: ${temperatureRange}.`);
}
body.push(language === "pl"
? `Sygnał modelu: ${condition}.`
: `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: "";
body.push(language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
} else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
}
if (maximumWind !== null) {
body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.`
: `Wind up to ${formatWindKmh(maximumWind, language)}.`);
}
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
? formatRainfall(rainfallTotal, language)
: null;
const pushParts = [
`${locationName}:`,
temperatureRange,
condition,
rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(maximumWind, language)}`) : null,
].filter(Boolean);
const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
: "";
return {
headline,
body,
pushBody: `${warningPrefix}${pushParts.join(", ")}.`,
severity,
generatedAt: now.toISOString(),
validUntil: hours.at(-1)?.time ?? null,
};
}

View File

@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
const DEFAULT_APP_URL = "http://127.0.0.1:3000"; const DEFAULT_APP_URL = "http://127.0.0.1:3000";
const DEFAULT_WARNING_INTERVAL_MINUTES = 5; const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
const DEFAULT_MORNING_BRIEF_TIME = "07:00"; const DEFAULT_MORNING_BRIEF_TIME = "07:00";
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
const LOOP_INTERVAL_MS = 30_000; const LOOP_INTERVAL_MS = 30_000;
const initialEnvKeys = new Set(Object.keys(process.env)); const initialEnvKeys = new Set(Object.keys(process.env));
@@ -12,10 +13,12 @@ loadEnvFile(".env.local", true);
const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL); 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 cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES); 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); const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const tomorrowBriefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
let lastWarningCheckAt = 0; let lastWarningCheckAt = 0;
let lastMorningBriefDate = ""; let lastMorningBriefDate = "";
let lastTomorrowBriefDate = "";
let isRunningTick = false; let isRunningTick = false;
function loadEnvFile(path, overrideLoadedValues) { function loadEnvFile(path, overrideLoadedValues) {
@@ -43,8 +46,8 @@ function readPositiveNumber(value, fallback) {
return Number.isFinite(number) && number > 0 ? number : fallback; return Number.isFinite(number) && number > 0 ? number : fallback;
} }
function normalizeTime(value) { function normalizeTime(value, fallback) {
return /^\d{2}:\d{2}$/.test(value) ? value : DEFAULT_MORNING_BRIEF_TIME; return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
} }
function getWarsawDateParts(date = new Date()) { function getWarsawDateParts(date = new Date()) {
@@ -113,8 +116,12 @@ async function tick() {
const warsawTime = getWarsawDateParts(); const warsawTime = getWarsawDateParts();
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) { if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) {
const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief"); const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief");
if (!sentBrief) return; if (sentBrief) lastMorningBriefDate = warsawTime.dateKey;
lastMorningBriefDate = warsawTime.dateKey; }
if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) {
const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey;
} }
} catch (error) { } catch (error) {
console.error(formatWorkerError(error)); console.error(formatWorkerError(error));
@@ -124,6 +131,6 @@ async function tick() {
} }
console.log(`wtr. notification worker: ${appUrl}`); console.log(`wtr. notification worker: ${appUrl}`);
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime} Europe/Warsaw.`); console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, tomorrow brief at ${tomorrowBriefTime} Europe/Warsaw.`);
void tick(); void tick();
setInterval(tick, LOOP_INTERVAL_MS); setInterval(tick, LOOP_INTERVAL_MS);

View File

@@ -19,6 +19,7 @@ export interface WarningPushSubscription {
language: Language; language: Language;
enabled: boolean; enabled: boolean;
morningBriefEnabled: boolean; morningBriefEnabled: boolean;
tomorrowBriefEnabled: boolean;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
locationName: string | null; locationName: string | null;