feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
This commit is contained in:
@@ -5,5 +5,6 @@ NOTIFICATIONS_CRON_SECRET=
|
|||||||
WTR_APP_URL=http://127.0.0.1:3000
|
WTR_APP_URL=http://127.0.0.1:3000
|
||||||
WTR_DATABASE_PATH=./data/wtr.sqlite
|
WTR_DATABASE_PATH=./data/wtr.sqlite
|
||||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||||
|
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Projekt
|
## 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.
|
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/`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
- HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera.
|
||||||
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
||||||
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
||||||
- Brief dnia 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.
|
- 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.
|
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
||||||
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
||||||
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -1,15 +1,15 @@
|
|||||||
# wtr.
|
# 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
|
## Najważniejsze funkcje
|
||||||
|
|
||||||
- Bieżące warunki z lokalnej analizy IMGW Hybrid, z opisanym fallbackiem do stacji synoptycznej.
|
- Bieżące warunki: IMGW Hybrid w Polsce, Open-Meteo jako modelowe warunki bieżące poza Polską.
|
||||||
- Prognoza modelowa 7 dni: IMGW ALARO dla dostępnych godzin oraz Open-Meteo jako uzupełnienie i fallback.
|
- Prognoza modelowa 7 dni: IMGW ALARO dla Polski oraz Open-Meteo jako globalny provider i fallback.
|
||||||
- Wyszukiwanie miejscowości w Polsce oraz opcjonalny wybór lokalizacji GPS.
|
- Globalne wyszukiwanie miejscowości oraz opcjonalny wybór lokalizacji GPS.
|
||||||
- Ostrzeżenia IMGW z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza.
|
- 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.
|
- 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ń.
|
- Widoki dashboardu, prognozy szczegółowej dnia, ostrzeżeń, hydro, stacji i ustawień.
|
||||||
- PWA z manifestem, własnym service workerem i podstawowym fallbackiem offline.
|
- 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_APP_URL=http://127.0.0.1:3000
|
||||||
WTR_DATABASE_PATH=./data/wtr.sqlite
|
WTR_DATABASE_PATH=./data/wtr.sqlite
|
||||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||||
|
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||||
```
|
```
|
||||||
@@ -111,6 +112,7 @@ docs/ szczegółowa dokumentacja techniczna
|
|||||||
## Status i Ograniczenia
|
## 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.
|
- `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`.
|
- 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ń.
|
- 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.
|
||||||
|
|||||||
31
app/api/current-weather/route.ts
Normal file
31
app/api/current-weather/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
|
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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
|
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
|
||||||
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
|
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
|
||||||
|
const region = parseRegion(searchParams.get("region"));
|
||||||
if (latitude === null || longitude === null) {
|
if (latitude === null || longitude === null) {
|
||||||
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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" },
|
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||||
|
|
||||||
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
|
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
|
||||||
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
|
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
|
||||||
@@ -13,6 +14,8 @@ interface RawReverseLocation {
|
|||||||
municipality?: string;
|
municipality?: string;
|
||||||
county?: string;
|
county?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +57,17 @@ export async function GET(request: Request) {
|
|||||||
?? data.name
|
?? data.name
|
||||||
?? data.display_name?.split(",")[0]?.trim();
|
?? data.display_name?.split(",")[0]?.trim();
|
||||||
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
|
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
|
||||||
|
const countryCode = data.address?.country_code?.toUpperCase() ?? null;
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name,
|
name,
|
||||||
province: data.address?.state ?? null,
|
province: data.address?.state ?? null,
|
||||||
district: data.address?.county ?? 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" },
|
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||||
|
|
||||||
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
||||||
|
|
||||||
@@ -7,8 +8,11 @@ interface RawLocation {
|
|||||||
name?: string;
|
name?: string;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
admin1?: string;
|
admin1?: string;
|
||||||
admin2?: string;
|
admin2?: string;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -19,10 +23,9 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
name: query,
|
name: query,
|
||||||
count: "8",
|
count: "10",
|
||||||
language,
|
language,
|
||||||
format: "json",
|
format: "json",
|
||||||
countryCode: "PL",
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
|
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 data = await response.json() as { results?: RawLocation[] };
|
||||||
const results = (data.results ?? []).flatMap((location) => {
|
const results = (data.results ?? []).flatMap((location) => {
|
||||||
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
|
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
|
||||||
|
const countryCode = location.country_code?.toUpperCase() ?? null;
|
||||||
return [{
|
return [{
|
||||||
id: location.id,
|
id: location.id,
|
||||||
name: location.name,
|
name: location.name,
|
||||||
@@ -37,6 +41,12 @@ export async function GET(request: Request) {
|
|||||||
longitude: location.longitude as number,
|
longitude: location.longitude as number,
|
||||||
province: location.admin1 ?? null,
|
province: location.admin1 ?? null,
|
||||||
district: location.admin2 ?? 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" } });
|
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, 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));
|
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -51,7 +51,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
const matchingWarnings = warnings.filter((warning) => (
|
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) {
|
for (const warning of matchingWarnings) {
|
||||||
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
||||||
|
|||||||
@@ -16,15 +16,27 @@ function isCronAuthorized(request: Request) {
|
|||||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
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", {
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "Europe/Warsaw",
|
timeZone: timeZone || "Europe/Warsaw",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
}).formatToParts(date);
|
}).formatToParts(date);
|
||||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
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) {
|
export async function GET(request: Request) {
|
||||||
@@ -36,35 +48,43 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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) => (
|
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||||
subscription.enabled
|
subscription.morningBriefEnabled
|
||||||
&& subscription.morningBriefEnabled
|
|
||||||
&& Number.isFinite(subscription.latitude)
|
&& Number.isFinite(subscription.latitude)
|
||||||
&& Number.isFinite(subscription.longitude)
|
&& Number.isFinite(subscription.longitude)
|
||||||
));
|
));
|
||||||
let warningsUnavailable = false;
|
let warningsUnavailable = false;
|
||||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||||
warningsUnavailable = true;
|
const warnings = hasPolishSubscriptions
|
||||||
return [];
|
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||||
});
|
warningsUnavailable = true;
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
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)) {
|
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const brief = buildWeatherBrief({
|
||||||
forecast,
|
forecast,
|
||||||
warnings,
|
warnings: subscription.region === "PL" ? warnings : [],
|
||||||
province: subscription.province,
|
province: subscription.province,
|
||||||
countyTeryt: subscription.countyTeryt,
|
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||||
locationName: subscription.locationName ?? "wtr.",
|
locationName: subscription.locationName ?? "wtr.",
|
||||||
language: subscription.language,
|
language: subscription.language,
|
||||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||||
@@ -87,7 +107,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
date: dateKey,
|
time: briefTime,
|
||||||
subscriptions: subscriptions.length,
|
subscriptions: subscriptions.length,
|
||||||
warningsUnavailable,
|
warningsUnavailable,
|
||||||
sent,
|
sent,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeProvinceName } from "@/lib/provinces";
|
|||||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { BrowserPushSubscription } from "@/types/notifications";
|
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json().catch(() => null) as {
|
const body = await request.json().catch(() => null) as {
|
||||||
subscription?: unknown;
|
subscription?: unknown;
|
||||||
province?: unknown;
|
province?: unknown;
|
||||||
|
region?: unknown;
|
||||||
language?: unknown;
|
language?: unknown;
|
||||||
enabled?: unknown;
|
enabled?: unknown;
|
||||||
morningBriefEnabled?: unknown;
|
morningBriefEnabled?: unknown;
|
||||||
@@ -32,17 +34,20 @@ export async function POST(request: Request) {
|
|||||||
latitude?: unknown;
|
latitude?: unknown;
|
||||||
longitude?: unknown;
|
longitude?: unknown;
|
||||||
locationName?: unknown;
|
locationName?: unknown;
|
||||||
|
timezone?: unknown;
|
||||||
countyTeryt?: unknown;
|
countyTeryt?: unknown;
|
||||||
temperatureUnit?: unknown;
|
temperatureUnit?: unknown;
|
||||||
windSpeedUnit?: unknown;
|
windSpeedUnit?: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
const subscription = body?.subscription;
|
const subscription = body?.subscription;
|
||||||
|
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
|
||||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||||
const language: Language = body?.language === "en" ? "en" : "pl";
|
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||||
const rawTemperatureUnit = body?.temperatureUnit;
|
const rawTemperatureUnit = body?.temperatureUnit;
|
||||||
const rawWindSpeedUnit = body?.windSpeedUnit;
|
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 });
|
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +56,15 @@ export async function POST(request: Request) {
|
|||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
subscription,
|
subscription,
|
||||||
province,
|
province,
|
||||||
|
region,
|
||||||
language,
|
language,
|
||||||
enabled: body?.enabled !== false,
|
enabled,
|
||||||
morningBriefEnabled: body?.morningBriefEnabled === true,
|
morningBriefEnabled: body?.morningBriefEnabled === true,
|
||||||
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
|
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
|
||||||
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
|
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
|
||||||
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
||||||
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||||
|
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,
|
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
|
||||||
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
|
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
|
||||||
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
||||||
|
|||||||
@@ -16,25 +16,36 @@ function isCronAuthorized(request: Request) {
|
|||||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
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", {
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "Europe/Warsaw",
|
timeZone: timeZone || "Europe/Warsaw",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
}).formatToParts(date);
|
}).formatToParts(date);
|
||||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
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 shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
|
||||||
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "Europe/Warsaw",
|
timeZone: timeZone || "Europe/Warsaw",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
}).formatToParts(shiftedDate);
|
}).formatToParts(shiftedDate);
|
||||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
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) {
|
export async function GET(request: Request) {
|
||||||
@@ -46,35 +57,43 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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) => (
|
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||||
subscription.enabled
|
subscription.tomorrowBriefEnabled
|
||||||
&& subscription.tomorrowBriefEnabled
|
|
||||||
&& Number.isFinite(subscription.latitude)
|
&& Number.isFinite(subscription.latitude)
|
||||||
&& Number.isFinite(subscription.longitude)
|
&& Number.isFinite(subscription.longitude)
|
||||||
));
|
));
|
||||||
let warningsUnavailable = false;
|
let warningsUnavailable = false;
|
||||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||||
warningsUnavailable = true;
|
const warnings = hasPolishSubscriptions
|
||||||
return [];
|
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||||
});
|
warningsUnavailable = true;
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
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)) {
|
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const brief = buildTomorrowWeatherBrief({
|
||||||
forecast,
|
forecast,
|
||||||
warnings,
|
warnings: subscription.region === "PL" ? warnings : [],
|
||||||
province: subscription.province,
|
province: subscription.province,
|
||||||
countyTeryt: subscription.countyTeryt,
|
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||||
locationName: subscription.locationName ?? "wtr.",
|
locationName: subscription.locationName ?? "wtr.",
|
||||||
language: subscription.language,
|
language: subscription.language,
|
||||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||||
@@ -97,7 +116,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
date: targetDateKey,
|
time: briefTime,
|
||||||
subscriptions: subscriptions.length,
|
subscriptions: subscriptions.length,
|
||||||
warningsUnavailable,
|
warningsUnavailable,
|
||||||
sent,
|
sent,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getUpcomingHourlyForecast } from "@/lib/forecast-utils";
|
|||||||
import { locateSynopStations } from "@/lib/location-utils";
|
import { locateSynopStations } from "@/lib/location-utils";
|
||||||
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||||
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -28,7 +29,8 @@ export function DashboardPage() {
|
|||||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||||
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||||
?? stations?.[0];
|
?? stations?.[0];
|
||||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
const activeLocation = selectedLocation;
|
||||||
|
const activeRegion = activeLocation?.region ?? "PL";
|
||||||
const stationPosition = selectedStation
|
const stationPosition = selectedStation
|
||||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||||
: null;
|
: null;
|
||||||
@@ -36,20 +38,32 @@ export function DashboardPage() {
|
|||||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
||||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude);
|
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude, activeRegion);
|
||||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude);
|
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion);
|
||||||
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null;
|
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null;
|
||||||
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => 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 (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<LocationSearch stations={stations} positions={positions} />
|
<LocationSearch stations={stations} positions={positions} />
|
||||||
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
<WeatherHero station={heroStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||||
<DashboardWarnings />
|
<DashboardWarnings />
|
||||||
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />
|
||||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />
|
||||||
<FavoritesSection stations={stations} />
|
<FavoritesSection stations={stations} />
|
||||||
<FeaturedStationsSection stations={stations} />
|
<FeaturedStationsSection stations={stations} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,25 +13,28 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { getProvinceForSelection } from "@/lib/provinces";
|
import { getProvinceForSelection } from "@/lib/provinces";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
|
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 { 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 { data: warnings = [] } = useWarnings();
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
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(() => {
|
const brief = useMemo(() => {
|
||||||
if (!forecast) return null;
|
if (!forecast) return null;
|
||||||
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||||
const tomorrowBrief = useMemo(() => {
|
const tomorrowBrief = useMemo(() => {
|
||||||
if (!forecast) return null;
|
if (!forecast) return null;
|
||||||
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||||
|
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
||||||
return <LoadingSkeleton className="h-48" />;
|
return <LoadingSkeleton className="h-48" />;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/lib/forecast-utils";
|
} from "@/lib/forecast-utils";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
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 { language, t } = useI18n();
|
||||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
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<DailyForecast | null>(null);
|
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
||||||
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
||||||
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
|
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
|
||||||
|
|||||||
@@ -142,17 +142,22 @@ export function SettingsPage() {
|
|||||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||||
?? stations?.[0];
|
?? stations?.[0];
|
||||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
const activeLocation = selectedLocation;
|
||||||
|
const selectedRegion = activeLocation?.region ?? "PL";
|
||||||
const stationPosition = selectedStation
|
const stationPosition = selectedStation
|
||||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||||
: null;
|
: null;
|
||||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||||
const notificationCountyTeryt = activeLocation?.countyTeryt ?? null;
|
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
||||||
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null;
|
||||||
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null;
|
||||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
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";
|
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,7 +178,7 @@ export function SettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
|
if (!notificationsEnabled || !vapidPublicKey || notificationStatus !== "granted") return;
|
||||||
const province = effectiveProvince;
|
const province = effectiveProvince;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -182,11 +187,14 @@ export function SettingsPage() {
|
|||||||
const subscription = await registration?.pushManager.getSubscription();
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
if (!subscription || cancelled) return;
|
if (!subscription || cancelled) return;
|
||||||
await savePushSubscription(subscription, province, language, {
|
await savePushSubscription(subscription, province, language, {
|
||||||
|
region: selectedRegion,
|
||||||
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||||
morningBriefEnabled,
|
morningBriefEnabled,
|
||||||
tomorrowBriefEnabled,
|
tomorrowBriefEnabled,
|
||||||
latitude: notificationLatitude,
|
latitude: notificationLatitude,
|
||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
|
timezone: notificationTimezone,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
temperatureUnit,
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
@@ -197,7 +205,7 @@ export function SettingsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!provinceDropdownOpen) return;
|
if (!provinceDropdownOpen) return;
|
||||||
@@ -229,7 +237,7 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [notificationStatus, t]);
|
}, [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() {
|
async function getServiceWorkerRegistration() {
|
||||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||||
@@ -237,7 +245,7 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enableWarningNotifications() {
|
async function enableWarningNotifications() {
|
||||||
if (!vapidPublicKey || !effectiveProvince) {
|
if (!vapidPublicKey || (selectedRegion === "PL" && !effectiveProvince)) {
|
||||||
setNotificationMessage(t("settings.notifications.saveError"));
|
setNotificationMessage(t("settings.notifications.saveError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,11 +269,14 @@ export function SettingsPage() {
|
|||||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||||
});
|
});
|
||||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||||
|
region: selectedRegion,
|
||||||
|
officialWarningsEnabled: canUseOfficialWarnings,
|
||||||
morningBriefEnabled,
|
morningBriefEnabled,
|
||||||
tomorrowBriefEnabled,
|
tomorrowBriefEnabled,
|
||||||
latitude: notificationLatitude,
|
latitude: notificationLatitude,
|
||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
|
timezone: notificationTimezone,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
temperatureUnit,
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
@@ -319,17 +330,20 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
async function updateMorningBriefPreference(enabled: boolean) {
|
async function updateMorningBriefPreference(enabled: boolean) {
|
||||||
setMorningBriefEnabled(enabled);
|
setMorningBriefEnabled(enabled);
|
||||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker?.getRegistration();
|
const registration = await navigator.serviceWorker?.getRegistration();
|
||||||
const subscription = await registration?.pushManager.getSubscription();
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
if (!subscription) return;
|
if (!subscription) return;
|
||||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||||
|
region: selectedRegion,
|
||||||
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||||
morningBriefEnabled: enabled,
|
morningBriefEnabled: enabled,
|
||||||
tomorrowBriefEnabled,
|
tomorrowBriefEnabled,
|
||||||
latitude: notificationLatitude,
|
latitude: notificationLatitude,
|
||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
|
timezone: notificationTimezone,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
temperatureUnit,
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
@@ -341,17 +355,20 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
async function updateTomorrowBriefPreference(enabled: boolean) {
|
async function updateTomorrowBriefPreference(enabled: boolean) {
|
||||||
setTomorrowBriefEnabled(enabled);
|
setTomorrowBriefEnabled(enabled);
|
||||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker?.getRegistration();
|
const registration = await navigator.serviceWorker?.getRegistration();
|
||||||
const subscription = await registration?.pushManager.getSubscription();
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
if (!subscription) return;
|
if (!subscription) return;
|
||||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||||
|
region: selectedRegion,
|
||||||
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||||
morningBriefEnabled,
|
morningBriefEnabled,
|
||||||
tomorrowBriefEnabled: enabled,
|
tomorrowBriefEnabled: enabled,
|
||||||
latitude: notificationLatitude,
|
latitude: notificationLatitude,
|
||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
|
timezone: notificationTimezone,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
temperatureUnit,
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
@@ -446,6 +463,7 @@ export function SettingsPage() {
|
|||||||
type="radio"
|
type="radio"
|
||||||
name="warning-notification-province-mode"
|
name="warning-notification-province-mode"
|
||||||
checked={provinceMode === "selected"}
|
checked={provinceMode === "selected"}
|
||||||
|
disabled={selectedRegion === "GLOBAL"}
|
||||||
onChange={() => setProvinceMode("selected")}
|
onChange={() => setProvinceMode("selected")}
|
||||||
className="mt-1 accent-accent"
|
className="mt-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
@@ -459,6 +477,7 @@ export function SettingsPage() {
|
|||||||
type="radio"
|
type="radio"
|
||||||
name="warning-notification-province-mode"
|
name="warning-notification-province-mode"
|
||||||
checked={provinceMode === "manual"}
|
checked={provinceMode === "manual"}
|
||||||
|
disabled={selectedRegion === "GLOBAL"}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
setProvinceMode("manual");
|
setProvinceMode("manual");
|
||||||
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
||||||
@@ -473,7 +492,7 @@ export function SettingsPage() {
|
|||||||
aria-label={t("settings.notifications.regionManual")}
|
aria-label={t("settings.notifications.regionManual")}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={provinceDropdownOpen}
|
aria-expanded={provinceDropdownOpen}
|
||||||
disabled={provinceMode !== "manual"}
|
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
|
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -514,6 +533,11 @@ export function SettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{selectedRegion === "GLOBAL" && (
|
||||||
|
<p className="rounded-card border border-border/60 bg-surface px-3 py-3 text-xs leading-5 text-muted">
|
||||||
|
{t("settings.notifications.globalAlertsUnavailable")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -541,9 +565,9 @@ export function SettingsPage() {
|
|||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="flex items-center gap-2 font-medium">
|
<span className="flex items-center gap-2 font-medium">
|
||||||
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
||||||
{t("settings.notifications.enable")}
|
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
|
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")}</span>
|
||||||
<span className="mt-2 block text-xs font-semibold text-accent">
|
<span className="mt-2 block text-xs font-semibold text-accent">
|
||||||
{isSavingSubscription
|
{isSavingSubscription
|
||||||
? t("settings.notifications.saving")
|
? t("settings.notifications.saving")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function DashboardWarnings() {
|
|||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||||
const [now, setNow] = useState<number | null>(null);
|
const [now, setNow] = useState<number | null>(null);
|
||||||
|
const isGlobalLocation = selectedLocation?.region === "GLOBAL";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => setNow(Date.now()), 0);
|
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 regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null;
|
||||||
const relevantWarnings = useMemo(() => {
|
const relevantWarnings = useMemo(() => {
|
||||||
if (!warnings || !province || now === null) return [];
|
if (isGlobalLocation || !warnings || !province || now === null) return [];
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
.filter((warning) => {
|
.filter((warning) => {
|
||||||
@@ -65,7 +66,7 @@ export function DashboardWarnings() {
|
|||||||
&& (validTo === null || validTo > now);
|
&& (validTo === null || validTo > now);
|
||||||
})
|
})
|
||||||
.sort((a, b) => compareDashboardWarnings(a, b, 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;
|
if (!province || !regionLabel || !relevantWarnings.length || now === null) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export function WarningsPanel() {
|
|||||||
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||||
|
if (selectedLocation?.region === "GLOBAL") {
|
||||||
|
return <EmptyState title={t("warnings.globalUnavailableTitle")} description={t("warnings.globalUnavailableDescription")} />;
|
||||||
|
}
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
||||||
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { fetchReverseLocation } from "@/lib/location-api";
|
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";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
|
||||||
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
|
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
|
||||||
@@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
setMessage(t("location.gpsUnavailable"));
|
setMessage(t("location.gpsUnavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!stations.length) {
|
|
||||||
setMessage(t("location.gpsStationsPending"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLocating(true);
|
setIsLocating(true);
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
async ({ coords }) => {
|
async ({ coords }) => {
|
||||||
@@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
name: t("location.gpsFallbackName"),
|
name: t("location.gpsFallbackName"),
|
||||||
province: null,
|
province: null,
|
||||||
district: null,
|
district: null,
|
||||||
|
country: null,
|
||||||
|
countryCode: null,
|
||||||
|
admin1: null,
|
||||||
|
admin2: null,
|
||||||
|
timezone: null,
|
||||||
|
region: "GLOBAL" as const,
|
||||||
}));
|
}));
|
||||||
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations);
|
const selected = createSelectedLocation({ ...place, latitude, longitude }, stations);
|
||||||
if (!nearest) {
|
selectLocation(selected);
|
||||||
setMessage(t("location.gpsStationsPending"));
|
setMessage(t("location.gpsSelected", { location: selected.name }));
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectLocation(nearest);
|
|
||||||
setMessage(t("location.gpsSelected", { location: nearest.name }));
|
|
||||||
} catch {
|
} catch {
|
||||||
setMessage(t("location.gpsPositionUnavailable"));
|
setMessage(t("location.gpsPositionUnavailable"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,13 +86,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
|
||||||
if (permission.state !== "granted" || autoLocated.current) return;
|
if (permission.state !== "granted" || autoLocated.current) return;
|
||||||
autoLocated.current = true;
|
autoLocated.current = true;
|
||||||
locate();
|
locate();
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}, [locate, stations.length]);
|
}, [locate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
@@ -108,7 +105,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
|
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
|
||||||
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
|
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
|
<Button type="button" onClick={locate} disabled={isLocating}>
|
||||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
|
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,7 +117,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
)}
|
)}
|
||||||
{!showPrompt && (
|
{!showPrompt && (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}>
|
<Button type="button" variant="glass" onClick={locate} disabled={isLocating}>
|
||||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||||
import { LoaderCircle, MapPin, Search, X } from "lucide-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 { useLocationSearch } from "@/hooks/use-location-search";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
|
||||||
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
|
||||||
import { useI18n } from "@/lib/i18n";
|
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 { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||||
import type { SelectedLocation } from "@/types/location";
|
import type { SelectedLocation } from "@/types/location";
|
||||||
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
|
||||||
|
|
||||||
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
@@ -20,10 +20,10 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
||||||
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
||||||
location,
|
location,
|
||||||
nearest: findNearestSynopStation(location, locatedStations),
|
selected: createSelectedLocation(location, locatedStations),
|
||||||
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
|
})), [locatedStations, locations]);
|
||||||
const showSuggestions = isFocused && query.trim().length >= 2;
|
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) {
|
function chooseLocation(location: SelectedLocation) {
|
||||||
selectLocation(location);
|
selectLocation(location);
|
||||||
@@ -32,7 +32,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
const firstSuggestion = suggestions[0]?.nearest;
|
const firstSuggestion = suggestions[0]?.selected;
|
||||||
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
|
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
chooseLocation(firstSuggestion);
|
chooseLocation(firstSuggestion);
|
||||||
@@ -66,18 +66,22 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
|
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
|
||||||
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
|
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
|
||||||
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
||||||
suggestions.map(({ location, nearest }) => nearest && (
|
suggestions.map(({ location, selected }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={location.id}
|
key={location.id}
|
||||||
onClick={() => 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"
|
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"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-sm font-semibold">{location.name}</span>
|
<span className="block text-sm font-semibold">{location.name}</span>
|
||||||
<span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
<span className="mt-0.5 block text-xs text-muted">{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
|
{selected.stationName && selected.distanceKm !== null ? (
|
||||||
|
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{selected.stationName} · {selected.distanceKm} km</strong></span>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +90,9 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
<CurrentLocationControl stations={locatedStations} />
|
<CurrentLocationControl stations={locatedStations} />
|
||||||
{selectedLocation && (
|
{selectedLocation && (
|
||||||
<p className="mt-3 px-1 text-xs text-muted">
|
<p className="mt-3 px-1 text-xs text-muted">
|
||||||
{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 })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 px-1 text-[0.68rem] text-muted">
|
<p className="mt-3 px-1 text-[0.68rem] text-muted">
|
||||||
|
|||||||
@@ -42,18 +42,19 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
|
|||||||
return 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 { language, t } = useI18n();
|
||||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
||||||
const displayedLocationName = locationName ?? station.name;
|
const displayedLocationName = locationName ?? station.name;
|
||||||
|
const hasGlobalModel = currentWeather?.coverage === "global-model";
|
||||||
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
||||||
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
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 ? {
|
const displayedStation = currentWeather ? {
|
||||||
...station,
|
...station,
|
||||||
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
|
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
|
||||||
temperature: currentWeather.temperature ?? station.temperature,
|
temperature: currentWeather.temperature ?? station.temperature,
|
||||||
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
||||||
windDirection: currentWeather.windDirection ?? station.windDirection,
|
windDirection: currentWeather.windDirection ?? station.windDirection,
|
||||||
@@ -67,7 +68,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
|||||||
const metrics = [
|
const metrics = [
|
||||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
|
{ 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: 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) },
|
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
|
||||||
];
|
];
|
||||||
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
||||||
@@ -116,6 +117,8 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
|||||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
<p>{currentWeatherLoading
|
<p>{currentWeatherLoading
|
||||||
? t("location.heroHybridLoading", { station: station.name })
|
? t("location.heroHybridLoading", { station: station.name })
|
||||||
|
: hasGlobalModel
|
||||||
|
? t("location.heroGlobalModelSource", { location: displayedLocationName })
|
||||||
: hasFullHybridAnalysis
|
: hasFullHybridAnalysis
|
||||||
? t("location.heroHybridSource", { location: displayedLocationName })
|
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||||
: hasPartialHybridAnalysis
|
: hasPartialHybridAnalysis
|
||||||
@@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
|||||||
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
|
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
|
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
|
||||||
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
||||||
</div>
|
</div>
|
||||||
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
|
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
|
|||||||
|
|
||||||
| Metoda | Endpoint | Przeznaczenie |
|
| 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-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/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. |
|
| `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
|
## Powiadomienia
|
||||||
@@ -19,7 +20,7 @@ Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push`
|
|||||||
| Metoda | Endpoint | Przeznaczenie |
|
| Metoda | Endpoint | Przeznaczenie |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. |
|
| `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. |
|
| `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. |
|
||||||
| `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. |
|
| `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. |
|
| `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 |
|
| Endpoint | Cache |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` |
|
| `/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-current` | `s-maxage=120`, `stale-while-revalidate=300` |
|
||||||
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
|
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
|
||||||
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Service worker:
|
|||||||
|
|
||||||
- cache'uje powłokę aplikacji,
|
- cache'uje powłokę aplikacji,
|
||||||
- obsługuje fallback `/offline` dla nawigacji,
|
- 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.
|
- obsługuje zdarzenia `push` i kliknięcia w powiadomienia.
|
||||||
|
|
||||||
Rejestracja service workera działa w buildzie produkcyjnym.
|
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
|
## Zasady Integracji
|
||||||
|
|
||||||
- Dane zewnętrzne przechodzą przez route handlery Next.js.
|
- Dane zewnętrzne przechodzą przez route handlery Next.js.
|
||||||
- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń.
|
- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń dla Polski.
|
||||||
- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW.
|
- 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.
|
- Braki danych są pokazywane jawnie, bez generowania fikcyjnych wartości.
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|
||||||
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
|
## Nominatim / OpenStreetMap
|
||||||
|
|
||||||
@@ -43,12 +57,13 @@ Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użyc
|
|||||||
| Dane | Cache |
|
| Dane | Cache |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` |
|
| 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` |
|
| Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` |
|
||||||
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
|
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
|
||||||
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
|
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||||
| Reverse geocoding | 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ł
|
## Ograniczenia Źródeł
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Powiadomienia obejmują:
|
Powiadomienia obejmują:
|
||||||
|
|
||||||
- nowe ostrzeżenia meteorologiczne IMGW,
|
- nowe ostrzeżenia meteorologiczne IMGW dla Polski,
|
||||||
- poranny brief dnia,
|
- poranny brief dnia,
|
||||||
- wieczorny brief z prognozą na jutro,
|
- wieczorny brief z prognozą na jutro,
|
||||||
- testowe powiadomienie wysyłane z `/settings`.
|
- 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:
|
Subskrypcja Web Push zapisuje:
|
||||||
|
|
||||||
- endpoint subskrypcji przeglądarki,
|
- endpoint subskrypcji przeglądarki,
|
||||||
- województwo,
|
- region pogodowy `PL` albo `GLOBAL`,
|
||||||
|
- województwo dla oficjalnych ostrzeżeń IMGW w Polsce,
|
||||||
- język interfejsu,
|
- język interfejsu,
|
||||||
- czy ostrzeżenia są aktywne,
|
- czy ostrzeżenia są aktywne,
|
||||||
- czy aktywny jest brief poranny,
|
- 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.
|
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 Self-Hosted
|
||||||
|
|
||||||
Worker powiadomień uruchamia się osobno od aplikacji:
|
Worker powiadomień uruchamia się osobno od aplikacji:
|
||||||
@@ -84,6 +87,7 @@ Domyślna konfiguracja:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||||
|
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||||
```
|
```
|
||||||
@@ -91,8 +95,9 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
|||||||
Worker:
|
Worker:
|
||||||
|
|
||||||
- sprawdza nowe ostrzeżenia co 5 minut,
|
- sprawdza nowe ostrzeżenia co 5 minut,
|
||||||
- po 07:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/daily-brief`,
|
- co 5 minut odpytuje endpointy briefów,
|
||||||
- po 18:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/tomorrow-brief`,
|
- 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.
|
- nie blokuje jednego harmonogramu błędem drugiego.
|
||||||
|
|
||||||
Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona.
|
Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona.
|
||||||
|
|||||||
@@ -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.
|
`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
|
## 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`:
|
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
|
## Prognoza Modelowa
|
||||||
|
|
||||||
Route handler `/api/forecast` pobiera równolegle:
|
Route handler `/api/forecast` dla `PL` pobiera równolegle:
|
||||||
|
|
||||||
- pełne 7 dni Open-Meteo,
|
- pełne 7 dni Open-Meteo,
|
||||||
- godzinowe IMGW ALARO.
|
- 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.
|
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:
|
Dashboard pokazuje:
|
||||||
|
|
||||||
- regułowy brief dnia,
|
- 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 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:
|
Zasady UI:
|
||||||
|
|
||||||
- lokalny obszar jest priorytetyzowany,
|
- 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.
|
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
|
## Opad i Pomiary Synoptyczne
|
||||||
|
|
||||||
Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu.
|
Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu.
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { QUERY_GC_TIME } from "@/lib/constants";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000;
|
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({
|
return useQuery({
|
||||||
queryKey: ["imgw-current-weather", latitude, longitude],
|
queryKey: ["current-weather", region, latitude, longitude],
|
||||||
queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal),
|
queryFn: ({ signal }) => fetchCurrentWeather(latitude as number, longitude as number, region, signal),
|
||||||
staleTime: CURRENT_WEATHER_STALE_TIME,
|
staleTime: CURRENT_WEATHER_STALE_TIME,
|
||||||
gcTime: QUERY_GC_TIME,
|
gcTime: QUERY_GC_TIME,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchForecast } from "@/lib/forecast-api";
|
import { fetchForecast } from "@/lib/forecast-api";
|
||||||
import { QUERY_GC_TIME } from "@/lib/constants";
|
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({
|
return useQuery({
|
||||||
queryKey: ["forecast", latitude, longitude],
|
queryKey: ["forecast", region, latitude, longitude],
|
||||||
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal),
|
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, region, signal),
|
||||||
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
|
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
|
||||||
staleTime: 15 * 60 * 1000,
|
staleTime: 15 * 60 * 1000,
|
||||||
gcTime: QUERY_GC_TIME,
|
gcTime: QUERY_GC_TIME,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
|
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) {
|
function readNumber(value: unknown) {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
@@ -59,18 +61,22 @@ function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
|
|||||||
const latitude = Number(raw.latitude);
|
const latitude = Number(raw.latitude);
|
||||||
const longitude = Number(raw.longitude);
|
const longitude = Number(raw.longitude);
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||||
|
const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL";
|
||||||
return {
|
return {
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
region,
|
||||||
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
||||||
hourly: normalizeHourlyForecast(raw.hourly),
|
hourly: normalizeHourlyForecast(raw.hourly),
|
||||||
daily: normalizeDailyForecast(raw.daily),
|
daily: normalizeDailyForecast(raw.daily),
|
||||||
sources: normalizeSources(raw.sources),
|
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) {
|
export async function fetchForecast(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
|
||||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
|
||||||
const response = await fetch(`/api/forecast?${params}`, { signal });
|
const response = await fetch(`/api/forecast?${params}`, { signal });
|
||||||
if (!response.ok) throw new Error("Unable to load forecast.");
|
if (!response.ok) throw new Error("Unable to load forecast.");
|
||||||
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
RawWeatherForecast,
|
RawWeatherForecast,
|
||||||
WeatherForecast,
|
WeatherForecast,
|
||||||
} from "@/types/forecast";
|
} from "@/types/forecast";
|
||||||
|
import { getWeatherCapabilities, type WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
const WARSAW_TIME_ZONE = "Europe/Warsaw";
|
const WARSAW_TIME_ZONE = "Europe/Warsaw";
|
||||||
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
|
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 latitude = Number(raw.latitude);
|
||||||
const longitude = Number(raw.longitude);
|
const longitude = Number(raw.longitude);
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||||
return {
|
return {
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
region,
|
||||||
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
|
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
|
||||||
hourly: normalizeOpenMeteoHourly(raw.hourly),
|
hourly: normalizeOpenMeteoHourly(raw.hourly),
|
||||||
daily: normalizeOpenMeteoDaily(raw.daily),
|
daily: normalizeOpenMeteoDaily(raw.daily),
|
||||||
sources: ["open-meteo"],
|
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 {
|
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null, region: WeatherRegion = "PL"): WeatherForecast {
|
||||||
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
|
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
|
||||||
const imgwHours = normalizeImgwHourly(imgwPayload);
|
const imgwHours = normalizeImgwHourly(imgwPayload);
|
||||||
let hasImgwHours = false;
|
let hasImgwHours = false;
|
||||||
const hourly = openMeteoForecast.hourly.map((hour) => {
|
const hourly = openMeteoForecast.hourly.map((hour) => {
|
||||||
|
|||||||
44
lib/i18n.tsx
44
lib/i18n.tsx
@@ -44,9 +44,12 @@ const translations = {
|
|||||||
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
|
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
|
||||||
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
|
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
|
||||||
"settings.notifications.noProvince": "brak wybranego województwa",
|
"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.deviceTitle": "To urządzenie",
|
||||||
"settings.notifications.enable": "Alerty IMGW",
|
"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.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.morningBrief": "Brief poranny o 7:00",
|
||||||
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
|
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
|
||||||
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
|
"settings.notifications.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.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
|
||||||
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
|
"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.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",
|
"pwa.install": "Zainstaluj",
|
||||||
"common.noData": "Brak danych",
|
"common.noData": "Brak danych",
|
||||||
"common.loading": "Ładowanie danych",
|
"common.loading": "Ładowanie danych",
|
||||||
@@ -87,15 +90,18 @@ const translations = {
|
|||||||
"brief.emptyTitle": "Brak briefu",
|
"brief.emptyTitle": "Brak briefu",
|
||||||
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
|
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
|
||||||
"location.label": "Twoja lokalizacja",
|
"location.label": "Twoja lokalizacja",
|
||||||
"location.searchLabel": "Szukaj miejscowości w Polsce",
|
"location.searchLabel": "Szukaj miejscowości",
|
||||||
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
|
"location.placeholder": "Wpisz miejscowość, np. Warszawa albo Rome…",
|
||||||
"location.clear": "Wyczyść wyszukiwanie",
|
"location.clear": "Wyczyść wyszukiwanie",
|
||||||
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
|
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
|
||||||
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
|
"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.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.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.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.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.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",
|
"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.gpsUse": "Użyj mojej lokalizacji",
|
||||||
"location.gpsLocating": "Ustalam lokalizację…",
|
"location.gpsLocating": "Ustalam lokalizację…",
|
||||||
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
|
"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.gpsAllow": "Użyj GPS",
|
||||||
"location.gpsNotNow": "Nie teraz",
|
"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.",
|
"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.wind": "Wiatr",
|
||||||
"weather.rainfall": "Suma opadu",
|
"weather.rainfall": "Suma opadu",
|
||||||
"weather.rainfall10m": "Opad 10 min",
|
"weather.rainfall10m": "Opad 10 min",
|
||||||
|
"weather.currentPrecipitation": "Opad",
|
||||||
"weather.pressure": "Ciśnienie",
|
"weather.pressure": "Ciśnienie",
|
||||||
"weather.feelsLike": "Odczuwalna",
|
"weather.feelsLike": "Odczuwalna",
|
||||||
"weather.measurement": "pomiar",
|
"weather.measurement": "pomiar",
|
||||||
|
"weather.modelUpdate": "aktualizacja modelu",
|
||||||
"weather.windDirection": "Kierunek wiatru",
|
"weather.windDirection": "Kierunek wiatru",
|
||||||
"weather.calm": "Pogodnie",
|
"weather.calm": "Pogodnie",
|
||||||
"weather.humid": "Wilgotno",
|
"weather.humid": "Wilgotno",
|
||||||
@@ -177,7 +185,7 @@ const translations = {
|
|||||||
"forecast.pastHour": "Miniona godzina",
|
"forecast.pastHour": "Miniona godzina",
|
||||||
"forecast.source": "Źródło prognozy:",
|
"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.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.error": "Nie udało się pobrać prognozy modelowej.",
|
||||||
"forecast.emptyTitle": "Brak prognozy",
|
"forecast.emptyTitle": "Brak prognozy",
|
||||||
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
|
"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.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||||
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
|
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
|
||||||
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
|
"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.drought": "Susza hydrologiczna",
|
||||||
"warnings.levelUnknown": "Poziom nieokreślony",
|
"warnings.levelUnknown": "Poziom nieokreślony",
|
||||||
"warnings.level": "Stopień {level}",
|
"warnings.level": "Stopień {level}",
|
||||||
@@ -291,9 +301,12 @@ const translations = {
|
|||||||
"settings.notifications.regionSelected": "Use the location selected in the app",
|
"settings.notifications.regionSelected": "Use the location selected in the app",
|
||||||
"settings.notifications.regionManual": "Choose province manually",
|
"settings.notifications.regionManual": "Choose province manually",
|
||||||
"settings.notifications.noProvince": "no province selected",
|
"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.deviceTitle": "This device",
|
||||||
"settings.notifications.enable": "IMGW alerts",
|
"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.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.morningBrief": "Morning brief at 7:00",
|
||||||
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.",
|
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.",
|
||||||
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
|
"settings.notifications.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.statusDenied": "Notifications are blocked in system or browser settings.",
|
||||||
"settings.notifications.statusGranted": "This device has notification permission.",
|
"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.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",
|
"pwa.install": "Install",
|
||||||
"common.noData": "No data",
|
"common.noData": "No data",
|
||||||
"common.loading": "Loading data",
|
"common.loading": "Loading data",
|
||||||
@@ -334,15 +347,18 @@ const translations = {
|
|||||||
"brief.emptyTitle": "Brief unavailable",
|
"brief.emptyTitle": "Brief unavailable",
|
||||||
"brief.emptyDescription": "The forecast does not contain enough data for the next day.",
|
"brief.emptyDescription": "The forecast does not contain enough data for the next day.",
|
||||||
"location.label": "Your location",
|
"location.label": "Your location",
|
||||||
"location.searchLabel": "Search places in Poland",
|
"location.searchLabel": "Search places",
|
||||||
"location.placeholder": "Enter a place, e.g. Piaseczno…",
|
"location.placeholder": "Enter a place, e.g. Warsaw or Rome…",
|
||||||
"location.clear": "Clear search",
|
"location.clear": "Clear search",
|
||||||
"location.error": "Unable to search for places. Try again.",
|
"location.error": "Unable to search for places. Try again.",
|
||||||
"location.preparing": "Preparing the nearest IMGW stations…",
|
"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.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.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.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.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.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",
|
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
|
||||||
@@ -353,7 +369,7 @@ const translations = {
|
|||||||
"location.gpsUse": "Use my location",
|
"location.gpsUse": "Use my location",
|
||||||
"location.gpsLocating": "Finding your location…",
|
"location.gpsLocating": "Finding your location…",
|
||||||
"location.gpsPromptTitle": "Show weather for 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.gpsAllow": "Use GPS",
|
||||||
"location.gpsNotNow": "Not now",
|
"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.",
|
"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.wind": "Wind",
|
||||||
"weather.rainfall": "Rainfall total",
|
"weather.rainfall": "Rainfall total",
|
||||||
"weather.rainfall10m": "Rainfall 10 min",
|
"weather.rainfall10m": "Rainfall 10 min",
|
||||||
|
"weather.currentPrecipitation": "Precipitation",
|
||||||
"weather.pressure": "Pressure",
|
"weather.pressure": "Pressure",
|
||||||
"weather.feelsLike": "Feels like",
|
"weather.feelsLike": "Feels like",
|
||||||
"weather.measurement": "measurement",
|
"weather.measurement": "measurement",
|
||||||
|
"weather.modelUpdate": "model update",
|
||||||
"weather.windDirection": "Wind direction",
|
"weather.windDirection": "Wind direction",
|
||||||
"weather.calm": "Fair",
|
"weather.calm": "Fair",
|
||||||
"weather.humid": "Humid",
|
"weather.humid": "Humid",
|
||||||
@@ -424,7 +442,7 @@ const translations = {
|
|||||||
"forecast.pastHour": "Past hour",
|
"forecast.pastHour": "Past hour",
|
||||||
"forecast.source": "Forecast source:",
|
"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.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.error": "Unable to load the model forecast.",
|
||||||
"forecast.emptyTitle": "Forecast unavailable",
|
"forecast.emptyTitle": "Forecast unavailable",
|
||||||
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
|
"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.error": "Unable to load meteorological or hydrological warnings.",
|
||||||
"warnings.emptyTitle": "No active warnings",
|
"warnings.emptyTitle": "No active warnings",
|
||||||
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological 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.drought": "Hydrological drought",
|
||||||
"warnings.levelUnknown": "Level not specified",
|
"warnings.levelUnknown": "Level not specified",
|
||||||
"warnings.level": "Level {level}",
|
"warnings.level": "Level {level}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { toNumber } from "@/lib/weather-utils";
|
import { toNumber } from "@/lib/weather-utils";
|
||||||
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
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;
|
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -104,6 +105,12 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
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,
|
measuredAt: row.measuredAt,
|
||||||
temperature: toCelsius(row.row.Temperature),
|
temperature: toCelsius(row.row.Temperature),
|
||||||
feelsLike: toCelsius(row.row.Chill),
|
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.");
|
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
|
||||||
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||||
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
|
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
|
||||||
import { getCountyTerytForLocation } from "@/lib/warning-regions";
|
import { getCountyTerytForLocation } from "@/lib/warning-regions";
|
||||||
|
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||||
|
|
||||||
const stationAliases: Record<string, string> = {
|
const stationAliases: Record<string, string> = {
|
||||||
gdansk: "gdanskrebiechowo",
|
gdansk: "gdanskrebiechowo",
|
||||||
@@ -64,6 +65,12 @@ export function findNearestSynopStation(
|
|||||||
name: location.name,
|
name: location.name,
|
||||||
province: location.province,
|
province: location.province,
|
||||||
district: location.district,
|
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),
|
countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name),
|
||||||
latitude: location.latitude,
|
latitude: location.latitude,
|
||||||
longitude: location.longitude,
|
longitude: location.longitude,
|
||||||
@@ -72,3 +79,35 @@ export function findNearestSynopStation(
|
|||||||
distanceKm: Math.round(nearest.distanceKm),
|
distanceKm: Math.round(nearest.distanceKm),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSelectedLocation(
|
||||||
|
location: Pick<LocationSearchResult, "name" | "province" | "district" | "country" | "countryCode" | "admin1" | "admin2" | "timezone" | "region" | "latitude" | "longitude">,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
interface VapidKeyResponse {
|
interface VapidKeyResponse {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -14,17 +15,20 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SavePushSubscriptionOptions {
|
interface SavePushSubscriptionOptions {
|
||||||
|
region?: WeatherRegion;
|
||||||
|
officialWarningsEnabled?: boolean;
|
||||||
morningBriefEnabled?: boolean;
|
morningBriefEnabled?: boolean;
|
||||||
tomorrowBriefEnabled?: boolean;
|
tomorrowBriefEnabled?: boolean;
|
||||||
latitude?: number | null;
|
latitude?: number | null;
|
||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
locationName?: string | null;
|
locationName?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
countyTeryt?: string | null;
|
countyTeryt?: string | null;
|
||||||
temperatureUnit?: TemperatureUnit;
|
temperatureUnit?: TemperatureUnit;
|
||||||
windSpeedUnit?: WindSpeedUnit;
|
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", {
|
const response = await fetch("/api/notifications/subscriptions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -32,12 +36,14 @@ export async function savePushSubscription(subscription: PushSubscription, provi
|
|||||||
subscription: subscription.toJSON(),
|
subscription: subscription.toJSON(),
|
||||||
province,
|
province,
|
||||||
language,
|
language,
|
||||||
enabled: true,
|
region: options.region ?? "PL",
|
||||||
|
enabled: options.officialWarningsEnabled ?? true,
|
||||||
morningBriefEnabled: options.morningBriefEnabled === true,
|
morningBriefEnabled: options.morningBriefEnabled === true,
|
||||||
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
|
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
|
||||||
latitude: options.latitude ?? null,
|
latitude: options.latitude ?? null,
|
||||||
longitude: options.longitude ?? null,
|
longitude: options.longitude ?? null,
|
||||||
locationName: options.locationName ?? null,
|
locationName: options.locationName ?? null,
|
||||||
|
timezone: options.timezone ?? null,
|
||||||
countyTeryt: options.countyTeryt ?? null,
|
countyTeryt: options.countyTeryt ?? null,
|
||||||
temperatureUnit: options.temperatureUnit,
|
temperatureUnit: options.temperatureUnit,
|
||||||
windSpeedUnit: options.windSpeedUnit,
|
windSpeedUnit: options.windSpeedUnit,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import webPush, { type PushSubscription } from "web-push";
|
import webPush, { type PushSubscription } from "web-push";
|
||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import { formatProvinceName } from "@/lib/provinces";
|
import { formatProvinceName } from "@/lib/provinces";
|
||||||
|
import type { WeatherBrief } from "@/lib/weather-brief";
|
||||||
import type { WeatherWarning } from "@/types/imgw";
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
import type { WarningPushSubscription } from "@/types/notifications";
|
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_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
|
||||||
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_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) {
|
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 title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
|
||||||
const validity = formatWarningValidity(warning, preference.language);
|
const validity = formatWarningValidity(warning, preference.language);
|
||||||
const level = getWarningLevelLabel(warning, preference.language);
|
const level = getWarningLevelLabel(warning, preference.language);
|
||||||
const bodyParts = preference.language === "pl"
|
const bodyParts = preference.language === "pl"
|
||||||
? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
|
? [`${region}: ${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 ? `${warning.probability}% probability` : null];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `IMGW: ${title}`,
|
title: `IMGW: ${title}`,
|
||||||
@@ -73,12 +73,12 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
|
|||||||
|
|
||||||
export async function sendTestNotification(preference: WarningPushSubscription) {
|
export async function sendTestNotification(preference: WarningPushSubscription) {
|
||||||
ensureWebPushConfigured();
|
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({
|
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"
|
body: preference.language === "pl"
|
||||||
? `Powiadomienia są aktywne dla województwa: ${province}.`
|
? `Powiadomienia są aktywne dla: ${location}.`
|
||||||
: `Notifications are active for: ${province}.`,
|
: `Notifications are active for: ${location}.`,
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
});
|
});
|
||||||
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||||
import type { WarningPushSubscription } from "@/types/notifications";
|
import type { WarningPushSubscription } from "@/types/notifications";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
interface PushDatabase {
|
interface PushDatabase {
|
||||||
database: Database.Database;
|
database: Database.Database;
|
||||||
@@ -13,6 +14,7 @@ interface PushSubscriptionRow {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
subscription_json: string;
|
subscription_json: string;
|
||||||
province: string;
|
province: string;
|
||||||
|
region?: string;
|
||||||
language: string;
|
language: string;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
morning_brief_enabled: number;
|
morning_brief_enabled: number;
|
||||||
@@ -20,6 +22,7 @@ interface PushSubscriptionRow {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
location_name: string | null;
|
location_name: string | null;
|
||||||
|
timezone: string | null;
|
||||||
county_teryt: string | null;
|
county_teryt: string | null;
|
||||||
temperature_unit: string | null;
|
temperature_unit: string | null;
|
||||||
wind_speed_unit: string | null;
|
wind_speed_unit: string | null;
|
||||||
@@ -50,7 +53,8 @@ function initializeDatabase(database: Database.Database) {
|
|||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
endpoint TEXT PRIMARY KEY,
|
endpoint TEXT PRIMARY KEY,
|
||||||
subscription_json TEXT NOT NULL,
|
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,
|
language TEXT NOT NULL,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
|
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -58,6 +62,7 @@ function initializeDatabase(database: Database.Database) {
|
|||||||
latitude REAL,
|
latitude REAL,
|
||||||
longitude REAL,
|
longitude REAL,
|
||||||
location_name TEXT,
|
location_name TEXT,
|
||||||
|
timezone TEXT,
|
||||||
county_teryt TEXT,
|
county_teryt TEXT,
|
||||||
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
|
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
|
||||||
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_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
|
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() {
|
function getDatabase() {
|
||||||
@@ -105,10 +117,12 @@ function getDatabase() {
|
|||||||
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
|
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
|
||||||
try {
|
try {
|
||||||
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
|
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
|
||||||
|
const region: WeatherRegion = row.region === "GLOBAL" || row.province === "global" ? "GLOBAL" : "PL";
|
||||||
return {
|
return {
|
||||||
endpoint: row.endpoint,
|
endpoint: row.endpoint,
|
||||||
subscription,
|
subscription,
|
||||||
province: row.province as Province,
|
province: row.province === "global" ? null : row.province as Province,
|
||||||
|
region,
|
||||||
language: row.language === "en" ? "en" : "pl",
|
language: row.language === "en" ? "en" : "pl",
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
morningBriefEnabled: row.morning_brief_enabled === 1,
|
morningBriefEnabled: row.morning_brief_enabled === 1,
|
||||||
@@ -116,6 +130,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
|
|||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
locationName: row.location_name,
|
locationName: row.location_name,
|
||||||
|
timezone: row.timezone,
|
||||||
countyTeryt: row.county_teryt,
|
countyTeryt: row.county_teryt,
|
||||||
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
|
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,
|
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
|
||||||
@@ -133,6 +148,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
endpoint,
|
endpoint,
|
||||||
subscription_json,
|
subscription_json,
|
||||||
province,
|
province,
|
||||||
|
region,
|
||||||
language,
|
language,
|
||||||
enabled,
|
enabled,
|
||||||
morning_brief_enabled,
|
morning_brief_enabled,
|
||||||
@@ -140,6 +156,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
location_name,
|
location_name,
|
||||||
|
timezone,
|
||||||
county_teryt,
|
county_teryt,
|
||||||
temperature_unit,
|
temperature_unit,
|
||||||
wind_speed_unit,
|
wind_speed_unit,
|
||||||
@@ -149,6 +166,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
@endpoint,
|
@endpoint,
|
||||||
@subscriptionJson,
|
@subscriptionJson,
|
||||||
@province,
|
@province,
|
||||||
|
@region,
|
||||||
@language,
|
@language,
|
||||||
@enabled,
|
@enabled,
|
||||||
@morningBriefEnabled,
|
@morningBriefEnabled,
|
||||||
@@ -156,6 +174,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
@latitude,
|
@latitude,
|
||||||
@longitude,
|
@longitude,
|
||||||
@locationName,
|
@locationName,
|
||||||
|
@timezone,
|
||||||
@countyTeryt,
|
@countyTeryt,
|
||||||
@temperatureUnit,
|
@temperatureUnit,
|
||||||
@windSpeedUnit,
|
@windSpeedUnit,
|
||||||
@@ -165,6 +184,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
ON CONFLICT(endpoint) DO UPDATE SET
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
subscription_json = excluded.subscription_json,
|
subscription_json = excluded.subscription_json,
|
||||||
province = excluded.province,
|
province = excluded.province,
|
||||||
|
region = excluded.region,
|
||||||
language = excluded.language,
|
language = excluded.language,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
morning_brief_enabled = excluded.morning_brief_enabled,
|
morning_brief_enabled = excluded.morning_brief_enabled,
|
||||||
@@ -172,6 +192,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
latitude = excluded.latitude,
|
latitude = excluded.latitude,
|
||||||
longitude = excluded.longitude,
|
longitude = excluded.longitude,
|
||||||
location_name = excluded.location_name,
|
location_name = excluded.location_name,
|
||||||
|
timezone = excluded.timezone,
|
||||||
county_teryt = excluded.county_teryt,
|
county_teryt = excluded.county_teryt,
|
||||||
temperature_unit = excluded.temperature_unit,
|
temperature_unit = excluded.temperature_unit,
|
||||||
wind_speed_unit = excluded.wind_speed_unit,
|
wind_speed_unit = excluded.wind_speed_unit,
|
||||||
@@ -179,7 +200,8 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
`).run({
|
`).run({
|
||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||||
province: subscription.province,
|
province: subscription.province ?? "global",
|
||||||
|
region: subscription.region,
|
||||||
language: subscription.language,
|
language: subscription.language,
|
||||||
enabled: subscription.enabled ? 1 : 0,
|
enabled: subscription.enabled ? 1 : 0,
|
||||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||||
@@ -187,6 +209,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
|||||||
latitude: subscription.latitude,
|
latitude: subscription.latitude,
|
||||||
longitude: subscription.longitude,
|
longitude: subscription.longitude,
|
||||||
locationName: subscription.locationName,
|
locationName: subscription.locationName,
|
||||||
|
timezone: subscription.timezone,
|
||||||
countyTeryt: subscription.countyTeryt,
|
countyTeryt: subscription.countyTeryt,
|
||||||
temperatureUnit: subscription.temperatureUnit,
|
temperatureUnit: subscription.temperatureUnit,
|
||||||
windSpeedUnit: subscription.windSpeedUnit,
|
windSpeedUnit: subscription.windSpeedUnit,
|
||||||
|
|||||||
113
lib/server-current-weather.ts
Normal file
113
lib/server-current-weather.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mergeForecastSources } from "@/lib/forecast-merge";
|
import { mergeForecastSources } from "@/lib/forecast-merge";
|
||||||
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
|
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 OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
||||||
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
|
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({
|
const openMeteoParams = new URLSearchParams({
|
||||||
latitude: String(latitude),
|
latitude: String(latitude),
|
||||||
longitude: String(longitude),
|
longitude: String(longitude),
|
||||||
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
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",
|
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",
|
forecast_days: "7",
|
||||||
wind_speed_unit: "ms",
|
wind_speed_unit: "ms",
|
||||||
});
|
});
|
||||||
@@ -42,11 +43,13 @@ export async function fetchServerForecast(latitude: number, longitude: number) {
|
|||||||
|
|
||||||
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
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.");
|
if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable.");
|
||||||
|
|
||||||
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
||||||
const imgwPayload = await readImgwPayload(imgwResponse);
|
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||||
return mergeForecastSources(openMeteoPayload, imgwPayload);
|
return mergeForecastSources(openMeteoPayload, imgwPayload, region);
|
||||||
}
|
}
|
||||||
|
|||||||
28
lib/store.ts
28
lib/store.ts
@@ -6,6 +6,8 @@ import type { SelectedLocation } from "@/types/location";
|
|||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
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";
|
type NotificationProvinceMode = "selected" | "manual";
|
||||||
|
|
||||||
@@ -61,6 +63,30 @@ export const useWeatherStore = create<WeatherStore>()(
|
|||||||
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
|
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
|
||||||
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
||||||
}),
|
}),
|
||||||
{ name: "wtr:preferences" },
|
{
|
||||||
|
name: "wtr:preferences",
|
||||||
|
migrate: (persisted) => {
|
||||||
|
const state = persisted as Partial<WeatherStore> | undefined;
|
||||||
|
const location = state?.selectedLocation as (Partial<SelectedLocation> & { 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"];
|
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) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -16,7 +16,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
const url = new URL(event.request.url);
|
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(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const DEFAULT_APP_URL = "http://127.0.0.1:3000";
|
const DEFAULT_APP_URL = "http://127.0.0.1:3000";
|
||||||
const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
|
const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
|
||||||
|
const DEFAULT_BRIEF_INTERVAL_MINUTES = 5;
|
||||||
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
||||||
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
|
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
|
||||||
const LOOP_INTERVAL_MS = 30_000;
|
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 appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
|
||||||
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
|
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
|
||||||
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
|
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
|
||||||
|
const 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 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);
|
const tomorrowBriefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
|
||||||
|
|
||||||
let lastWarningCheckAt = 0;
|
let lastWarningCheckAt = 0;
|
||||||
let lastMorningBriefDate = "";
|
let lastMorningBriefCheckAt = 0;
|
||||||
let lastTomorrowBriefDate = "";
|
let lastTomorrowBriefCheckAt = 0;
|
||||||
let isRunningTick = false;
|
let isRunningTick = false;
|
||||||
|
|
||||||
function loadEnvFile(path, overrideLoadedValues) {
|
function loadEnvFile(path, overrideLoadedValues) {
|
||||||
@@ -50,27 +52,6 @@ function normalizeTime(value, fallback) {
|
|||||||
return /^\d{2}:\d{2}$/.test(value) ? 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) {
|
async function callNotificationEndpoint(path) {
|
||||||
const response = await fetch(`${appUrl}${path}`, {
|
const response = await fetch(`${appUrl}${path}`, {
|
||||||
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
|
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
|
||||||
@@ -106,6 +87,7 @@ async function tick() {
|
|||||||
isRunningTick = true;
|
isRunningTick = true;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const warningIntervalMs = warningIntervalMinutes * 60_000;
|
const warningIntervalMs = warningIntervalMinutes * 60_000;
|
||||||
|
const briefIntervalMs = briefIntervalMinutes * 60_000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (now - lastWarningCheckAt >= warningIntervalMs) {
|
if (now - lastWarningCheckAt >= warningIntervalMs) {
|
||||||
@@ -113,15 +95,14 @@ async function tick() {
|
|||||||
await callNotificationEndpointSafely("/api/notifications/check");
|
await callNotificationEndpointSafely("/api/notifications/check");
|
||||||
}
|
}
|
||||||
|
|
||||||
const warsawTime = getWarsawDateParts();
|
if (now - lastMorningBriefCheckAt >= briefIntervalMs) {
|
||||||
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) {
|
lastMorningBriefCheckAt = now;
|
||||||
const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief");
|
await callNotificationEndpointSafely("/api/notifications/daily-brief");
|
||||||
if (sentBrief) lastMorningBriefDate = warsawTime.dateKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) {
|
if (now - lastTomorrowBriefCheckAt >= briefIntervalMs) {
|
||||||
const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
|
lastTomorrowBriefCheckAt = now;
|
||||||
if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey;
|
await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(formatWorkerError(error));
|
console.error(formatWorkerError(error));
|
||||||
@@ -131,6 +112,6 @@ async function tick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`wtr. notification worker: ${appUrl}`);
|
console.log(`wtr. notification worker: ${appUrl}`);
|
||||||
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, 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();
|
void tick();
|
||||||
setInterval(tick, LOOP_INTERVAL_MS);
|
setInterval(tick, LOOP_INTERVAL_MS);
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ export interface DailyForecast {
|
|||||||
export interface WeatherForecast {
|
export interface WeatherForecast {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
region: WeatherRegion;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
hourly: HourlyForecast[];
|
hourly: HourlyForecast[];
|
||||||
daily: DailyForecast[];
|
daily: DailyForecast[];
|
||||||
sources: ForecastSource[];
|
sources: ForecastSource[];
|
||||||
|
capabilities: WeatherCapabilities;
|
||||||
|
unavailableReasons: string[];
|
||||||
}
|
}
|
||||||
|
import type { WeatherCapabilities, WeatherRegion } from "@/types/weather-region";
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ export interface RawImgwHybridWeatherResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
|
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;
|
coverage: ImgwCurrentWeatherCoverage;
|
||||||
|
region: WeatherRegion;
|
||||||
measuredAt: string;
|
measuredAt: string;
|
||||||
temperature: number | null;
|
temperature: number | null;
|
||||||
feelsLike: number | null;
|
feelsLike: number | null;
|
||||||
@@ -41,3 +42,4 @@ export interface ImgwCurrentWeather {
|
|||||||
weatherCode: number | null;
|
weatherCode: number | null;
|
||||||
condition: CurrentWeatherCondition;
|
condition: CurrentWeatherCondition;
|
||||||
}
|
}
|
||||||
|
import type { WeatherRegion, WeatherSourceMetadata } from "@/types/weather-region";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
export interface LocationSearchResult {
|
export interface LocationSearchResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,22 +7,40 @@ export interface LocationSearchResult {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
province: string | null;
|
province: string | null;
|
||||||
district: 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 {
|
export interface ReverseLocationResult {
|
||||||
name: string;
|
name: string;
|
||||||
province: string | null;
|
province: string | null;
|
||||||
district: 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 {
|
export interface SelectedLocation {
|
||||||
name: string;
|
name: string;
|
||||||
province: string | null;
|
province: string | null;
|
||||||
district: 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;
|
countyTeryt: string | null;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
stationId: string;
|
stationId: string | null;
|
||||||
stationName: string;
|
stationName: string | null;
|
||||||
distanceKm: number;
|
distanceKm: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
import type { WeatherRegion } from "@/types/weather-region";
|
||||||
|
|
||||||
export interface PushSubscriptionKeys {
|
export interface PushSubscriptionKeys {
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
@@ -16,7 +17,8 @@ export interface BrowserPushSubscription {
|
|||||||
export interface WarningPushSubscription {
|
export interface WarningPushSubscription {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
subscription: BrowserPushSubscription;
|
subscription: BrowserPushSubscription;
|
||||||
province: Province;
|
province: Province | null;
|
||||||
|
region: WeatherRegion;
|
||||||
language: Language;
|
language: Language;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
morningBriefEnabled: boolean;
|
morningBriefEnabled: boolean;
|
||||||
@@ -24,6 +26,7 @@ export interface WarningPushSubscription {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
|
timezone: string | null;
|
||||||
countyTeryt: string | null;
|
countyTeryt: string | null;
|
||||||
temperatureUnit: TemperatureUnit;
|
temperatureUnit: TemperatureUnit;
|
||||||
windSpeedUnit: WindSpeedUnit;
|
windSpeedUnit: WindSpeedUnit;
|
||||||
|
|||||||
53
types/weather-region.ts
Normal file
53
types/weather-region.ts
Normal file
@@ -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<WeatherRegion, WeatherCapabilities> = {
|
||||||
|
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"];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user