diff --git a/.env.example b/.env.example index 379914f..753941d 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,6 @@ NOTIFICATIONS_CRON_SECRET= WTR_APP_URL=http://127.0.0.1:3000 WTR_DATABASE_PATH=./data/wtr.sqlite NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 +NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 diff --git a/AGENTS.md b/AGENTS.md index c6a80c0..160d8cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Projekt -`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. +`wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową. Dla Polski pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Poza Polską pokazuje modelowe bieżące warunki i prognozę Open-Meteo bez oficjalnych ostrzeżeń IMGW. Open-Meteo Geocoding służy do globalnego wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React, `web-push` i SQLite przez `better-sqlite3`. PWA korzysta z manifestu oraz własnego service workera. @@ -38,19 +38,20 @@ Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck` - Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`. - Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji. - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. -- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. +- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń tylko dla Polski. Poza Polską używaj Open-Meteo jako modelowego źródła bieżących warunków i prognozy. Nie generuj fikcyjnych danych, nie przedstawiaj prognozy jako pomiaru IMGW i nie przedstawiaj modelowych sygnałów jako oficjalnych alertów. +- Lokalizacja ma region `PL` albo `GLOBAL`. Preferuj capabilities i metadane źródła z `types/weather-region.ts` zamiast rozsiewania lokalnych warunków po kraju w UI. - 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 pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`. - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. -- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. +- Route handler prognozy pobiera pełne 7 dni Open-Meteo, a dla `PL` dodatkowo 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. Dla `GLOBAL` nie odpytuj ALARO i używaj Open-Meteo z lokalną strefą czasu. 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` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. - 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 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. +- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i, dla `PL`, 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. - Preferencje jednostek temperatury i wiatru są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI. -- 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`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`. +- 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. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`. - 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 30e36d6..dc61ef9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # wtr. -`wtr.` to mobilna PWA pogodowa dla Polski oparta o publiczne dane IMGW oraz jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. +`wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową przez Open-Meteo. -Aplikacja pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, prognozę godzinową i 7-dniową, ostrzeżenia meteorologiczne i hydrologiczne, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI. +Aplikacja pokazuje bieżącą analizę IMGW Hybrid dla Polski, modelowe warunki bieżące Open-Meteo poza Polską, prognozę godzinową i 7-dniową, ostrzeżenia IMGW dla Polski, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI. ## Najważniejsze funkcje -- Bieżące warunki z lokalnej analizy IMGW Hybrid, z opisanym fallbackiem do stacji synoptycznej. -- Prognoza modelowa 7 dni: IMGW ALARO dla dostępnych godzin oraz Open-Meteo jako uzupełnienie i fallback. -- Wyszukiwanie miejscowości w Polsce oraz opcjonalny wybór lokalizacji GPS. -- Ostrzeżenia IMGW z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza. +- Bieżące warunki: IMGW Hybrid w Polsce, Open-Meteo jako modelowe warunki bieżące poza Polską. +- Prognoza modelowa 7 dni: IMGW ALARO dla Polski oraz Open-Meteo jako globalny provider i fallback. +- Globalne wyszukiwanie miejscowości oraz opcjonalny wybór lokalizacji GPS. +- Ostrzeżenia IMGW dla Polski z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza. - Powiadomienia Web Push o nowych ostrzeżeniach, porannym briefie dnia i wieczornym briefie na jutro. - Widoki dashboardu, prognozy szczegółowej dnia, ostrzeżeń, hydro, stacji i ustawień. - PWA z manifestem, własnym service workerem i podstawowym fallbackiem offline. @@ -53,6 +53,7 @@ NOTIFICATIONS_CRON_SECRET= WTR_APP_URL=http://127.0.0.1:3000 WTR_DATABASE_PATH=./data/wtr.sqlite NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 +NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 ``` @@ -111,6 +112,7 @@ docs/ szczegółowa dokumentacja techniczna ## Status i Ograniczenia - `lib/push-store.ts` przechowuje subskrypcje Web Push i historię wysyłek w lokalnym SQLite. Na self-hostingu zadbaj o trwały katalog dla `WTR_DATABASE_PATH` i backup pliku bazy. +- Oficjalne ostrzeżenia pogodowe i dane hydrologiczne są obsługiwane tylko dla Polski przez IMGW. Poza Polską aplikacja pokazuje prognozę modelową Open-Meteo i briefy bez oficjalnych alertów. - Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`. - Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń. -- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary i prognozy są w UI rozdzielane. +- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary, modelowe warunki bieżące i prognozy są w UI rozdzielane. diff --git a/app/api/current-weather/route.ts b/app/api/current-weather/route.ts new file mode 100644 index 0000000..fc51643 --- /dev/null +++ b/app/api/current-weather/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { fetchServerCurrentWeather } from "@/lib/server-current-weather"; +import type { WeatherRegion } from "@/types/weather-region"; + +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; +} + +function parseRegion(value: string | null): WeatherRegion { + return value === "GLOBAL" ? "GLOBAL" : "PL"; +} + +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 region = parseRegion(searchParams.get("region")); + if (latitude === null || longitude === null) { + return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); + } + + try { + return NextResponse.json(await fetchServerCurrentWeather(latitude, longitude, region), { + headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" }, + }); + } catch { + return NextResponse.json({ error: region === "PL" ? "IMGW Hybrid service is unavailable." : "Open-Meteo current conditions are unavailable." }, { status: 502 }); + } +} diff --git a/app/api/forecast/route.ts b/app/api/forecast/route.ts index 9f39fc5..086c995 100644 --- a/app/api/forecast/route.ts +++ b/app/api/forecast/route.ts @@ -1,16 +1,22 @@ import { NextResponse } from "next/server"; import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast"; +import type { WeatherRegion } from "@/types/weather-region"; + +function parseRegion(value: string | null): WeatherRegion { + return value === "GLOBAL" ? "GLOBAL" : "PL"; +} export async function GET(request: Request) { const { searchParams } = new URL(request.url); const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90); const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180); + const region = parseRegion(searchParams.get("region")); if (latitude === null || longitude === null) { return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); } try { - return NextResponse.json(await fetchServerForecast(latitude, longitude), { + return NextResponse.json(await fetchServerForecast(latitude, longitude, region), { headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" }, }); } catch { diff --git a/app/api/locations/reverse/route.ts b/app/api/locations/reverse/route.ts index 4a43dfd..bbd5793 100644 --- a/app/api/locations/reverse/route.ts +++ b/app/api/locations/reverse/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getWeatherRegionForCountryCode } from "@/types/weather-region"; const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse"; const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)"; @@ -13,6 +14,8 @@ interface RawReverseLocation { municipality?: string; county?: string; state?: string; + country?: string; + country_code?: string; }; } @@ -54,10 +57,17 @@ export async function GET(request: Request) { ?? data.name ?? data.display_name?.split(",")[0]?.trim(); if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 }); + const countryCode = data.address?.country_code?.toUpperCase() ?? null; return NextResponse.json({ name, province: data.address?.state ?? null, district: data.address?.county ?? null, + country: data.address?.country ?? null, + countryCode, + admin1: data.address?.state ?? null, + admin2: data.address?.county ?? null, + timezone: null, + region: getWeatherRegionForCountryCode(countryCode), }, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, }); diff --git a/app/api/locations/search/route.ts b/app/api/locations/search/route.ts index 0ab0aac..a9d1f1a 100644 --- a/app/api/locations/search/route.ts +++ b/app/api/locations/search/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getWeatherRegionForCountryCode } from "@/types/weather-region"; const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; @@ -7,8 +8,11 @@ interface RawLocation { name?: string; latitude?: number; longitude?: number; + country?: string; + country_code?: string; admin1?: string; admin2?: string; + timezone?: string; } export async function GET(request: Request) { @@ -19,10 +23,9 @@ export async function GET(request: Request) { const params = new URLSearchParams({ name: query, - count: "8", + count: "10", language, format: "json", - countryCode: "PL", }); try { const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } }); @@ -30,6 +33,7 @@ export async function GET(request: Request) { const data = await response.json() as { results?: RawLocation[] }; const results = (data.results ?? []).flatMap((location) => { if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return []; + const countryCode = location.country_code?.toUpperCase() ?? null; return [{ id: location.id, name: location.name, @@ -37,6 +41,12 @@ export async function GET(request: Request) { longitude: location.longitude as number, province: location.admin1 ?? null, district: location.admin2 ?? null, + country: location.country ?? null, + countryCode, + admin1: location.admin1 ?? null, + admin2: location.admin2 ?? null, + timezone: location.timezone ?? null, + region: getWeatherRegionForCountryCode(countryCode), }]; }); return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } }); diff --git a/app/api/notifications/check/route.ts b/app/api/notifications/check/route.ts index f323084..1fa122b 100644 --- a/app/api/notifications/check/route.ts +++ b/app/api/notifications/check/route.ts @@ -41,7 +41,7 @@ export async function GET(request: Request) { try { const now = Date.now(); const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now)); - const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled); + const subscriptions = getPushSubscriptions().filter((subscription) => subscription.region === "PL" && subscription.enabled && subscription.province); const activeWarningIds = new Set(warnings.map((warning) => warning.id)); let sent = 0; let skipped = 0; @@ -51,7 +51,7 @@ export async function GET(request: Request) { for (const subscription of subscriptions) { const matchingWarnings = warnings.filter((warning) => ( - subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province) + subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : subscription.province !== null && warning.provinces.includes(subscription.province) )); for (const warning of matchingWarnings) { if (hasSentWarning(subscription.endpoint, warning.id)) { diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts index fbecb73..3a117b3 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -16,15 +16,27 @@ function isCronAuthorized(request: Request) { return authHeader === `Bearer ${secret}` || secretHeader === secret; } -function getWarsawDateKey(date = new Date()) { +const DEFAULT_MORNING_BRIEF_TIME = "07:00"; + +function normalizeTime(value: string | undefined, fallback: string) { + return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback; +} + +function getLocalDateParts(timeZone: string | null, date = new Date()) { const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Warsaw", + timeZone: timeZone || "Europe/Warsaw", year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", }).formatToParts(date); const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; - return `${part("year")}-${part("month")}-${part("day")}`; + return { + dateKey: `${part("year")}-${part("month")}-${part("day")}`, + time: `${part("hour")}:${part("minute")}`, + }; } export async function GET(request: Request) { @@ -36,35 +48,43 @@ export async function GET(request: Request) { } const now = new Date(); - const dateKey = getWarsawDateKey(now); + const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME); const subscriptions = getPushSubscriptions().filter((subscription) => ( - subscription.enabled - && subscription.morningBriefEnabled + subscription.morningBriefEnabled && Number.isFinite(subscription.latitude) && Number.isFinite(subscription.longitude) )); let warningsUnavailable = false; - const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { - warningsUnavailable = true; - return []; - }); + const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL"); + const warnings = hasPolishSubscriptions + ? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { + warningsUnavailable = true; + return []; + }) + : []; let sent = 0; let skipped = 0; let failed = 0; for (const subscription of subscriptions) { + const localTime = getLocalDateParts(subscription.timezone, now); + if (localTime.time < briefTime) { + skipped += 1; + continue; + } + const dateKey = localTime.dateKey; if (hasSentMorningBrief(subscription.endpoint, dateKey)) { skipped += 1; continue; } try { - const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); + const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region); const brief = buildWeatherBrief({ forecast, - warnings, + warnings: subscription.region === "PL" ? warnings : [], province: subscription.province, - countyTeryt: subscription.countyTeryt, + countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null, locationName: subscription.locationName ?? "wtr.", language: subscription.language, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, @@ -87,7 +107,7 @@ export async function GET(request: Request) { return NextResponse.json({ ok: true, - date: dateKey, + time: briefTime, subscriptions: subscriptions.length, warningsUnavailable, sent, diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index c0a6fea..6edb270 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -5,6 +5,7 @@ import { normalizeProvinceName } from "@/lib/provinces"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils"; import type { Language } from "@/lib/i18n"; import type { BrowserPushSubscription } from "@/types/notifications"; +import type { WeatherRegion } from "@/types/weather-region"; export const runtime = "nodejs"; @@ -25,6 +26,7 @@ export async function POST(request: Request) { const body = await request.json().catch(() => null) as { subscription?: unknown; province?: unknown; + region?: unknown; language?: unknown; enabled?: unknown; morningBriefEnabled?: unknown; @@ -32,17 +34,20 @@ export async function POST(request: Request) { latitude?: unknown; longitude?: unknown; locationName?: unknown; + timezone?: unknown; countyTeryt?: unknown; temperatureUnit?: unknown; windSpeedUnit?: unknown; } | null; const subscription = body?.subscription; + const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL"; const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); const language: Language = body?.language === "en" ? "en" : "pl"; const rawTemperatureUnit = body?.temperatureUnit; const rawWindSpeedUnit = body?.windSpeedUnit; - if (!isBrowserPushSubscription(subscription) || !province) { + const enabled = body?.enabled !== false && region === "PL"; + if (!isBrowserPushSubscription(subscription) || (enabled && !province)) { return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 }); } @@ -51,13 +56,15 @@ export async function POST(request: Request) { endpoint: subscription.endpoint, subscription, province, + region, language, - enabled: body?.enabled !== false, + enabled, 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, + timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null, countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null, temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT, diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts index 26c2f3c..0d9600d 100644 --- a/app/api/notifications/tomorrow-brief/route.ts +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -16,25 +16,36 @@ function isCronAuthorized(request: Request) { return authHeader === `Bearer ${secret}` || secretHeader === secret; } -function getWarsawDateKey(date = new Date(), dayOffset = 0) { +const DEFAULT_TOMORROW_BRIEF_TIME = "18:00"; + +function normalizeTime(value: string | undefined, fallback: string) { + return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback; +} + +function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset = 0) { const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Warsaw", + timeZone: timeZone || "Europe/Warsaw", year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", }).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 dateKey = `${part("year")}-${part("month")}-${part("day")}`; + const time = `${part("hour")}:${part("minute")}`; + if (dayOffset === 0) return { dateKey, time }; 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", + timeZone: 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")}`; + return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time }; } export async function GET(request: Request) { @@ -46,35 +57,43 @@ export async function GET(request: Request) { } const now = new Date(); - const targetDateKey = getWarsawDateKey(now, 1); + const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME); const subscriptions = getPushSubscriptions().filter((subscription) => ( - subscription.enabled - && subscription.tomorrowBriefEnabled + 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 []; - }); + const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL"); + const warnings = hasPolishSubscriptions + ? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { + warningsUnavailable = true; + return []; + }) + : []; let sent = 0; let skipped = 0; let failed = 0; for (const subscription of subscriptions) { + const localToday = getLocalDateParts(subscription.timezone, now); + if (localToday.time < briefTime) { + skipped += 1; + continue; + } + const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey; if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) { skipped += 1; continue; } try { - const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); + const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region); const brief = buildTomorrowWeatherBrief({ forecast, - warnings, + warnings: subscription.region === "PL" ? warnings : [], province: subscription.province, - countyTeryt: subscription.countyTeryt, + countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null, locationName: subscription.locationName ?? "wtr.", language: subscription.language, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, @@ -97,7 +116,7 @@ export async function GET(request: Request) { return NextResponse.json({ ok: true, - date: targetDateKey, + time: briefTime, subscriptions: subscriptions.length, warningsUnavailable, sent, diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index aadaa33..063fdc5 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -18,6 +18,7 @@ import { getUpcomingHourlyForecast } from "@/lib/forecast-utils"; import { locateSynopStations } from "@/lib/location-utils"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card"; +import type { SynopStation } from "@/types/imgw"; export function DashboardPage() { const { t } = useI18n(); @@ -28,7 +29,8 @@ export function DashboardPage() { const selectedStation = stations?.find((station) => station.id === selectedStationId) ?? stations?.find((station) => station.name === DEFAULT_STATION_NAME) ?? stations?.[0]; - const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null; + const activeLocation = selectedLocation; + const activeRegion = activeLocation?.region ?? "PL"; const stationPosition = selectedStation ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) : null; @@ -36,20 +38,32 @@ export function DashboardPage() { const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude; const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude; const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name; - const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude); - const { data: forecast } = useForecast(forecastLatitude, forecastLongitude); + const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude, activeRegion); + const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion); const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null; const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending; if (isPending) return ; if (isError || !stations?.length || !selectedStation) return refetch()} description={t("dashboard.error")} />; + const heroStation: SynopStation = activeLocation?.region === "GLOBAL" ? { + id: `global:${activeLocation.latitude},${activeLocation.longitude}`, + name: activeLocation.name, + measuredAt: null, + temperature: null, + windSpeed: null, + windDirection: null, + humidity: null, + rainfall: null, + pressure: null, + } : selectedStation; + return ( - + - - + + diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index 839fc06..a1b6f6c 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -13,25 +13,28 @@ import { useI18n } from "@/lib/i18n"; import { getProvinceForSelection } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief"; +import type { WeatherRegion } from "@/types/weather-region"; -export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { +export function WeatherBriefCard({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) { const { language, t } = useI18n(); - const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); + const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region); const { data: warnings = [] } = useWarnings(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); - const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const isGlobalLocation = region === "GLOBAL"; + const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]); const brief = useMemo(() => { if (!forecast) return null; - return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]); + return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); + }, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]); const tomorrowBrief = useMemo(() => { if (!forecast) return null; - return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]); + return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); + }, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]); if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { return ; diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index 288ac28..b067293 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -26,6 +26,7 @@ import { } from "@/lib/forecast-utils"; import { useWeatherStore } from "@/lib/store"; import type { DailyForecast, HourlyForecast } from "@/types/forecast"; +import type { WeatherRegion } from "@/types/weather-region"; function formatHour(value: string) { return value.slice(11, 16); @@ -118,11 +119,11 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: ); } -export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { +export function ForecastPanel({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) { const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); - const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); + const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region); const [selectedDay, setSelectedDay] = useState(null); const closeDayDetails = useCallback(() => setSelectedDay(null), []); const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : []; diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 9b13fc0..16333b4 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -142,17 +142,22 @@ export function SettingsPage() { 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 activeLocation = selectedLocation; + const selectedRegion = activeLocation?.region ?? "PL"; 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 notificationCountyTeryt = activeLocation?.countyTeryt ?? null; - const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); - const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; - const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); + const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null); + const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null; + const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null; + const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null; + const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince); + const effectiveProvinceLabel = selectedRegion === "GLOBAL" + ? t("settings.notifications.globalRegion") + : effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie"; useEffect(() => { @@ -173,7 +178,7 @@ export function SettingsPage() { }, []); useEffect(() => { - if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return; + if (!notificationsEnabled || !vapidPublicKey || notificationStatus !== "granted") return; const province = effectiveProvince; let cancelled = false; @@ -182,11 +187,14 @@ export function SettingsPage() { const subscription = await registration?.pushManager.getSubscription(); if (!subscription || cancelled) return; await savePushSubscription(subscription, province, language, { + region: selectedRegion, + officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled, morningBriefEnabled, tomorrowBriefEnabled, latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + timezone: notificationTimezone, countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, @@ -197,7 +205,7 @@ export function SettingsPage() { return () => { cancelled = true; }; - }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); + }, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); useEffect(() => { if (!provinceDropdownOpen) return; @@ -229,7 +237,7 @@ export function SettingsPage() { } }, [notificationStatus, t]); - const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured"); + const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || effectiveProvince) && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured"); async function getServiceWorkerRegistration() { if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available."); @@ -237,7 +245,7 @@ export function SettingsPage() { } async function enableWarningNotifications() { - if (!vapidPublicKey || !effectiveProvince) { + if (!vapidPublicKey || (selectedRegion === "PL" && !effectiveProvince)) { setNotificationMessage(t("settings.notifications.saveError")); return; } @@ -261,11 +269,14 @@ export function SettingsPage() { applicationServerKey: decodeBase64UrlKey(vapidPublicKey), }); await savePushSubscription(subscription, effectiveProvince, language, { + region: selectedRegion, + officialWarningsEnabled: canUseOfficialWarnings, morningBriefEnabled, tomorrowBriefEnabled, latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + timezone: notificationTimezone, countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, @@ -319,17 +330,20 @@ export function SettingsPage() { async function updateMorningBriefPreference(enabled: boolean) { setMorningBriefEnabled(enabled); - if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return; + if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return; try { const registration = await navigator.serviceWorker?.getRegistration(); const subscription = await registration?.pushManager.getSubscription(); if (!subscription) return; await savePushSubscription(subscription, effectiveProvince, language, { + region: selectedRegion, + officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled, morningBriefEnabled: enabled, tomorrowBriefEnabled, latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + timezone: notificationTimezone, countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, @@ -341,17 +355,20 @@ export function SettingsPage() { async function updateTomorrowBriefPreference(enabled: boolean) { setTomorrowBriefEnabled(enabled); - if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return; + if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return; try { const registration = await navigator.serviceWorker?.getRegistration(); const subscription = await registration?.pushManager.getSubscription(); if (!subscription) return; await savePushSubscription(subscription, effectiveProvince, language, { + region: selectedRegion, + officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled, morningBriefEnabled, tomorrowBriefEnabled: enabled, latitude: notificationLatitude, longitude: notificationLongitude, locationName: notificationLocationName, + timezone: notificationTimezone, countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, @@ -446,6 +463,7 @@ export function SettingsPage() { type="radio" name="warning-notification-province-mode" checked={provinceMode === "selected"} + disabled={selectedRegion === "GLOBAL"} onChange={() => setProvinceMode("selected")} className="mt-1 accent-accent" /> @@ -459,6 +477,7 @@ export function SettingsPage() { type="radio" name="warning-notification-province-mode" checked={provinceMode === "manual"} + disabled={selectedRegion === "GLOBAL"} onChange={() => { setProvinceMode("manual"); if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie"); @@ -473,7 +492,7 @@ export function SettingsPage() { aria-label={t("settings.notifications.regionManual")} aria-haspopup="listbox" aria-expanded={provinceDropdownOpen} - disabled={provinceMode !== "manual"} + disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"} onBlur={(event) => { if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false); }} @@ -514,6 +533,11 @@ export function SettingsPage() { + {selectedRegion === "GLOBAL" && ( + + {t("settings.notifications.globalAlertsUnavailable")} + + )} @@ -541,9 +565,9 @@ export function SettingsPage() { - {t("settings.notifications.enable")} + {selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")} - {t("settings.notifications.enableDescription")} + {selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")} {isSavingSubscription ? t("settings.notifications.saving") diff --git a/components/warnings/dashboard-warnings.tsx b/components/warnings/dashboard-warnings.tsx index ade514f..ac51505 100644 --- a/components/warnings/dashboard-warnings.tsx +++ b/components/warnings/dashboard-warnings.tsx @@ -42,6 +42,7 @@ export function DashboardWarnings() { const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); const [now, setNow] = useState(null); + const isGlobalLocation = selectedLocation?.region === "GLOBAL"; useEffect(() => { const timeoutId = window.setTimeout(() => setNow(Date.now()), 0); @@ -52,10 +53,10 @@ export function DashboardWarnings() { }; }, []); - const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null; const relevantWarnings = useMemo(() => { - if (!warnings || !province || now === null) return []; + if (isGlobalLocation || !warnings || !province || now === null) return []; return warnings .filter((warning) => { @@ -65,7 +66,7 @@ export function DashboardWarnings() { && (validTo === null || validTo > now); }) .sort((a, b) => compareDashboardWarnings(a, b, now)); - }, [now, province, selectedLocation, warnings]); + }, [isGlobalLocation, now, province, selectedLocation, warnings]); if (!province || !regionLabel || !relevantWarnings.length || now === null) return null; diff --git a/components/warnings/warnings-panel.tsx b/components/warnings/warnings-panel.tsx index 076a870..85c0e76 100644 --- a/components/warnings/warnings-panel.tsx +++ b/components/warnings/warnings-panel.tsx @@ -26,6 +26,9 @@ export function WarningsPanel() { const { data: warnings, isPending, isError, refetch } = useWarnings(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); + if (selectedLocation?.region === "GLOBAL") { + return ; + } if (isPending) return ; if (isError) return refetch()} description={t("warnings.error")} />; if (!warnings?.length) return ; diff --git a/components/weather/current-location-control.tsx b/components/weather/current-location-control.tsx index 4390c78..3d589ad 100644 --- a/components/weather/current-location-control.tsx +++ b/components/weather/current-location-control.tsx @@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr import { Button } from "@/components/ui/button"; import { useI18n } from "@/lib/i18n"; import { fetchReverseLocation } from "@/lib/location-api"; -import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils"; +import { createSelectedLocation, type LocatedSynopStation } from "@/lib/location-utils"; import { useWeatherStore } from "@/lib/store"; const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen"; @@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta setMessage(t("location.gpsUnavailable")); return; } - if (!stations.length) { - setMessage(t("location.gpsStationsPending")); - return; - } - setIsLocating(true); navigator.geolocation.getCurrentPosition( async ({ coords }) => { @@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta name: t("location.gpsFallbackName"), province: null, district: null, + country: null, + countryCode: null, + admin1: null, + admin2: null, + timezone: null, + region: "GLOBAL" as const, })); - const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations); - if (!nearest) { - setMessage(t("location.gpsStationsPending")); - return; - } - selectLocation(nearest); - setMessage(t("location.gpsSelected", { location: nearest.name })); + const selected = createSelectedLocation({ ...place, latitude, longitude }, stations); + selectLocation(selected); + setMessage(t("location.gpsSelected", { location: selected.name })); } catch { setMessage(t("location.gpsPositionUnavailable")); } finally { @@ -89,13 +86,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta }, []); useEffect(() => { - if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return; + if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return; navigator.permissions.query({ name: "geolocation" }).then((permission) => { if (permission.state !== "granted" || autoLocated.current) return; autoLocated.current = true; locate(); }).catch(() => undefined); - }, [locate, stations.length]); + }, [locate]); return ( @@ -108,7 +105,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta {t("location.gpsPromptDescription")} {!isSecureContext && {t("location.gpsSecureContext")}} - + {isLocating ? : } {isLocating ? t("location.gpsLocating") : t("location.gpsAllow")} @@ -120,7 +117,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta )} {!showPrompt && ( - + {isLocating ? : } {isLocating ? t("location.gpsLocating") : t("location.gpsUse")} diff --git a/components/weather/location-search.tsx b/components/weather/location-search.tsx index b29a170..3f1489a 100644 --- a/components/weather/location-search.tsx +++ b/components/weather/location-search.tsx @@ -2,13 +2,13 @@ import { useMemo, useState, type KeyboardEvent } from "react"; import { LoaderCircle, MapPin, Search, X } from "lucide-react"; +import { CurrentLocationControl } from "@/components/weather/current-location-control"; import { useLocationSearch } from "@/hooks/use-location-search"; -import { useWeatherStore } from "@/lib/store"; -import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils"; import { useI18n } from "@/lib/i18n"; +import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils"; +import { useWeatherStore } from "@/lib/store"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { SelectedLocation } from "@/types/location"; -import { CurrentLocationControl } from "@/components/weather/current-location-control"; export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) { const { language, t } = useI18n(); @@ -20,10 +20,10 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]); const suggestions = useMemo(() => (locations ?? []).map((location) => ({ location, - nearest: findNearestSynopStation(location, locatedStations), - })).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]); + selected: createSelectedLocation(location, locatedStations), + })), [locatedStations, locations]); const showSuggestions = isFocused && query.trim().length >= 2; - const isPreparingStations = positions.length === 0; + const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName); function chooseLocation(location: SelectedLocation) { selectLocation(location); @@ -32,7 +32,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation } function handleSearchKeyDown(event: KeyboardEvent) { - const firstSuggestion = suggestions[0]?.nearest; + const firstSuggestion = suggestions[0]?.selected; if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return; event.preventDefault(); chooseLocation(firstSuggestion); @@ -66,18 +66,22 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation {isPreparingStations ? {t("location.preparing")} : isError ? {t("location.error")} : !isFetching && !suggestions.length ? {t("location.empty")} : - suggestions.map(({ location, nearest }) => nearest && ( + suggestions.map(({ location, selected }) => ( chooseLocation(nearest)} + onClick={() => chooseLocation(selected)} className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" > {location.name} - {[location.district, location.province].filter(Boolean).join(" · ")} + {[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")} - {t("location.nearest")}{nearest.stationName} · {nearest.distanceKm} km + {selected.stationName && selected.distanceKm !== null ? ( + {t("location.nearest")}{selected.stationName} · {selected.distanceKm} km + ) : ( + {t("location.modelSource")}Open-Meteo + )} ))} @@ -86,7 +90,9 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation {selectedLocation && ( - {t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} + {selectedLocation.stationName && selectedLocation.distanceKm !== null + ? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm }) + : t("location.currentSourceGlobal", { location: selectedLocation.name })} )} diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 710d25e..8e48563 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -42,18 +42,19 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null { return null; } -export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number }) { +export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) { const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const [devEffectOverride, setDevEffectOverride] = useState(null); const displayedLocationName = locationName ?? station.name; + const hasGlobalModel = currentWeather?.coverage === "global-model"; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; - const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; + const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30; const displayedStation = currentWeather ? { ...station, - measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt, + measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt, temperature: currentWeather.temperature ?? station.temperature, windSpeed: currentWeather.windSpeed ?? station.windSpeed, windDirection: currentWeather.windDirection ?? station.windDirection, @@ -67,7 +68,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f const metrics = [ { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) }, - { icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, + { icon: Umbrella, label: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, ]; const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm" @@ -116,6 +117,8 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f {currentWeatherLoading ? t("location.heroHybridLoading", { station: station.name }) + : hasGlobalModel + ? t("location.heroGlobalModelSource", { location: displayedLocationName }) : hasFullHybridAnalysis ? t("location.heroHybridSource", { location: displayedLocationName }) : hasPartialHybridAnalysis @@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f {formatTemperature(displayedStation.temperature, language, temperatureUnit)} {weatherDescription} - {t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)} + {t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)} diff --git a/docs/api.md b/docs/api.md index e7c4c9d..f17626a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,10 +6,11 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next | Metoda | Endpoint | Przeznaczenie | | --- | --- | --- | -| `GET` | `/api/forecast?latitude={lat}&longitude={lon}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Łączy IMGW ALARO z Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | +| `GET` | `/api/current-weather?latitude={lat}&longitude={lon}®ion={PL\|GLOBAL}` | Zwraca znormalizowane bieżące warunki. Dla `PL` używa IMGW Hybrid, dla `GLOBAL` modelowych warunków Open-Meteo. | +| `GET` | `/api/forecast?latitude={lat}&longitude={lon}®ion={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | | `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | | `GET` | `/api/imgw/{path}` | Proxy allowlistowanych endpointów IMGW `danepubliczne.imgw.pl`. Obsługuje kolekcje `synop`, `hydro`, `meteo`, `warningsmeteo`, `warningshydro`, `product` oraz szczegół `synop/id/{id}`. Nieobsługiwana ścieżka zwraca `404`. | -| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości w Polsce przez Open-Meteo Geocoding. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. | +| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości globalnie przez Open-Meteo Geocoding. Wyniki zawierają `countryCode`, `admin1`, `admin2`, `timezone` i region `PL` albo `GLOBAL`. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. | | `GET` | `/api/locations/reverse?latitude={lat}&longitude={lon}&language={pl\|en}` | Ustala nazwę miejsca dla pozycji GPS przez Nominatim / OpenStreetMap. Współrzędne są zaokrąglane do trzech miejsc po przecinku. | ## Powiadomienia @@ -19,7 +20,7 @@ Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` | Metoda | Endpoint | Przeznaczenie | | --- | --- | --- | | `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. | -| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo i opcjonalny powiat TERYT. | +| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje region `PL`/`GLOBAL`, preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo dla `PL` i opcjonalny powiat TERYT. | | `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. | | `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. | | `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. | @@ -48,6 +49,7 @@ Jeśli `NOTIFICATIONS_CRON_SECRET` nie jest ustawiony, endpointy harmonogramu s | Endpoint | Cache | | --- | --- | | `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` | +| `/api/current-weather` | `s-maxage=120`, `stale-while-revalidate=300` | | `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` | | `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` | | `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` | diff --git a/docs/architecture.md b/docs/architecture.md index bb5e239..884962a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ Service worker: - cache'uje powłokę aplikacji, - obsługuje fallback `/offline` dla nawigacji, -- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/forecast`, +- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`, - obsługuje zdarzenia `push` i kliknięcia w powiadomienia. Rejestracja service workera działa w buildzie produkcyjnym. @@ -66,6 +66,7 @@ Interfejs jest dostępny po polsku i angielsku. Teksty są w `lib/i18n.tsx`. Naz ## Zasady Integracji - Dane zewnętrzne przechodzą przez route handlery Next.js. -- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń. -- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW. +- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń dla Polski. +- Poza Polską Open-Meteo jest źródłem modelowych bieżących warunków i prognozy. +- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW ani oficjalny alert. - Braki danych są pokazywane jawnie, bez generowania fikcyjnych wartości. diff --git a/docs/data-sources.md b/docs/data-sources.md index c5036f0..c872dd1 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -18,13 +18,27 @@ IMGW jest traktowane jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognoza modelowa jest w interfejsie rozdzielona od pomiarów. +IMGW jest używane tylko dla lokalizacji w Polsce. Dla lokalizacji globalnych oficjalne ostrzeżenia IMGW, dane hydrologiczne IMGW, TERYT i powiatowe filtrowanie ostrzeżeń są niedostępne. + ## Open-Meteo -Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada. +Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada. Poza Polską dostarcza także modelowe warunki bieżące dla dashboardu. -Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do wyszukiwania miejscowości w Polsce. +Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do globalnego wyszukiwania miejscowości. Wynik zawiera m.in. `countryCode`, `admin1`, `admin2`, `timezone` i współrzędne, które aplikacja zapisuje w modelu lokalizacji. -Przed większym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania z Open-Meteo albo zastąpić usługę własnym dostawcą. +Przed większym, publicznym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania, limity i licencję Open-Meteo albo zastąpić usługę własnym dostawcą. + +## Capabilities per Region + +| Capability | Polska | Global | +| --- | --- | --- | +| Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model | +| Prognoza godzinowa | IMGW ALARO + Open-Meteo | Open-Meteo | +| Prognoza dzienna | IMGW ALARO/Open-Meteo | Open-Meteo | +| Oficjalne ostrzeżenia | IMGW | niedostępne | +| Filtrowanie powiatowe | TERYT | niedostępne | +| Briefy | tak | tak, bez oficjalnych alertów | +| Push alerty oficjalne | IMGW | niedostępne | ## Nominatim / OpenStreetMap @@ -43,12 +57,13 @@ Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użyc | Dane | Cache | | --- | --- | | IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` | +| Modelowe warunki bieżące Open-Meteo | 120 sekund, `stale-while-revalidate=300` | | Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` | | Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` | | Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` | | Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` | -Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią. +Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią. ## Ograniczenia Źródeł diff --git a/docs/notifications.md b/docs/notifications.md index 0889be3..9474d21 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -2,7 +2,7 @@ Powiadomienia obejmują: -- nowe ostrzeżenia meteorologiczne IMGW, +- nowe ostrzeżenia meteorologiczne IMGW dla Polski, - poranny brief dnia, - wieczorny brief z prognozą na jutro, - testowe powiadomienie wysyłane z `/settings`. @@ -44,7 +44,8 @@ Jeśli zmienna nie jest ustawiona, aplikacja używa `./data/wtr.sqlite` względe Subskrypcja Web Push zapisuje: - endpoint subskrypcji przeglądarki, -- województwo, +- region pogodowy `PL` albo `GLOBAL`, +- województwo dla oficjalnych ostrzeżeń IMGW w Polsce, - język interfejsu, - czy ostrzeżenia są aktywne, - czy aktywny jest brief poranny, @@ -56,6 +57,8 @@ Subskrypcja Web Push zapisuje: Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki. +Dla subskrypcji `GLOBAL` oficjalne ostrzeżenia IMGW są wyłączone, `province` może być puste, a powiadomienia mogą obejmować brief poranny i brief na jutro oparte o prognozę modelową Open-Meteo. + ## Worker Self-Hosted Worker powiadomień uruchamia się osobno od aplikacji: @@ -84,6 +87,7 @@ Domyślna konfiguracja: ```bash NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 +NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 ``` @@ -91,8 +95,9 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 Worker: - sprawdza nowe ostrzeżenia co 5 minut, -- po 07:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/daily-brief`, -- po 18:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/tomorrow-brief`, +- co 5 minut odpytuje endpointy briefów, +- endpoint `/api/notifications/daily-brief` wysyła po 07:00 lokalnego czasu zapisanego w subskrypcji, +- endpoint `/api/notifications/tomorrow-brief` wysyła po 18:00 lokalnego czasu zapisanego w subskrypcji, - nie blokuje jednego harmonogramu błędem drugiego. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. diff --git a/docs/weather-logic.md b/docs/weather-logic.md index c5cad9c..8e890e3 100644 --- a/docs/weather-logic.md +++ b/docs/weather-logic.md @@ -2,9 +2,29 @@ `wtr.` rozdziela bieżące pomiary, lokalną analizę IMGW Hybrid, prognozę modelową i ostrzeżenia. Prognoza nie jest przedstawiana jako pomiar IMGW. +## Region i Kontrakt Danych + +Aplikacja rozpoznaje dwa regiony pogodowe: + +- `PL` - lokalizacje w Polsce, +- `GLOBAL` - wszystkie pozostałe lokalizacje. + +Region jest częścią modelu lokalizacji i odpowiedzi pogodowych. UI powinien bazować na capabilities i metadanych źródła, a nie rozsiewać własne warunki po kraju. + +Każda znormalizowana odpowiedź bieżących warunków zawiera metadane: + +- `source`, np. `IMGW` albo `OPEN_METEO`, +- `sourceLabel` dla UI, +- `sourceType`, np. `hybrid` albo `model`, +- `isOfficial`, +- `region`, +- `fetchedAt` i `measuredAt`/czas aktualizacji. + ## Bieżące Warunki -Dashboard hero korzysta z publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl` przez `/api/imgw-current`. +Dashboard hero korzysta z `/api/current-weather`. + +Dla `PL` endpoint używa publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl`. Dla `GLOBAL` używa modelowych bieżących warunków Open-Meteo i nie opisuje ich jako pomiaru. Normalizacja w `lib/imgw-current-api.ts`: @@ -19,13 +39,15 @@ Jeśli Hybrid nie dostarcza pełnych danych, UI jawnie korzysta z fallbacku `syn ## Prognoza Modelowa -Route handler `/api/forecast` pobiera równolegle: +Route handler `/api/forecast` dla `PL` pobiera równolegle: - pełne 7 dni Open-Meteo, - godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo. Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont do 7 dni. Awaria ALARO pozostawia działający fallback Open-Meteo. +Dla `GLOBAL` route handler nie odpytuje IMGW ALARO i używa Open-Meteo z `timezone=auto`. + Dashboard pokazuje: - regułowy brief dnia, @@ -38,6 +60,8 @@ Widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. Ostrzeżenia meteorologiczne IMGW zawierają kody powiatów TERYT. Ostrzeżenia hydrologiczne zawierają jawne województwa. Normalizacja odbywa się przez `lib/provinces.ts` i `lib/warning-regions.ts`. +Ostrzeżenia są capability tylko dla `PL`. Dla `GLOBAL` widok `/warnings` pokazuje stan niedostępności oficjalnych ostrzeżeń i nie traktuje prognozy modelowej jako oficjalnego alertu. + Zasady UI: - lokalny obszar jest priorytetyzowany, @@ -55,6 +79,8 @@ Brief dnia analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i na Brief na jutro analizuje pełny jutrzejszy dzień prognozy. Może informować o burzach na podstawie kodów pogodowych modelu nawet wtedy, gdy IMGW nie opublikowało oficjalnego ostrzeżenia. +Dla `GLOBAL` briefy korzystają z Open-Meteo i pomijają oficjalne ostrzeżenia IMGW. + ## Opad i Pomiary Synoptyczne Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu. diff --git a/hooks/use-current-weather.ts b/hooks/use-current-weather.ts index a629f89..df817ec 100644 --- a/hooks/use-current-weather.ts +++ b/hooks/use-current-weather.ts @@ -1,15 +1,16 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { fetchImgwCurrentWeather } from "@/lib/imgw-current-api"; +import { fetchCurrentWeather } from "@/lib/imgw-current-api"; import { QUERY_GC_TIME } from "@/lib/constants"; +import type { WeatherRegion } from "@/types/weather-region"; const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000; -export function useCurrentWeather(latitude?: number, longitude?: number) { +export function useCurrentWeather(latitude?: number, longitude?: number, region: WeatherRegion = "PL") { return useQuery({ - queryKey: ["imgw-current-weather", latitude, longitude], - queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal), + queryKey: ["current-weather", region, latitude, longitude], + queryFn: ({ signal }) => fetchCurrentWeather(latitude as number, longitude as number, region, signal), staleTime: CURRENT_WEATHER_STALE_TIME, gcTime: QUERY_GC_TIME, retry: 1, diff --git a/hooks/use-forecast.ts b/hooks/use-forecast.ts index bac5ac4..cb44873 100644 --- a/hooks/use-forecast.ts +++ b/hooks/use-forecast.ts @@ -3,11 +3,12 @@ import { useQuery } from "@tanstack/react-query"; import { fetchForecast } from "@/lib/forecast-api"; import { QUERY_GC_TIME } from "@/lib/constants"; +import type { WeatherRegion } from "@/types/weather-region"; -export function useForecast(latitude?: number, longitude?: number) { +export function useForecast(latitude?: number, longitude?: number, region: WeatherRegion = "PL") { return useQuery({ - queryKey: ["forecast", latitude, longitude], - queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal), + queryKey: ["forecast", region, latitude, longitude], + queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, region, signal), enabled: Number.isFinite(latitude) && Number.isFinite(longitude), staleTime: 15 * 60 * 1000, gcTime: QUERY_GC_TIME, diff --git a/lib/forecast-api.ts b/lib/forecast-api.ts index 5fc8a8b..f5b13b7 100644 --- a/lib/forecast-api.ts +++ b/lib/forecast-api.ts @@ -1,4 +1,6 @@ import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast"; +import type { WeatherRegion } from "@/types/weather-region"; +import { getWeatherCapabilities } from "@/types/weather-region"; function readNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; @@ -59,18 +61,22 @@ function normalizeForecast(raw: Partial): WeatherForecast { const latitude = Number(raw.latitude); const longitude = Number(raw.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); + const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL"; return { latitude, longitude, + region, timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw", hourly: normalizeHourlyForecast(raw.hourly), daily: normalizeDailyForecast(raw.daily), sources: normalizeSources(raw.sources), + capabilities: raw.capabilities ?? getWeatherCapabilities(region), + unavailableReasons: Array.isArray(raw.unavailableReasons) ? raw.unavailableReasons.filter((value): value is string => typeof value === "string") : [], }; } -export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) { - const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) }); +export async function fetchForecast(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) { + const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region }); const response = await fetch(`/api/forecast?${params}`, { signal }); if (!response.ok) throw new Error("Unable to load forecast."); return normalizeForecast(await response.json() as Partial); diff --git a/lib/forecast-merge.ts b/lib/forecast-merge.ts index 3dc71fc..853ba57 100644 --- a/lib/forecast-merge.ts +++ b/lib/forecast-merge.ts @@ -8,6 +8,7 @@ import type { RawWeatherForecast, WeatherForecast, } from "@/types/forecast"; +import { getWeatherCapabilities, type WeatherRegion } from "@/types/weather-region"; const WARSAW_TIME_ZONE = "Europe/Warsaw"; const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", { @@ -96,17 +97,20 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[ }); } -function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast { +function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast { const latitude = Number(raw.latitude); const longitude = Number(raw.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); return { latitude, longitude, + region, timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE, hourly: normalizeOpenMeteoHourly(raw.hourly), daily: normalizeOpenMeteoDaily(raw.daily), sources: ["open-meteo"], + capabilities: getWeatherCapabilities(region), + unavailableReasons: region === "GLOBAL" ? ["official-warnings"] : [], }; } @@ -188,8 +192,8 @@ function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecas }; } -export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast { - const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload); +export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null, region: WeatherRegion = "PL"): WeatherForecast { + const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region); const imgwHours = normalizeImgwHourly(imgwPayload); let hasImgwHours = false; const hourly = openMeteoForecast.hourly.map((hour) => { diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 645634e..83ee916 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -44,9 +44,12 @@ const translations = { "settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji", "settings.notifications.regionManual": "Wybierz województwo ręcznie", "settings.notifications.noProvince": "brak wybranego województwa", + "settings.notifications.globalRegion": "lokalizacja poza Polską", + "settings.notifications.globalAlertsUnavailable": "Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.", "settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.enable": "Alerty IMGW", "settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", + "settings.notifications.enableGlobalDescription": "Subskrypcja zostanie zapisana dla wybranej lokalizacji i użyta do briefów opartych o prognozę modelową.", "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.tomorrowBrief": "Brief na jutro o 18:00", @@ -71,7 +74,7 @@ const translations = { "settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.", "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", "settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.", - "settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW przez Web Push. W aktualnej wersji subskrypcje są przechowywane w pamięci procesu, więc produkcja wymaga podmiany magazynu na trwałą bazę lub KV.", + "settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.", "pwa.install": "Zainstaluj", "common.noData": "Brak danych", "common.loading": "Ładowanie danych", @@ -87,15 +90,18 @@ const translations = { "brief.emptyTitle": "Brak briefu", "brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.", "location.label": "Twoja lokalizacja", - "location.searchLabel": "Szukaj miejscowości w Polsce", - "location.placeholder": "Wpisz miejscowość, np. Piaseczno…", + "location.searchLabel": "Szukaj miejscowości", + "location.placeholder": "Wpisz miejscowość, np. Warszawa albo Rome…", "location.clear": "Wyczyść wyszukiwanie", "location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.", "location.preparing": "Przygotowuję listę najbliższych stacji IMGW…", - "location.empty": "Nie znaleziono pasującej miejscowości w Polsce.", + "location.empty": "Nie znaleziono pasującej miejscowości.", "location.nearest": "Najbliższa stacja IMGW", + "location.modelSource": "Źródło modelowe", "location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", + "location.currentSourceGlobal": "{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.", "location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}", + "location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km", "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km", @@ -106,7 +112,7 @@ const translations = { "location.gpsUse": "Użyj mojej lokalizacji", "location.gpsLocating": "Ustalam lokalizację…", "location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?", - "location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.", + "location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.", "location.gpsAllow": "Użyj GPS", "location.gpsNotNow": "Nie teraz", "location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.", @@ -129,9 +135,11 @@ const translations = { "weather.wind": "Wiatr", "weather.rainfall": "Suma opadu", "weather.rainfall10m": "Opad 10 min", + "weather.currentPrecipitation": "Opad", "weather.pressure": "Ciśnienie", "weather.feelsLike": "Odczuwalna", "weather.measurement": "pomiar", + "weather.modelUpdate": "aktualizacja modelu", "weather.windDirection": "Kierunek wiatru", "weather.calm": "Pogodnie", "weather.humid": "Wilgotno", @@ -177,7 +185,7 @@ const translations = { "forecast.pastHour": "Miniona godzina", "forecast.source": "Źródło prognozy:", "forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.", - "forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.", + "forecast.sourceFallbackDescription": "Prognoza jest wyświetlana jako modelowa prognoza Open-Meteo.", "forecast.error": "Nie udało się pobrać prognozy modelowej.", "forecast.emptyTitle": "Brak prognozy", "forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.", @@ -217,6 +225,8 @@ const translations = { "warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.emptyTitle": "Brak aktywnych ostrzeżeń", "warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.", + "warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski", + "warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia pogodowe są obecnie obsługiwane tylko dla Polski przez IMGW. Dla tej lokalizacji możesz korzystać z prognozy modelowej Open-Meteo, ale nie traktujemy jej jako oficjalnego alertu.", "warnings.drought": "Susza hydrologiczna", "warnings.levelUnknown": "Poziom nieokreślony", "warnings.level": "Stopień {level}", @@ -291,9 +301,12 @@ const translations = { "settings.notifications.regionSelected": "Use the location selected in the app", "settings.notifications.regionManual": "Choose province manually", "settings.notifications.noProvince": "no province selected", + "settings.notifications.globalRegion": "location outside Poland", + "settings.notifications.globalAlertsUnavailable": "Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.", "settings.notifications.deviceTitle": "This device", "settings.notifications.enable": "IMGW alerts", "settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.", + "settings.notifications.enableGlobalDescription": "The subscription will be saved for the selected location and used for model forecast briefs.", "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.tomorrowBrief": "Tomorrow brief at 18:00", @@ -318,7 +331,7 @@ const translations = { "settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.", "settings.notifications.statusGranted": "This device has notification permission.", "settings.notifications.statusReady": "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.", - "settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings through Web Push. This version stores subscriptions in process memory, so production needs a durable database or KV store.", + "settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.", "pwa.install": "Install", "common.noData": "No data", "common.loading": "Loading data", @@ -334,15 +347,18 @@ const translations = { "brief.emptyTitle": "Brief unavailable", "brief.emptyDescription": "The forecast does not contain enough data for the next day.", "location.label": "Your location", - "location.searchLabel": "Search places in Poland", - "location.placeholder": "Enter a place, e.g. Piaseczno…", + "location.searchLabel": "Search places", + "location.placeholder": "Enter a place, e.g. Warsaw or Rome…", "location.clear": "Clear search", "location.error": "Unable to search for places. Try again.", "location.preparing": "Preparing the nearest IMGW stations…", - "location.empty": "No matching place was found in Poland.", + "location.empty": "No matching place was found.", "location.nearest": "Nearest IMGW station", + "location.modelSource": "Model source", "location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.", "location.heroHybridSource": "IMGW Hybrid analysis for: {location}", + "location.currentSourceGlobal": "{location}: coordinates are used for Open-Meteo model current conditions and forecast.", + "location.heroGlobalModelSource": "Open-Meteo model current conditions for: {location}", "location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away", "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away", @@ -353,7 +369,7 @@ const translations = { "location.gpsUse": "Use my location", "location.gpsLocating": "Finding your location…", "location.gpsPromptTitle": "Show weather for your location?", - "location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.", + "location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.", "location.gpsAllow": "Use GPS", "location.gpsNotNow": "Not now", "location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.", @@ -376,9 +392,11 @@ const translations = { "weather.wind": "Wind", "weather.rainfall": "Rainfall total", "weather.rainfall10m": "Rainfall 10 min", + "weather.currentPrecipitation": "Precipitation", "weather.pressure": "Pressure", "weather.feelsLike": "Feels like", "weather.measurement": "measurement", + "weather.modelUpdate": "model update", "weather.windDirection": "Wind direction", "weather.calm": "Fair", "weather.humid": "Humid", @@ -424,7 +442,7 @@ const translations = { "forecast.pastHour": "Past hour", "forecast.source": "Forecast source:", "forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.", - "forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.", + "forecast.sourceFallbackDescription": "The forecast is shown as an Open-Meteo model forecast.", "forecast.error": "Unable to load the model forecast.", "forecast.emptyTitle": "Forecast unavailable", "forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.", @@ -464,6 +482,8 @@ const translations = { "warnings.error": "Unable to load meteorological or hydrological warnings.", "warnings.emptyTitle": "No active warnings", "warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.", + "warnings.globalUnavailableTitle": "Official warnings only for Poland", + "warnings.globalUnavailableDescription": "Official weather warnings are currently supported only for Poland through IMGW. For this location you can use the Open-Meteo model forecast, but it is not treated as an official alert.", "warnings.drought": "Hydrological drought", "warnings.levelUnknown": "Level not specified", "warnings.level": "Level {level}", diff --git a/lib/imgw-current-api.ts b/lib/imgw-current-api.ts index b3af463..142dbc1 100644 --- a/lib/imgw-current-api.ts +++ b/lib/imgw-current-api.ts @@ -1,5 +1,6 @@ import { toNumber } from "@/lib/weather-utils"; import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current"; +import type { WeatherRegion } from "@/types/weather-region"; const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000; @@ -104,6 +105,12 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons return { coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only", + region: "PL", + source: "IMGW", + sourceLabel: "IMGW Hybrid", + sourceType: fullRow ? "hybrid" : "model", + isOfficial: true, + fetchedAt: new Date().toISOString(), measuredAt: row.measuredAt, temperature: toCelsius(row.row.Temperature), feelsLike: toCelsius(row.row.Chill), @@ -126,3 +133,10 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid."); return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse); } + +export async function fetchCurrentWeather(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) { + const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region }); + const response = await fetch(`/api/current-weather?${params}`, { signal }); + if (!response.ok) throw new Error("Nie udało się pobrać bieżących warunków pogodowych."); + return await response.json() as ImgwCurrentWeather | null; +} diff --git a/lib/location-utils.ts b/lib/location-utils.ts index a6071bb..19d775f 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -1,6 +1,7 @@ import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { LocationSearchResult, SelectedLocation } from "@/types/location"; import { getCountyTerytForLocation } from "@/lib/warning-regions"; +import { getWeatherRegionForCountryCode } from "@/types/weather-region"; const stationAliases: Record = { gdansk: "gdanskrebiechowo", @@ -64,6 +65,12 @@ export function findNearestSynopStation( name: location.name, province: location.province, district: location.district, + country: "Polska", + countryCode: "PL", + admin1: location.province, + admin2: location.district, + timezone: "Europe/Warsaw", + region: "PL", countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name), latitude: location.latitude, longitude: location.longitude, @@ -72,3 +79,35 @@ export function findNearestSynopStation( distanceKm: Math.round(nearest.distanceKm), }; } + +export function createSelectedLocation( + location: Pick, + stations: LocatedSynopStation[], +): SelectedLocation { + const countryCode = location.countryCode?.toUpperCase() ?? null; + const region = location.region ?? getWeatherRegionForCountryCode(countryCode); + const nearest = region === "PL" + ? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => { + const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude); + return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best; + }, null) + : null; + + return { + name: location.name, + province: location.province, + district: location.district, + country: location.country, + countryCode, + admin1: location.admin1, + admin2: location.admin2, + timezone: location.timezone, + region, + countyTeryt: region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null, + latitude: location.latitude, + longitude: location.longitude, + stationId: nearest?.station.id ?? null, + stationName: nearest?.station.name ?? null, + distanceKm: nearest ? Math.round(nearest.distanceKm) : null, + }; +} diff --git a/lib/notification-api.ts b/lib/notification-api.ts index bf5c374..2064a6d 100644 --- a/lib/notification-api.ts +++ b/lib/notification-api.ts @@ -1,6 +1,7 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { WeatherRegion } from "@/types/weather-region"; interface VapidKeyResponse { configured: boolean; @@ -14,17 +15,20 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) { } interface SavePushSubscriptionOptions { + region?: WeatherRegion; + officialWarningsEnabled?: boolean; morningBriefEnabled?: boolean; tomorrowBriefEnabled?: boolean; latitude?: number | null; longitude?: number | null; locationName?: string | null; + timezone?: string | null; countyTeryt?: string | null; temperatureUnit?: TemperatureUnit; windSpeedUnit?: WindSpeedUnit; } -export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) { +export async function savePushSubscription(subscription: PushSubscription, province: Province | null, language: Language, options: SavePushSubscriptionOptions = {}) { const response = await fetch("/api/notifications/subscriptions", { method: "POST", headers: { "content-type": "application/json" }, @@ -32,12 +36,14 @@ export async function savePushSubscription(subscription: PushSubscription, provi subscription: subscription.toJSON(), province, language, - enabled: true, + region: options.region ?? "PL", + enabled: options.officialWarningsEnabled ?? true, morningBriefEnabled: options.morningBriefEnabled === true, tomorrowBriefEnabled: options.tomorrowBriefEnabled === true, latitude: options.latitude ?? null, longitude: options.longitude ?? null, locationName: options.locationName ?? null, + timezone: options.timezone ?? null, countyTeryt: options.countyTeryt ?? null, temperatureUnit: options.temperatureUnit, windSpeedUnit: options.windSpeedUnit, diff --git a/lib/push-service.ts b/lib/push-service.ts index 7a50bbc..db82c0a 100644 --- a/lib/push-service.ts +++ b/lib/push-service.ts @@ -1,9 +1,9 @@ import webPush, { type PushSubscription } from "web-push"; import type { Language } from "@/lib/i18n"; import { formatProvinceName } from "@/lib/provinces"; +import type { WeatherBrief } from "@/lib/weather-brief"; import type { WeatherWarning } from "@/types/imgw"; import type { WarningPushSubscription } from "@/types/notifications"; -import type { WeatherBrief } from "@/lib/weather-brief"; const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY; const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY; @@ -49,13 +49,13 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) { } function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) { - const province = formatProvinceName(preference.province, preference.language); + const region = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "wtr."; const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning"); const validity = formatWarningValidity(warning, preference.language); const level = getWarningLevelLabel(warning, preference.language); const bodyParts = preference.language === "pl" - ? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null] - : [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null]; + ? [`${region}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null] + : [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null]; return { title: `IMGW: ${title}`, @@ -73,12 +73,12 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio export async function sendTestNotification(preference: WarningPushSubscription) { ensureWebPushConfigured(); - const province = formatProvinceName(preference.province, preference.language); + const location = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "Open-Meteo"; const payload = JSON.stringify({ - title: preference.language === "pl" ? "wtr.: test powiadomień IMGW" : "wtr.: IMGW notification test", + title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test", body: preference.language === "pl" - ? `Powiadomienia są aktywne dla województwa: ${province}.` - : `Notifications are active for: ${province}.`, + ? `Powiadomienia są aktywne dla: ${location}.` + : `Notifications are active for: ${location}.`, url: "/settings", }); await webPush.sendNotification(preference.subscription as PushSubscription, payload); diff --git a/lib/push-store.ts b/lib/push-store.ts index 9fd21f6..1a8a90f 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils"; import type { WarningPushSubscription } from "@/types/notifications"; import type { Province } from "@/types/province"; +import type { WeatherRegion } from "@/types/weather-region"; interface PushDatabase { database: Database.Database; @@ -13,6 +14,7 @@ interface PushSubscriptionRow { endpoint: string; subscription_json: string; province: string; + region?: string; language: string; enabled: number; morning_brief_enabled: number; @@ -20,6 +22,7 @@ interface PushSubscriptionRow { latitude: number | null; longitude: number | null; location_name: string | null; + timezone: string | null; county_teryt: string | null; temperature_unit: string | null; wind_speed_unit: string | null; @@ -50,7 +53,8 @@ function initializeDatabase(database: Database.Database) { CREATE TABLE IF NOT EXISTS push_subscriptions ( endpoint TEXT PRIMARY KEY, subscription_json TEXT NOT NULL, - province TEXT NOT NULL, + province TEXT NOT NULL DEFAULT 'global', + region TEXT NOT NULL DEFAULT 'PL', language TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, morning_brief_enabled INTEGER NOT NULL DEFAULT 0, @@ -58,6 +62,7 @@ function initializeDatabase(database: Database.Database) { latitude REAL, longitude REAL, location_name TEXT, + timezone TEXT, county_teryt TEXT, temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}', wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}', @@ -89,6 +94,13 @@ function initializeDatabase(database: Database.Database) { FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE ); `); + const columns = database.prepare("PRAGMA table_info(push_subscriptions)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "region")) { + database.prepare("ALTER TABLE push_subscriptions ADD COLUMN region TEXT NOT NULL DEFAULT 'PL'").run(); + } + if (!columns.some((column) => column.name === "timezone")) { + database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run(); + } } function getDatabase() { @@ -105,10 +117,12 @@ function getDatabase() { function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null { try { const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"]; + const region: WeatherRegion = row.region === "GLOBAL" || row.province === "global" ? "GLOBAL" : "PL"; return { endpoint: row.endpoint, subscription, - province: row.province as Province, + province: row.province === "global" ? null : row.province as Province, + region, language: row.language === "en" ? "en" : "pl", enabled: row.enabled === 1, morningBriefEnabled: row.morning_brief_enabled === 1, @@ -116,6 +130,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | latitude: row.latitude, longitude: row.longitude, locationName: row.location_name, + timezone: row.timezone, countyTeryt: row.county_teryt, temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT, @@ -133,6 +148,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { endpoint, subscription_json, province, + region, language, enabled, morning_brief_enabled, @@ -140,6 +156,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { latitude, longitude, location_name, + timezone, county_teryt, temperature_unit, wind_speed_unit, @@ -149,6 +166,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { @endpoint, @subscriptionJson, @province, + @region, @language, @enabled, @morningBriefEnabled, @@ -156,6 +174,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { @latitude, @longitude, @locationName, + @timezone, @countyTeryt, @temperatureUnit, @windSpeedUnit, @@ -165,6 +184,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { ON CONFLICT(endpoint) DO UPDATE SET subscription_json = excluded.subscription_json, province = excluded.province, + region = excluded.region, language = excluded.language, enabled = excluded.enabled, morning_brief_enabled = excluded.morning_brief_enabled, @@ -172,6 +192,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { latitude = excluded.latitude, longitude = excluded.longitude, location_name = excluded.location_name, + timezone = excluded.timezone, county_teryt = excluded.county_teryt, temperature_unit = excluded.temperature_unit, wind_speed_unit = excluded.wind_speed_unit, @@ -179,7 +200,8 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { `).run({ endpoint: subscription.endpoint, subscriptionJson: JSON.stringify(subscription.subscription), - province: subscription.province, + province: subscription.province ?? "global", + region: subscription.region, language: subscription.language, enabled: subscription.enabled ? 1 : 0, morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0, @@ -187,6 +209,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { latitude: subscription.latitude, longitude: subscription.longitude, locationName: subscription.locationName, + timezone: subscription.timezone, countyTeryt: subscription.countyTeryt, temperatureUnit: subscription.temperatureUnit, windSpeedUnit: subscription.windSpeedUnit, diff --git a/lib/server-current-weather.ts b/lib/server-current-weather.ts new file mode 100644 index 0000000..59d67d5 --- /dev/null +++ b/lib/server-current-weather.ts @@ -0,0 +1,113 @@ +import { normalizeImgwCurrentWeather } from "@/lib/imgw-current-api"; +import { toNumber } from "@/lib/weather-utils"; +import type { CurrentWeatherCondition, ImgwCurrentWeather, RawImgwHybridWeatherResponse } from "@/types/imgw-current"; +import type { WeatherRegion } from "@/types/weather-region"; + +const IMGW_HYBRID_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi"; +// This browser token is published by the official meteo.imgw.pl frontend. +const IMGW_HYBRID_TOKEN = "p4DXKjsYadfBV21TYrDk"; +const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"; + +interface RawOpenMeteoCurrentResponse { + current?: { + time?: string; + temperature_2m?: unknown; + apparent_temperature?: unknown; + relative_humidity_2m?: unknown; + precipitation?: unknown; + rain?: unknown; + showers?: unknown; + snowfall?: unknown; + weather_code?: unknown; + cloud_cover?: unknown; + pressure_msl?: unknown; + wind_speed_10m?: unknown; + wind_direction_10m?: unknown; + }; +} + +function getOpenMeteoCondition(weatherCode: number | null, rain: number | null, showers: number | null, snowfall: number | null): CurrentWeatherCondition { + if (weatherCode !== null && weatherCode >= 95) return "thunderstorm"; + if ((snowfall ?? 0) > 0 || (weatherCode !== null && weatherCode >= 71 && weatherCode <= 77)) return "snow"; + if ((rain ?? 0) > 0 || (showers ?? 0) > 0 || (weatherCode !== null && weatherCode >= 51 && weatherCode <= 67) || (weatherCode !== null && weatherCode >= 80 && weatherCode <= 82)) return "rain"; + return null; +} + +function normalizeOpenMeteoCurrent(payload: RawOpenMeteoCurrentResponse): ImgwCurrentWeather | null { + const current = payload.current; + if (!current?.time) return null; + const measuredAt = new Date(current.time).toISOString(); + const weatherCode = toNumber(current.weather_code); + const rain = toNumber(current.rain); + const showers = toNumber(current.showers); + const snowfall = toNumber(current.snowfall); + const precipitation = toNumber(current.precipitation); + + return { + coverage: "global-model", + region: "GLOBAL", + source: "OPEN_METEO", + sourceLabel: "Open-Meteo", + sourceType: "model", + isOfficial: false, + fetchedAt: new Date().toISOString(), + measuredAt, + temperature: toNumber(current.temperature_2m), + feelsLike: toNumber(current.apparent_temperature), + windSpeed: toNumber(current.wind_speed_10m), + windDirection: toNumber(current.wind_direction_10m), + humidity: toNumber(current.relative_humidity_2m), + pressure: toNumber(current.pressure_msl), + precipitation10m: precipitation, + rainfall10m: rain ?? showers ?? precipitation, + snowfall10m: snowfall, + cloudCover: toNumber(current.cloud_cover), + weatherCode, + condition: getOpenMeteoCondition(weatherCode, rain, showers, snowfall), + }; +} + +export async function fetchImgwHybridCurrentWeather(latitude: number, longitude: number) { + const params = new URLSearchParams({ + token: IMGW_HYBRID_TOKEN, + lat: String(latitude), + lon: String(longitude), + m: "hybrid", + }); + const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } }); + if (!response.ok) throw new Error("IMGW Hybrid service is unavailable."); + return await response.json() as RawImgwHybridWeatherResponse; +} + +export async function fetchServerCurrentWeather(latitude: number, longitude: number, region: WeatherRegion) { + if (region === "PL") { + return normalizeImgwCurrentWeather(await fetchImgwHybridCurrentWeather(latitude, longitude)); + } + + const params = new URLSearchParams({ + latitude: String(latitude), + longitude: String(longitude), + current: [ + "temperature_2m", + "apparent_temperature", + "relative_humidity_2m", + "precipitation", + "rain", + "showers", + "snowfall", + "weather_code", + "cloud_cover", + "pressure_msl", + "wind_speed_10m", + "wind_direction_10m", + ].join(","), + timezone: "auto", + wind_speed_unit: "ms", + precipitation_unit: "mm", + timeformat: "iso8601", + forecast_days: "1", + }); + const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, { next: { revalidate: 120 } }); + if (!response.ok) throw new Error("Open-Meteo current conditions are unavailable."); + return normalizeOpenMeteoCurrent(await response.json() as RawOpenMeteoCurrentResponse); +} diff --git a/lib/server-forecast.ts b/lib/server-forecast.ts index 91031a9..d2f9472 100644 --- a/lib/server-forecast.ts +++ b/lib/server-forecast.ts @@ -1,5 +1,6 @@ import { mergeForecastSources } from "@/lib/forecast-merge"; import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast"; +import type { WeatherRegion } from "@/types/weather-region"; const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"; const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi"; @@ -23,13 +24,13 @@ async function readImgwPayload(response: Response | null) { } } -export async function fetchServerForecast(latitude: number, longitude: number) { +export async function fetchServerForecast(latitude: number, longitude: number, region: WeatherRegion = "PL") { 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", + timezone: region === "PL" ? "Europe/Warsaw" : "auto", forecast_days: "7", wind_speed_unit: "ms", }); @@ -42,11 +43,13 @@ export async function fetchServerForecast(latitude: number, longitude: number) { 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), + region === "PL" + ? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null) + : Promise.resolve(null), ]); if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable."); const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast; const imgwPayload = await readImgwPayload(imgwResponse); - return mergeForecastSources(openMeteoPayload, imgwPayload); + return mergeForecastSources(openMeteoPayload, imgwPayload, region); } diff --git a/lib/store.ts b/lib/store.ts index 62085fd..9930ce2 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -6,6 +6,8 @@ import type { SelectedLocation } from "@/types/location"; import type { Province } from "@/types/province"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import type { WeatherRegion } from "@/types/weather-region"; +import { getWeatherRegionForCountryCode } from "@/types/weather-region"; type NotificationProvinceMode = "selected" | "manual"; @@ -61,6 +63,30 @@ export const useWeatherStore = create()( setTemperatureUnit: (unit) => set({ temperatureUnit: unit }), setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), }), - { name: "wtr:preferences" }, + { + name: "wtr:preferences", + migrate: (persisted) => { + const state = persisted as Partial | undefined; + const location = state?.selectedLocation as (Partial & { region?: WeatherRegion }) | null | undefined; + if (!state || !location) return persisted; + const countryCode = location.countryCode ?? (location.province ? "PL" : null); + const region = location.region ?? getWeatherRegionForCountryCode(countryCode); + return { + ...state, + selectedLocation: { + ...location, + country: location.country ?? (countryCode === "PL" ? "Polska" : null), + countryCode, + admin1: location.admin1 ?? location.province ?? null, + admin2: location.admin2 ?? location.district ?? null, + timezone: location.timezone ?? (countryCode === "PL" ? "Europe/Warsaw" : null), + region, + stationId: location.stationId ?? null, + stationName: location.stationName ?? null, + distanceKm: location.distanceKm ?? null, + }, + }; + }, + }, ), ); diff --git a/public/sw.js b/public/sw.js index e54f647..1fc45e5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "wtr-shell-v4"; +const CACHE_NAME = "wtr-shell-v5"; const SHELL = ["/", "/settings", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"]; self.addEventListener("install", (event) => { @@ -16,7 +16,7 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; const url = new URL(event.request.url); - if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") { + if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/current-weather" || url.pathname === "/api/forecast") { event.respondWith( fetch(event.request) .then((response) => { diff --git a/scripts/notification-worker.mjs b/scripts/notification-worker.mjs index 6f33cdc..e41d0c6 100644 --- a/scripts/notification-worker.mjs +++ b/scripts/notification-worker.mjs @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; const DEFAULT_APP_URL = "http://127.0.0.1:3000"; const DEFAULT_WARNING_INTERVAL_MINUTES = 5; +const DEFAULT_BRIEF_INTERVAL_MINUTES = 5; const DEFAULT_MORNING_BRIEF_TIME = "07:00"; const DEFAULT_TOMORROW_BRIEF_TIME = "18:00"; const LOOP_INTERVAL_MS = 30_000; @@ -13,12 +14,13 @@ loadEnvFile(".env.local", true); const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL); const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? ""; const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES); +const briefIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_BRIEF_INTERVAL_MINUTES, DEFAULT_BRIEF_INTERVAL_MINUTES); 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 lastMorningBriefDate = ""; -let lastTomorrowBriefDate = ""; +let lastMorningBriefCheckAt = 0; +let lastTomorrowBriefCheckAt = 0; let isRunningTick = false; function loadEnvFile(path, overrideLoadedValues) { @@ -50,27 +52,6 @@ function normalizeTime(value, fallback) { return /^\d{2}:\d{2}$/.test(value) ? value : fallback; } -function getWarsawDateParts(date = new Date()) { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Warsaw", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const part = (type) => parts.find((entry) => entry.type === type)?.value ?? ""; - return { - dateKey: `${part("year")}-${part("month")}-${part("day")}`, - time: `${part("hour")}:${part("minute")}`, - }; -} - -function isAtOrAfterTime(currentTime, targetTime) { - return currentTime >= targetTime; -} - async function callNotificationEndpoint(path) { const response = await fetch(`${appUrl}${path}`, { headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {}, @@ -106,6 +87,7 @@ async function tick() { isRunningTick = true; const now = Date.now(); const warningIntervalMs = warningIntervalMinutes * 60_000; + const briefIntervalMs = briefIntervalMinutes * 60_000; try { if (now - lastWarningCheckAt >= warningIntervalMs) { @@ -113,15 +95,14 @@ async function tick() { await callNotificationEndpointSafely("/api/notifications/check"); } - const warsawTime = getWarsawDateParts(); - if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) { - const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief"); - if (sentBrief) lastMorningBriefDate = warsawTime.dateKey; + if (now - lastMorningBriefCheckAt >= briefIntervalMs) { + lastMorningBriefCheckAt = now; + await callNotificationEndpointSafely("/api/notifications/daily-brief"); } - if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) { - const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief"); - if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey; + if (now - lastTomorrowBriefCheckAt >= briefIntervalMs) { + lastTomorrowBriefCheckAt = now; + await callNotificationEndpointSafely("/api/notifications/tomorrow-brief"); } } catch (error) { console.error(formatWorkerError(error)); @@ -131,6 +112,6 @@ async function tick() { } console.log(`wtr. notification worker: ${appUrl}`); -console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, tomorrow brief at ${tomorrowBriefTime} Europe/Warsaw.`); +console.log(`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`); void tick(); setInterval(tick, LOOP_INTERVAL_MS); diff --git a/types/forecast.ts b/types/forecast.ts index faf3a4b..0b95051 100644 --- a/types/forecast.ts +++ b/types/forecast.ts @@ -65,8 +65,12 @@ export interface DailyForecast { export interface WeatherForecast { latitude: number; longitude: number; + region: WeatherRegion; timezone: string; hourly: HourlyForecast[]; daily: DailyForecast[]; sources: ForecastSource[]; + capabilities: WeatherCapabilities; + unavailableReasons: string[]; } +import type { WeatherCapabilities, WeatherRegion } from "@/types/weather-region"; diff --git a/types/imgw-current.ts b/types/imgw-current.ts index 91284f4..a5d6f24 100644 --- a/types/imgw-current.ts +++ b/types/imgw-current.ts @@ -23,10 +23,11 @@ export interface RawImgwHybridWeatherResponse { } export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; -export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only"; +export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only" | "global-model"; -export interface ImgwCurrentWeather { +export interface ImgwCurrentWeather extends WeatherSourceMetadata { coverage: ImgwCurrentWeatherCoverage; + region: WeatherRegion; measuredAt: string; temperature: number | null; feelsLike: number | null; @@ -41,3 +42,4 @@ export interface ImgwCurrentWeather { weatherCode: number | null; condition: CurrentWeatherCondition; } +import type { WeatherRegion, WeatherSourceMetadata } from "@/types/weather-region"; diff --git a/types/location.ts b/types/location.ts index d57b3e7..5d22689 100644 --- a/types/location.ts +++ b/types/location.ts @@ -1,3 +1,5 @@ +import type { WeatherRegion } from "@/types/weather-region"; + export interface LocationSearchResult { id: number; name: string; @@ -5,22 +7,40 @@ export interface LocationSearchResult { longitude: number; province: string | null; district: string | null; + country: string | null; + countryCode: string | null; + admin1: string | null; + admin2: string | null; + timezone: string | null; + region: WeatherRegion; } export interface ReverseLocationResult { name: string; province: string | null; district: string | null; + country: string | null; + countryCode: string | null; + admin1: string | null; + admin2: string | null; + timezone: string | null; + region: WeatherRegion; } export interface SelectedLocation { name: string; province: string | null; district: string | null; + country: string | null; + countryCode: string | null; + admin1: string | null; + admin2: string | null; + timezone: string | null; + region: WeatherRegion; countyTeryt: string | null; latitude: number; longitude: number; - stationId: string; - stationName: string; - distanceKm: number; + stationId: string | null; + stationName: string | null; + distanceKm: number | null; } diff --git a/types/notifications.ts b/types/notifications.ts index 0ffd20e..abdead5 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -1,6 +1,7 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { WeatherRegion } from "@/types/weather-region"; export interface PushSubscriptionKeys { p256dh: string; @@ -16,7 +17,8 @@ export interface BrowserPushSubscription { export interface WarningPushSubscription { endpoint: string; subscription: BrowserPushSubscription; - province: Province; + province: Province | null; + region: WeatherRegion; language: Language; enabled: boolean; morningBriefEnabled: boolean; @@ -24,6 +26,7 @@ export interface WarningPushSubscription { latitude: number | null; longitude: number | null; locationName: string | null; + timezone: string | null; countyTeryt: string | null; temperatureUnit: TemperatureUnit; windSpeedUnit: WindSpeedUnit; diff --git a/types/weather-region.ts b/types/weather-region.ts new file mode 100644 index 0000000..cf2ddac --- /dev/null +++ b/types/weather-region.ts @@ -0,0 +1,53 @@ +export type WeatherRegion = "PL" | "GLOBAL"; + +export type WeatherSource = "IMGW" | "ALARO" | "OPEN_METEO"; + +export type WeatherSourceType = "observation" | "model" | "hybrid" | "official_warning"; + +export interface WeatherCapabilities { + current: boolean; + forecastHourly: boolean; + forecastDaily: boolean; + officialWarnings: boolean; + countyFiltering: boolean; + brief: boolean; + pushOfficialAlerts: boolean; +} + +export interface WeatherSourceMetadata { + source: WeatherSource; + sourceLabel: string; + sourceType: WeatherSourceType; + isOfficial: boolean; + fetchedAt?: string; + generatedAt?: string; +} + +export const WEATHER_CAPABILITIES: Record = { + PL: { + current: true, + forecastHourly: true, + forecastDaily: true, + officialWarnings: true, + countyFiltering: true, + brief: true, + pushOfficialAlerts: true, + }, + GLOBAL: { + current: true, + forecastHourly: true, + forecastDaily: true, + officialWarnings: false, + countyFiltering: false, + brief: true, + pushOfficialAlerts: false, + }, +}; + +export function getWeatherRegionForCountryCode(countryCode?: string | null): WeatherRegion { + return countryCode?.trim().toUpperCase() === "PL" ? "PL" : "GLOBAL"; +} + +export function getWeatherCapabilities(region?: WeatherRegion | null) { + return WEATHER_CAPABILITIES[region ?? "PL"]; +}
+ {t("settings.notifications.globalAlertsUnavailable")} +
{t("location.gpsPromptDescription")}
{t("location.gpsSecureContext")}
{t("location.preparing")}
{t("location.error")}
{t("location.empty")}
- {t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} + {selectedLocation.stationName && selectedLocation.distanceKm !== null + ? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm }) + : t("location.currentSourceGlobal", { location: selectedLocation.name })}
diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 710d25e..8e48563 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -42,18 +42,19 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null { return null; } -export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number }) { +export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) { const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const [devEffectOverride, setDevEffectOverride] = useState(null); const displayedLocationName = locationName ?? station.name; + const hasGlobalModel = currentWeather?.coverage === "global-model"; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; - const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; + const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30; const displayedStation = currentWeather ? { ...station, - measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt, + measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt, temperature: currentWeather.temperature ?? station.temperature, windSpeed: currentWeather.windSpeed ?? station.windSpeed, windDirection: currentWeather.windDirection ?? station.windDirection, @@ -67,7 +68,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f const metrics = [ { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) }, - { icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, + { icon: Umbrella, label: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, ]; const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm" @@ -116,6 +117,8 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f {currentWeatherLoading ? t("location.heroHybridLoading", { station: station.name }) + : hasGlobalModel + ? t("location.heroGlobalModelSource", { location: displayedLocationName }) : hasFullHybridAnalysis ? t("location.heroHybridSource", { location: displayedLocationName }) : hasPartialHybridAnalysis @@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f {formatTemperature(displayedStation.temperature, language, temperatureUnit)} {weatherDescription} - {t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)} + {t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}
{currentWeatherLoading ? t("location.heroHybridLoading", { station: station.name }) + : hasGlobalModel + ? t("location.heroGlobalModelSource", { location: displayedLocationName }) : hasFullHybridAnalysis ? t("location.heroHybridSource", { location: displayedLocationName }) : hasPartialHybridAnalysis @@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f {formatTemperature(displayedStation.temperature, language, temperatureUnit)}
{weatherDescription}
{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}
{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}