diff --git a/AGENTS.md b/AGENTS.md index 872d929..637aaf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,13 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - 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`. - 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 najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. +- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. - 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. -- Powiadomienia Web Push o ostrzeżeniach meteo 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. +- 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. - 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 5359cac..7045339 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.** -`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami. +`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, regułowy brief dnia, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami. Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone. @@ -71,6 +71,8 @@ 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. + 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. Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące. @@ -129,7 +131,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 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, 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. 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,7 +142,7 @@ WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com NOTIFICATIONS_CRON_SECRET= ``` -Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer ` albo `x-cron-secret`. +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. ## Wdrożenie na Vercel diff --git a/app/api/forecast/route.ts b/app/api/forecast/route.ts index 66d14de..9f39fc5 100644 --- a/app/api/forecast/route.ts +++ b/app/api/forecast/route.ts @@ -1,62 +1,16 @@ import { NextResponse } from "next/server"; -import { mergeForecastSources } from "@/lib/forecast-merge"; -import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast"; - -const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"; -const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi"; -// This browser token is published by the official meteo.imgw.pl frontend. -const IMGW_FORECAST_TOKEN = "p4DXKjsYadfBV21TYrDk"; -const OPEN_METEO_TIMEOUT_MS = 12_000; -const IMGW_TIMEOUT_MS = 5_000; - -function parseCoordinate(value: string | null, min: number, max: number) { - if (!value?.trim()) return null; - const coordinate = Number(value); - return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null; -} - -async function readImgwPayload(response: Response | null) { - if (!response?.ok) return null; - try { - return await response.json() as RawImgwForecastResponse; - } catch { - return null; - } -} +import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90); - const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180); + const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90); + const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180); if (latitude === null || longitude === null) { return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); } - const openMeteoParams = new URLSearchParams({ - latitude: String(latitude), - longitude: String(longitude), - hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m", - daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset", - timezone: "Europe/Warsaw", - forecast_days: "7", - wind_speed_unit: "ms", - }); - const imgwParams = new URLSearchParams({ - token: IMGW_FORECAST_TOKEN, - lat: String(latitude), - lon: String(longitude), - m: "alaro", - }); - try { - const [openMeteoResponse, imgwResponse] = await Promise.all([ - fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }), - fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null), - ]); - if (!openMeteoResponse.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 }); - const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast; - const imgwPayload = await readImgwPayload(imgwResponse); - return NextResponse.json(mergeForecastSources(openMeteoPayload, imgwPayload), { + return NextResponse.json(await fetchServerForecast(latitude, longitude), { headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" }, }); } catch { diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts new file mode 100644 index 0000000..03b5034 --- /dev/null +++ b/app/api/notifications/daily-brief/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { fetchServerForecast } from "@/lib/server-forecast"; +import { fetchMeteoWarnings } from "@/lib/server-warnings"; +import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store"; +import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service"; +import { buildWeatherBrief } 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()) { + 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 ?? ""; + return `${part("year")}-${part("month")}-${part("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 dateKey = getWarsawDateKey(now); + const subscriptions = getPushSubscriptions().filter((subscription) => ( + subscription.enabled + && subscription.morningBriefEnabled + && Number.isFinite(subscription.latitude) + && Number.isFinite(subscription.longitude) + )); + const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)); + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const subscription of subscriptions) { + if (hasSentMorningBrief(subscription.endpoint, dateKey)) { + skipped += 1; + continue; + } + + try { + const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); + const brief = buildWeatherBrief({ + forecast, + warnings, + province: subscription.province, + locationName: subscription.locationName ?? "wtr.", + language: subscription.language, + now, + }); + if (!brief) { + skipped += 1; + continue; + } + await sendMorningBriefNotification(subscription, brief); + markMorningBriefSent(subscription.endpoint, dateKey); + 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: dateKey, + subscriptions: subscriptions.length, + sent, + skipped, + failed, + }); +} diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index 632da64..95c1d36 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -26,6 +26,10 @@ export async function POST(request: Request) { province?: unknown; language?: unknown; enabled?: unknown; + morningBriefEnabled?: unknown; + latitude?: unknown; + longitude?: unknown; + locationName?: unknown; } | null; const subscription = body?.subscription; const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); @@ -42,6 +46,10 @@ export async function POST(request: Request) { province, language, enabled: body?.enabled !== false, + morningBriefEnabled: body?.morningBriefEnabled === 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, createdAt: now, updatedAt: now, }); diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index 9981538..a5956ce 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -15,6 +15,7 @@ import { useCurrentWeather } from "@/hooks/use-current-weather"; import { ForecastPanel } from "@/components/forecast/forecast-panel"; import { locateSynopStations } from "@/lib/location-utils"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; +import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card"; export function DashboardPage() { const { t } = useI18n(); @@ -43,6 +44,7 @@ export function DashboardPage() { + diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx new file mode 100644 index 0000000..c55f6d3 --- /dev/null +++ b/components/dashboard/weather-brief-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useMemo } from "react"; +import { AlertTriangle, BellRing, CloudSun, RefreshCw } from "lucide-react"; +import { EmptyState } from "@/components/states/empty-state"; +import { LoadingSkeleton } from "@/components/states/loading-skeleton"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useForecast } from "@/hooks/use-forecast"; +import { useWarnings } from "@/hooks/use-warnings"; +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"; + +export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { + const { language, t } = useI18n(); + const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); + const { data: warnings = [] } = useWarnings(); + const selectedStationId = useWeatherStore((state) => state.selectedStationId); + const selectedLocation = useWeatherStore((state) => state.selectedLocation); + const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + + const brief = useMemo(() => { + if (!forecast) return null; + return buildWeatherBrief({ forecast, warnings, province, locationName, language }); + }, [forecast, language, locationName, province, warnings]); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { + return ; + } + + if (isError || !forecast) { + return ( + +

{t("brief.error")}

+ +
+ ); + } + + if (!brief) { + return ; + } + + const isWarning = brief.severity === "warning"; + + return ( + +
+
+ {isWarning ?
+
+

+ {t("brief.label")} +

+

{brief.headline}

+

{t("brief.description", { location: locationName })}

+
+
+ +
    + {brief.body.slice(0, 4).map((line) => ( +
  • + {line} +
  • + ))} +
+ +

+

+
+ ); +} diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 1a7407d..3a14f49 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -1,13 +1,16 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Bell, BellRing, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react"; +import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { LanguageToggle } from "@/components/ui/language-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle"; +import { useMeteoStationPositions } from "@/hooks/use-meteo-stations"; +import { useWeatherStations } from "@/hooks/use-weather-stations"; import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; +import { locateSynopStations } from "@/lib/location-utils"; import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; @@ -60,15 +63,29 @@ export function SettingsPage() { const [isSavingSubscription, setIsSavingSubscription] = useState(false); const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); const [notificationMessage, setNotificationMessage] = useState(null); + const { data: stations } = useWeatherStations(); + const { data: positions = [] } = useMeteoStationPositions(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled); + const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); + const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); + const selectedStation = stations?.find((station) => station.id === selectedStationId) + ?? stations?.find((station) => station.id === DEFAULT_STATION_ID) + ?? stations?.[0]; + const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null; + const stationPosition = selectedStation + ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) + : null; + 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 selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); @@ -99,14 +116,19 @@ export function SettingsPage() { const registration = await navigator.serviceWorker?.getRegistration(); const subscription = await registration?.pushManager.getSubscription(); if (!subscription || cancelled) return; - await savePushSubscription(subscription, province, language).catch(() => undefined); + await savePushSubscription(subscription, province, language, { + morningBriefEnabled, + latitude: notificationLatitude, + longitude: notificationLongitude, + locationName: notificationLocationName, + }).catch(() => undefined); } syncSubscriptionProvince(); return () => { cancelled = true; }; - }, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]); + }, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]); const notificationStatusLabel = useMemo(() => { switch (notificationStatus) { @@ -158,7 +180,12 @@ export function SettingsPage() { userVisibleOnly: true, applicationServerKey: decodeBase64UrlKey(vapidPublicKey), }); - await savePushSubscription(subscription, effectiveProvince, language); + await savePushSubscription(subscription, effectiveProvince, language, { + morningBriefEnabled, + latitude: notificationLatitude, + longitude: notificationLongitude, + locationName: notificationLocationName, + }); setNotificationsEnabled(true); setNotificationMessage(t("settings.notifications.saveSuccess")); } catch { @@ -180,6 +207,7 @@ export function SettingsPage() { await subscription.unsubscribe(); } setNotificationsEnabled(false); + setMorningBriefEnabled(false); setNotificationMessage(t("settings.notifications.disableSuccess")); } catch { setNotificationMessage(t("settings.notifications.saveError")); @@ -204,6 +232,24 @@ export function SettingsPage() { } } + async function updateMorningBriefPreference(enabled: boolean) { + setMorningBriefEnabled(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: enabled, + latitude: notificationLatitude, + longitude: notificationLongitude, + locationName: notificationLocationName, + }); + } catch { + setNotificationMessage(t("settings.notifications.saveError")); + } + } + return (
@@ -301,6 +347,23 @@ export function SettingsPage() { {notificationMessage &&

{notificationMessage}

}
+ +