Compare commits
23 Commits
3515fca0da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2515abba | |||
| 6bec7060e0 | |||
| c7da9e0d94 | |||
| 91acdb39b8 | |||
| ab6b7b414f | |||
| f15ebd28dc | |||
| b1cbc771f8 | |||
| 169d5e5e59 | |||
| bdba3c709c | |||
| ee55521803 | |||
| 8bbd9397a1 | |||
| 9087e1215c | |||
| 6be00da027 | |||
| 5049d81bd6 | |||
| 2182297adc | |||
| d572c9cc53 | |||
| bc8c346a7b | |||
| add8438307 | |||
| 50d46be617 | |||
| bb03657c58 | |||
| 1cd0491e6e | |||
| 3820e01be6 | |||
| 7dbcd8686b |
@@ -5,5 +5,6 @@ NOTIFICATIONS_CRON_SECRET=
|
||||
WTR_APP_URL=http://127.0.0.1:3000
|
||||
WTR_DATABASE_PATH=./data/wtr.sqlite
|
||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Lint, typecheck and build
|
||||
name: Lint, test, typecheck and build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -28,8 +28,14 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
coverage
|
||||
data
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
tsconfig.tsbuildinfo
|
||||
next-env.d.ts
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Projekt
|
||||
|
||||
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
||||
`wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową. Dla Polski pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Poza Polską pokazuje modelowe bieżące warunki i prognozę Open-Meteo bez oficjalnych ostrzeżeń IMGW. Open-Meteo Geocoding służy do globalnego wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
||||
|
||||
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React, `web-push` i SQLite przez `better-sqlite3`. PWA korzysta z manifestu oraz własnego service workera.
|
||||
|
||||
@@ -23,13 +23,17 @@ Wymagany jest Node.js 20.9 lub nowszy.
|
||||
npm install
|
||||
npm run dev
|
||||
npm run lint
|
||||
npm run format
|
||||
npm run format:check
|
||||
npm run test
|
||||
npm run test:watch
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run start
|
||||
npm run notifications:worker
|
||||
```
|
||||
|
||||
Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
|
||||
Testy jednostkowe uruchamia `npm run test` przez Vitest, a `npm run test:watch` działa w trybie obserwowania zmian. `npm run format` i `npm run format:check` używają Prettiera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
|
||||
|
||||
## Konwencje kodu
|
||||
|
||||
@@ -38,19 +42,21 @@ Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck`
|
||||
- Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`.
|
||||
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
|
||||
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń tylko dla Polski. Poza Polską używaj Open-Meteo jako modelowego źródła bieżących warunków i prognozy. Nie generuj fikcyjnych danych, nie przedstawiaj prognozy jako pomiaru IMGW i nie przedstawiaj modelowych sygnałów jako oficjalnych alertów.
|
||||
- Lokalizacja ma region `PL` albo `GLOBAL`. Preferuj capabilities i metadane źródła z `types/weather-region.ts` zamiast rozsiewania lokalnych warunków po kraju w UI.
|
||||
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
||||
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
||||
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
||||
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||
- Route handler prognozy pobiera pełne 7 dni Open-Meteo, a dla `PL` dodatkowo godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dla `GLOBAL` nie odpytuj ALARO i używaj Open-Meteo z lokalną strefą czasu. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
||||
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
||||
- HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera.
|
||||
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
||||
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
||||
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW.
|
||||
- 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`.
|
||||
- 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.
|
||||
- Sekcje dashboardu pod główną kartą pogody definiuj centralnie w `lib/dashboard-sections.ts`; widoczność i kolejność zapisuj w store, a komponent dashboardu renderuj według znormalizowanej kolejności.
|
||||
- Preferencje jednostek temperatury, wiatru, opadu, ciśnienia i dystansu są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`, 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. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`.
|
||||
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
||||
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
||||
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||
@@ -105,8 +111,8 @@ Przy zmianach UI:
|
||||
- Dobieraj lokalną weryfikację proporcjonalnie do ryzyka zmiany. CI w Gitea Actions jest ostateczną bramką jakości po pushu.
|
||||
- Zmiany w README, docs, tekstach i komentarzach: nie uruchamiaj `npm run lint`, `npm run typecheck` ani `npm run build`; wystarczy `git diff` i ewentualnie `git diff --check`.
|
||||
- Małe zmiany wizualne, np. klasy Tailwind, border, radius, spacing, kolory albo copy w JSX: nie uruchamiaj rutynowo `npm run build`. Uruchom `npm run lint` tylko, gdy zmiana mogła naruszyć składnię albo reguły lintingu, a `npm run typecheck` tylko przy zmianach typów, propsów lub logiki.
|
||||
- Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX.
|
||||
- Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run typecheck` i `npm run build`.
|
||||
- Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX, oraz `npm run test`, jeśli zmiana dotyka logiki objętej testami.
|
||||
- Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm run test` i `npm run build`.
|
||||
- Przed większym pushem albo większym refaktorem możesz uruchomić pełną weryfikację.
|
||||
- Sprawdź `git status` przed zakończeniem. Używaj `git diff --check` wtedy, gdy zmiana mogła wprowadzić problemy whitespace. Nie commituj wygenerowanego churnu w `next-env.d.ts`.
|
||||
- Commituj zakończone zmiany w formacie Conventional Commits, np. `fix: correct warning filtering`, ale nie używaj eskalacji dla `git commit` bez realnej potrzeby.
|
||||
|
||||
26
README.md
26
README.md
@@ -1,15 +1,15 @@
|
||||
# wtr.
|
||||
|
||||
`wtr.` to mobilna PWA pogodowa dla Polski oparta o publiczne dane IMGW oraz jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo.
|
||||
`wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową przez Open-Meteo.
|
||||
|
||||
Aplikacja pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, prognozę godzinową i 7-dniową, ostrzeżenia meteorologiczne i hydrologiczne, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI.
|
||||
Aplikacja pokazuje bieżącą analizę IMGW Hybrid dla Polski, modelowe warunki bieżące Open-Meteo poza Polską, prognozę godzinową i 7-dniową, ostrzeżenia IMGW dla Polski, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI.
|
||||
|
||||
## Najważniejsze funkcje
|
||||
|
||||
- Bieżące warunki z lokalnej analizy IMGW Hybrid, z opisanym fallbackiem do stacji synoptycznej.
|
||||
- Prognoza modelowa 7 dni: IMGW ALARO dla dostępnych godzin oraz Open-Meteo jako uzupełnienie i fallback.
|
||||
- Wyszukiwanie miejscowości w Polsce oraz opcjonalny wybór lokalizacji GPS.
|
||||
- Ostrzeżenia IMGW z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza.
|
||||
- Bieżące warunki: IMGW Hybrid w Polsce, Open-Meteo jako modelowe warunki bieżące poza Polską.
|
||||
- Prognoza modelowa 7 dni: IMGW ALARO dla Polski oraz Open-Meteo jako globalny provider i fallback.
|
||||
- Globalne wyszukiwanie miejscowości oraz opcjonalny wybór lokalizacji GPS.
|
||||
- Ostrzeżenia IMGW dla Polski z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza.
|
||||
- Powiadomienia Web Push o nowych ostrzeżeniach, porannym briefie dnia i wieczornym briefie na jutro.
|
||||
- Widoki dashboardu, prognozy szczegółowej dnia, ostrzeżeń, hydro, stacji i ustawień.
|
||||
- PWA z manifestem, własnym service workerem i podstawowym fallbackiem offline.
|
||||
@@ -53,6 +53,7 @@ NOTIFICATIONS_CRON_SECRET=
|
||||
WTR_APP_URL=http://127.0.0.1:3000
|
||||
WTR_DATABASE_PATH=./data/wtr.sqlite
|
||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||
```
|
||||
@@ -62,15 +63,19 @@ Przykład znajduje się w [.env.example](.env.example).
|
||||
## Komendy
|
||||
|
||||
| Komenda | Opis |
|
||||
| --- | --- |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| `npm run dev` | Uruchamia serwer deweloperski Next.js. |
|
||||
| `npm run lint` | Uruchamia ESLint. |
|
||||
| `npm run format` | Formatuje obsługiwane pliki przez Prettier. |
|
||||
| `npm run format:check` | Sprawdza formatowanie Prettier bez zapisywania zmian. |
|
||||
| `npm run test` | Uruchamia testy jednostkowe logiki pogodowej przez Vitest. |
|
||||
| `npm run test:watch` | Uruchamia Vitest w trybie obserwowania zmian. |
|
||||
| `npm run typecheck` | Uruchamia `tsc --noEmit`. |
|
||||
| `npm run build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. |
|
||||
| `npm run start` | Uruchamia zbudowaną aplikację. |
|
||||
| `npm run notifications:worker` | Uruchamia self-hostowany worker powiadomień. Wymaga działającej aplikacji Next.js. |
|
||||
|
||||
Repozytorium nie ma obecnie skryptu testów ani formattera.
|
||||
Testy jednostkowe korzystają z Vitest i obejmują kluczową logikę pogodową, m.in. briefy, jednostki, ikony prognozy oraz dopasowanie ostrzeżeń do regionu. Formatowanie jest obsługiwane przez Prettier.
|
||||
|
||||
## CI
|
||||
|
||||
@@ -79,7 +84,9 @@ Repozytorium używa Gitea Actions. Workflow znajduje się w [.gitea/workflows/ci
|
||||
Pipeline działa na `ubuntu-latest`, instaluje zależności przez `npm ci`, a następnie uruchamia:
|
||||
|
||||
- `npm run lint`,
|
||||
- `npm run format:check`,
|
||||
- `npm run typecheck`,
|
||||
- `npm run test`,
|
||||
- `npm run build`.
|
||||
|
||||
Na instancji Gitea musi być włączony Actions runner kompatybilny z etykietą `ubuntu-latest`.
|
||||
@@ -111,6 +118,7 @@ docs/ szczegółowa dokumentacja techniczna
|
||||
## Status i Ograniczenia
|
||||
|
||||
- `lib/push-store.ts` przechowuje subskrypcje Web Push i historię wysyłek w lokalnym SQLite. Na self-hostingu zadbaj o trwały katalog dla `WTR_DATABASE_PATH` i backup pliku bazy.
|
||||
- Oficjalne ostrzeżenia pogodowe i dane hydrologiczne są obsługiwane tylko dla Polski przez IMGW. Poza Polską aplikacja pokazuje prognozę modelową Open-Meteo i briefy bez oficjalnych alertów.
|
||||
- Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`.
|
||||
- Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń.
|
||||
- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary i prognozy są w UI rozdzielane.
|
||||
- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary, modelowe warunki bieżące i prognozy są w UI rozdzielane.
|
||||
|
||||
37
app/api/current-weather/route.ts
Normal file
37
app/api/current-weather/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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 { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
function parseRegion(value: string | null): WeatherRegion {
|
||||
return value === "GLOBAL" ? "GLOBAL" : "PL";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
|
||||
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
|
||||
const region = parseRegion(searchParams.get("region"));
|
||||
if (latitude === null || longitude === null) {
|
||||
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(await fetchServerForecast(latitude, longitude), {
|
||||
return NextResponse.json(await fetchServerForecast(latitude, longitude, region), {
|
||||
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response";
|
||||
|
||||
const ALLOWED_PATHS = new Set([
|
||||
"synop",
|
||||
"hydro",
|
||||
"meteo",
|
||||
"warningsmeteo",
|
||||
"warningshydro",
|
||||
"product",
|
||||
]);
|
||||
const ALLOWED_PATHS = new Set(["synop", "hydro", "meteo", "warningsmeteo", "warningshydro", "product"]);
|
||||
const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data";
|
||||
const USER_AGENT = "wtr./1.0 (weather data proxy)";
|
||||
|
||||
@@ -30,7 +23,11 @@ export async function GET(_: Request, context: { params: Promise<{ path: string[
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readImgwResponseBody(response);
|
||||
if ((resource === "warningsmeteo" || resource === "warningshydro") && response.status === 404 && isImgwNoProductsResponse(body.json)) {
|
||||
if (
|
||||
(resource === "warningsmeteo" || resource === "warningshydro") &&
|
||||
response.status === 404 &&
|
||||
isImgwNoProductsResponse(body.json)
|
||||
) {
|
||||
return NextResponse.json([], {
|
||||
headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||
|
||||
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
|
||||
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
|
||||
@@ -13,6 +14,8 @@ interface RawReverseLocation {
|
||||
municipality?: string;
|
||||
county?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,21 +49,32 @@ export async function GET(request: Request) {
|
||||
headers: { Accept: "application/json", "User-Agent": USER_AGENT },
|
||||
});
|
||||
if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
|
||||
const data = await response.json() as RawReverseLocation;
|
||||
const name = data.address?.city
|
||||
?? data.address?.town
|
||||
?? data.address?.village
|
||||
?? data.address?.municipality
|
||||
?? data.name
|
||||
?? data.display_name?.split(",")[0]?.trim();
|
||||
const data = (await response.json()) as RawReverseLocation;
|
||||
const name =
|
||||
data.address?.city ??
|
||||
data.address?.town ??
|
||||
data.address?.village ??
|
||||
data.address?.municipality ??
|
||||
data.name ??
|
||||
data.display_name?.split(",")[0]?.trim();
|
||||
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
|
||||
return NextResponse.json({
|
||||
const countryCode = data.address?.country_code?.toUpperCase() ?? null;
|
||||
return NextResponse.json(
|
||||
{
|
||||
name,
|
||||
province: data.address?.state ?? null,
|
||||
district: data.address?.county ?? null,
|
||||
}, {
|
||||
country: data.address?.country ?? null,
|
||||
countryCode,
|
||||
admin1: data.address?.state ?? null,
|
||||
admin2: data.address?.county ?? null,
|
||||
timezone: null,
|
||||
region: getWeatherRegionForCountryCode(countryCode),
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||
|
||||
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
||||
|
||||
@@ -7,8 +8,11 @@ interface RawLocation {
|
||||
name?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
admin1?: string;
|
||||
admin2?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -19,27 +23,38 @@ export async function GET(request: Request) {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
name: query,
|
||||
count: "8",
|
||||
count: "10",
|
||||
language,
|
||||
format: "json",
|
||||
countryCode: "PL",
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
|
||||
if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
|
||||
const data = await response.json() as { results?: RawLocation[] };
|
||||
const data = (await response.json()) as { results?: RawLocation[] };
|
||||
const results = (data.results ?? []).flatMap((location) => {
|
||||
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
|
||||
return [{
|
||||
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude))
|
||||
return [];
|
||||
const countryCode = location.country_code?.toUpperCase() ?? null;
|
||||
return [
|
||||
{
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number,
|
||||
province: location.admin1 ?? null,
|
||||
district: location.admin2 ?? null,
|
||||
}];
|
||||
country: location.country ?? null,
|
||||
countryCode,
|
||||
admin1: location.admin1 ?? null,
|
||||
admin2: location.admin2 ?? null,
|
||||
timezone: location.timezone ?? null,
|
||||
region: getWeatherRegionForCountryCode(countryCode),
|
||||
},
|
||||
];
|
||||
});
|
||||
return NextResponse.json(results, {
|
||||
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
|
||||
});
|
||||
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
||||
import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store";
|
||||
import {
|
||||
getPushSubscriptions,
|
||||
hasSentWarning,
|
||||
markWarningSent,
|
||||
pruneSentWarnings,
|
||||
removePushSubscription,
|
||||
} from "@/lib/push-store";
|
||||
import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
|
||||
import { warningMatchesCounty } from "@/lib/warning-regions";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
@@ -40,8 +46,12 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now));
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled);
|
||||
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) =>
|
||||
isRelevantWarning(warning, now),
|
||||
);
|
||||
const subscriptions = getPushSubscriptions().filter(
|
||||
(subscription) => subscription.region === "PL" && subscription.enabled && subscription.province,
|
||||
);
|
||||
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
@@ -50,9 +60,11 @@ export async function GET(request: Request) {
|
||||
pruneSentWarnings(activeWarningIds);
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const matchingWarnings = warnings.filter((warning) => (
|
||||
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province)
|
||||
));
|
||||
const matchingWarnings = warnings.filter((warning) =>
|
||||
subscription.countyTeryt
|
||||
? warningMatchesCounty(warning, subscription.countyTeryt)
|
||||
: subscription.province !== null && warning.provinces.includes(subscription.province),
|
||||
);
|
||||
for (const warning of matchingWarnings) {
|
||||
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
||||
skipped += 1;
|
||||
@@ -65,7 +77,8 @@ export async function GET(request: Request) {
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
const statusCode =
|
||||
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
|
||||
}
|
||||
}
|
||||
@@ -80,9 +93,12 @@ export async function GET(request: Request) {
|
||||
failed,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Unable to check IMGW meteorological warnings.",
|
||||
details: getErrorMessage(error),
|
||||
}, { status: 502 });
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchServerForecast } from "@/lib/server-forecast";
|
||||
import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
||||
import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store";
|
||||
import {
|
||||
getPushSubscriptions,
|
||||
hasSentMorningBrief,
|
||||
markMorningBriefSent,
|
||||
removePushSubscription,
|
||||
} from "@/lib/push-store";
|
||||
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
|
||||
import { buildWeatherBrief } from "@/lib/weather-brief";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||
import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -16,15 +21,27 @@ function isCronAuthorized(request: Request) {
|
||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||
}
|
||||
|
||||
function getWarsawDateKey(date = new Date()) {
|
||||
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
||||
|
||||
function normalizeTime(value: string | undefined, fallback: string) {
|
||||
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getLocalDateParts(timeZone: string | null, date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
return {
|
||||
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
|
||||
time: `${part("hour")}:${part("minute")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -36,43 +53,72 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateKey = getWarsawDateKey(now);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||
subscription.enabled
|
||||
&& subscription.morningBriefEnabled
|
||||
&& Number.isFinite(subscription.latitude)
|
||||
&& Number.isFinite(subscription.longitude)
|
||||
));
|
||||
const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
|
||||
const allSubscriptions = getPushSubscriptions();
|
||||
const subscriptions = allSubscriptions.filter(
|
||||
(subscription) =>
|
||||
subscription.morningBriefEnabled &&
|
||||
Number.isFinite(subscription.latitude) &&
|
||||
Number.isFinite(subscription.longitude),
|
||||
);
|
||||
const skippedReasons = {
|
||||
disabled: allSubscriptions.filter((subscription) => !subscription.morningBriefEnabled).length,
|
||||
missingLocation: allSubscriptions.filter(
|
||||
(subscription) =>
|
||||
subscription.morningBriefEnabled &&
|
||||
(!Number.isFinite(subscription.latitude) || !Number.isFinite(subscription.longitude)),
|
||||
).length,
|
||||
beforeTime: 0,
|
||||
alreadySent: 0,
|
||||
noBrief: 0,
|
||||
};
|
||||
let warningsUnavailable = false;
|
||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||
const warnings = hasPolishSubscriptions
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const localTime = getLocalDateParts(subscription.timezone, now);
|
||||
if (localTime.time < briefTime) {
|
||||
skipped += 1;
|
||||
skippedReasons.beforeTime += 1;
|
||||
continue;
|
||||
}
|
||||
const dateKey = localTime.dateKey;
|
||||
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
|
||||
skipped += 1;
|
||||
skippedReasons.alreadySent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
|
||||
const forecast = await fetchServerForecast(
|
||||
subscription.latitude as number,
|
||||
subscription.longitude as number,
|
||||
subscription.region,
|
||||
);
|
||||
const brief = buildWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
warnings: subscription.region === "PL" ? warnings : [],
|
||||
province: subscription.province,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||
locationName: subscription.locationName ?? "wtr.",
|
||||
language: subscription.language,
|
||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
|
||||
now,
|
||||
});
|
||||
if (!brief) {
|
||||
skipped += 1;
|
||||
skippedReasons.noBrief += 1;
|
||||
continue;
|
||||
}
|
||||
await sendMorningBriefNotification(subscription, brief);
|
||||
@@ -80,18 +126,21 @@ export async function GET(request: Request) {
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
const statusCode =
|
||||
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date: dateKey,
|
||||
subscriptions: subscriptions.length,
|
||||
time: briefTime,
|
||||
subscriptions: allSubscriptions.length,
|
||||
eligibleSubscriptions: subscriptions.length,
|
||||
warningsUnavailable,
|
||||
sent,
|
||||
skipped,
|
||||
skippedReasons,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,19 +2,33 @@ import { NextResponse } from "next/server";
|
||||
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
|
||||
import { isWebPushConfigured } from "@/lib/push-service";
|
||||
import { normalizeProvinceName } from "@/lib/provinces";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||
import {
|
||||
DEFAULT_DISTANCE_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
DEFAULT_PRESSURE_UNIT,
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
isDistanceUnit,
|
||||
isPrecipitationUnit,
|
||||
isPressureUnit,
|
||||
isTemperatureUnit,
|
||||
isWindSpeedUnit,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const subscription = value as Partial<BrowserPushSubscription>;
|
||||
return typeof subscription.endpoint === "string"
|
||||
&& subscription.endpoint.startsWith("https://")
|
||||
&& typeof subscription.keys?.p256dh === "string"
|
||||
&& typeof subscription.keys.auth === "string";
|
||||
return (
|
||||
typeof subscription.endpoint === "string" &&
|
||||
subscription.endpoint.startsWith("https://") &&
|
||||
typeof subscription.keys?.p256dh === "string" &&
|
||||
typeof subscription.keys.auth === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -22,9 +36,10 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null) as {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
subscription?: unknown;
|
||||
province?: unknown;
|
||||
region?: unknown;
|
||||
language?: unknown;
|
||||
enabled?: unknown;
|
||||
morningBriefEnabled?: unknown;
|
||||
@@ -32,17 +47,26 @@ export async function POST(request: Request) {
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
locationName?: unknown;
|
||||
timezone?: unknown;
|
||||
countyTeryt?: unknown;
|
||||
temperatureUnit?: unknown;
|
||||
windSpeedUnit?: unknown;
|
||||
precipitationUnit?: unknown;
|
||||
pressureUnit?: unknown;
|
||||
distanceUnit?: unknown;
|
||||
} | null;
|
||||
const subscription = body?.subscription;
|
||||
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
|
||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||
const rawTemperatureUnit = body?.temperatureUnit;
|
||||
const rawWindSpeedUnit = body?.windSpeedUnit;
|
||||
const rawPrecipitationUnit = body?.precipitationUnit;
|
||||
const rawPressureUnit = body?.pressureUnit;
|
||||
const rawDistanceUnit = body?.distanceUnit;
|
||||
|
||||
if (!isBrowserPushSubscription(subscription) || !province) {
|
||||
const enabled = body?.enabled !== false && region === "PL";
|
||||
if (!isBrowserPushSubscription(subscription) || (enabled && !province)) {
|
||||
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -51,16 +75,22 @@ export async function POST(request: Request) {
|
||||
endpoint: subscription.endpoint,
|
||||
subscription,
|
||||
province,
|
||||
region,
|
||||
language,
|
||||
enabled: body?.enabled !== false,
|
||||
enabled,
|
||||
morningBriefEnabled: body?.morningBriefEnabled === true,
|
||||
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
|
||||
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
|
||||
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
||||
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||
locationName:
|
||||
typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||
timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null,
|
||||
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
|
||||
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: isPrecipitationUnit(rawPrecipitationUnit) ? rawPrecipitationUnit : DEFAULT_PRECIPITATION_UNIT,
|
||||
pressureUnit: isPressureUnit(rawPressureUnit) ? rawPressureUnit : DEFAULT_PRESSURE_UNIT,
|
||||
distanceUnit: isDistanceUnit(rawDistanceUnit) ? rawDistanceUnit : DEFAULT_DISTANCE_UNIT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -69,7 +99,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { endpoint?: unknown } | null;
|
||||
const body = (await request.json().catch(() => null)) as { endpoint?: unknown } | null;
|
||||
if (typeof body?.endpoint !== "string" || !body.endpoint) {
|
||||
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null) as { endpoint?: unknown } | null;
|
||||
const body = (await request.json().catch(() => null)) as { endpoint?: unknown } | null;
|
||||
if (typeof body?.endpoint !== "string" || !body.endpoint) {
|
||||
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export async function POST(request: Request) {
|
||||
await sendTestNotification(subscription);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
const statusCode =
|
||||
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
|
||||
return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchServerForecast } from "@/lib/server-forecast";
|
||||
import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
||||
import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store";
|
||||
import {
|
||||
getPushSubscriptions,
|
||||
hasSentTomorrowBrief,
|
||||
markTomorrowBriefSent,
|
||||
removePushSubscription,
|
||||
} from "@/lib/push-store";
|
||||
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
|
||||
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||
import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -16,25 +21,39 @@ function isCronAuthorized(request: Request) {
|
||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||
}
|
||||
|
||||
function getWarsawDateKey(date = new Date(), dayOffset = 0) {
|
||||
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
|
||||
|
||||
function normalizeTime(value: string | undefined, fallback: string) {
|
||||
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset = 0) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
const dateKey = `${part("year")}-${part("month")}-${part("day")}`;
|
||||
const time = `${part("hour")}:${part("minute")}`;
|
||||
if (dayOffset === 0) return { dateKey, time };
|
||||
|
||||
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
|
||||
const shiftedDate = new Date(
|
||||
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
|
||||
);
|
||||
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(shiftedDate);
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -46,43 +65,72 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const targetDateKey = getWarsawDateKey(now, 1);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||
subscription.enabled
|
||||
&& subscription.tomorrowBriefEnabled
|
||||
&& Number.isFinite(subscription.latitude)
|
||||
&& Number.isFinite(subscription.longitude)
|
||||
));
|
||||
const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
|
||||
const allSubscriptions = getPushSubscriptions();
|
||||
const subscriptions = allSubscriptions.filter(
|
||||
(subscription) =>
|
||||
subscription.tomorrowBriefEnabled &&
|
||||
Number.isFinite(subscription.latitude) &&
|
||||
Number.isFinite(subscription.longitude),
|
||||
);
|
||||
const skippedReasons = {
|
||||
disabled: allSubscriptions.filter((subscription) => !subscription.tomorrowBriefEnabled).length,
|
||||
missingLocation: allSubscriptions.filter(
|
||||
(subscription) =>
|
||||
subscription.tomorrowBriefEnabled &&
|
||||
(!Number.isFinite(subscription.latitude) || !Number.isFinite(subscription.longitude)),
|
||||
).length,
|
||||
beforeTime: 0,
|
||||
alreadySent: 0,
|
||||
noBrief: 0,
|
||||
};
|
||||
let warningsUnavailable = false;
|
||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||
const warnings = hasPolishSubscriptions
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const localToday = getLocalDateParts(subscription.timezone, now);
|
||||
if (localToday.time < briefTime) {
|
||||
skipped += 1;
|
||||
skippedReasons.beforeTime += 1;
|
||||
continue;
|
||||
}
|
||||
const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey;
|
||||
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
|
||||
skipped += 1;
|
||||
skippedReasons.alreadySent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
|
||||
const forecast = await fetchServerForecast(
|
||||
subscription.latitude as number,
|
||||
subscription.longitude as number,
|
||||
subscription.region,
|
||||
);
|
||||
const brief = buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
warnings: subscription.region === "PL" ? warnings : [],
|
||||
province: subscription.province,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||
locationName: subscription.locationName ?? "wtr.",
|
||||
language: subscription.language,
|
||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
|
||||
now,
|
||||
});
|
||||
if (!brief) {
|
||||
skipped += 1;
|
||||
skippedReasons.noBrief += 1;
|
||||
continue;
|
||||
}
|
||||
await sendTomorrowBriefNotification(subscription, brief);
|
||||
@@ -90,18 +138,21 @@ export async function GET(request: Request) {
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
const statusCode =
|
||||
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date: targetDateKey,
|
||||
subscriptions: subscriptions.length,
|
||||
time: briefTime,
|
||||
subscriptions: allSubscriptions.length,
|
||||
eligibleSubscriptions: subscriptions.length,
|
||||
warningsUnavailable,
|
||||
sent,
|
||||
skipped,
|
||||
skippedReasons,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ select {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
@apply border border-border/70 bg-surface shadow-soft;
|
||||
@@ -120,11 +128,8 @@ select {
|
||||
@media (min-width: 640px) {
|
||||
@layer utilities {
|
||||
.modal-safe-area {
|
||||
padding:
|
||||
calc(1rem + env(safe-area-inset-top))
|
||||
calc(1rem + env(safe-area-inset-right))
|
||||
calc(1rem + env(safe-area-inset-bottom))
|
||||
calc(1rem + env(safe-area-inset-left));
|
||||
padding: calc(1rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-right))
|
||||
calc(1rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,11 +137,8 @@ select {
|
||||
@media (min-width: 1024px) {
|
||||
@layer utilities {
|
||||
.modal-safe-area {
|
||||
padding:
|
||||
calc(2rem + env(safe-area-inset-top))
|
||||
calc(2rem + env(safe-area-inset-right))
|
||||
calc(2rem + env(safe-area-inset-bottom))
|
||||
calc(2rem + env(safe-area-inset-left));
|
||||
padding: calc(2rem + env(safe-area-inset-top)) calc(2rem + env(safe-area-inset-right))
|
||||
calc(2rem + env(safe-area-inset-bottom)) calc(2rem + env(safe-area-inset-left));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ export const metadata: Metadata = {
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." },
|
||||
icons: {
|
||||
icon: [{ url: "/icons/icon.svg", type: "image/svg+xml" }, { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
|
||||
icon: [
|
||||
{ url: "/icons/icon.svg", type: "image/svg+xml" },
|
||||
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
],
|
||||
apple: "/icons/icon-192.png",
|
||||
},
|
||||
};
|
||||
@@ -46,8 +49,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
return (
|
||||
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
|
||||
<body className={`${inter.variable} font-sans`}>
|
||||
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script>
|
||||
<Script id="wtr-language" strategy="beforeInteractive">{languageScript}</Script>
|
||||
<Script id="wtr-theme" strategy="beforeInteractive">
|
||||
{themeScript}
|
||||
</Script>
|
||||
<Script id="wtr-language" strategy="beforeInteractive">
|
||||
{languageScript}
|
||||
</Script>
|
||||
<Providers>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Providers>
|
||||
|
||||
@@ -8,10 +8,17 @@ export default function OfflinePage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent"><WifiOff className="size-6" /></div>
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent">
|
||||
<WifiOff className="size-6" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p>
|
||||
<Link href="/" className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">{t("offline.back")}</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{t("offline.back")}
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
49
components/charts/chart-tooltip.tsx
Normal file
49
components/charts/chart-tooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ChartTooltipPayload {
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
name?: ReactNode;
|
||||
payload?: Record<string, unknown>;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active?: boolean;
|
||||
label?: ReactNode;
|
||||
labelFormatter?: (label: ReactNode) => ReactNode;
|
||||
payload?: ChartTooltipPayload[];
|
||||
valueFormatter?: (entry: ChartTooltipPayload) => ReactNode;
|
||||
}
|
||||
|
||||
export function ChartTooltip({ active, label, labelFormatter, payload, valueFormatter }: ChartTooltipProps) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="min-w-36 rounded-card border border-border/80 bg-surface-raised/95 px-3.5 py-3 text-xs text-foreground shadow-card backdrop-blur-xl">
|
||||
{label !== undefined && (
|
||||
<p className="mb-2 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{labelFormatter ? labelFormatter(label) : label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry, index) => (
|
||||
<div
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"
|
||||
key={`${String(entry.dataKey ?? entry.name ?? "series")}-${index}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted">
|
||||
{entry.color && (
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums text-foreground">
|
||||
{valueFormatter ? valueFormatter(entry) : String(entry.value ?? "—")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
import { CHART_COLORS } from "@/lib/chart-theme";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||
import { formatForecastTemperature } from "@/lib/forecast-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { convertTemperature, formatTemperatureValue, getTemperatureUnitLabel } from "@/lib/weather-utils";
|
||||
import {
|
||||
convertPrecipitation,
|
||||
convertTemperature,
|
||||
formatTemperatureValue,
|
||||
getPrecipitationUnitLabel,
|
||||
getTemperatureUnitLabel,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { HourlyForecast } from "@/types/forecast";
|
||||
|
||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||
|
||||
interface ChartLegendItem {
|
||||
color: string;
|
||||
label: string;
|
||||
marker: "bar" | "line" | "dashed-line";
|
||||
}
|
||||
|
||||
function formatHour(value: string) {
|
||||
return value.slice(11, 16);
|
||||
}
|
||||
|
||||
function formatChartPrecipitation(value: number | null, language: "pl" | "en", unit: string) {
|
||||
if (value === null) return "—";
|
||||
const formattedValue = new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
|
||||
minimumFractionDigits: unit === "in" ? 2 : 0,
|
||||
maximumFractionDigits: unit === "mm" ? (value < 1 ? 2 : 1) : 2,
|
||||
}).format(value);
|
||||
return `${formattedValue} ${unit}`;
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: ChartLegendItem[] }) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-medium text-muted">
|
||||
{items.map((item) => (
|
||||
<span className="inline-flex items-center gap-1.5" key={item.label}>
|
||||
{item.marker === "bar" ? (
|
||||
<span className="h-2.5 w-3 rounded-t-[5px]" style={{ backgroundColor: item.color }} aria-hidden="true" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-0 w-5 border-t-2 ${item.marker === "dashed-line" ? "border-dashed" : "border-solid"}`}
|
||||
style={{ borderColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const rows = hours.map((hour) => ({
|
||||
time: formatHour(hour.time),
|
||||
temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit),
|
||||
feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit),
|
||||
precipitation: hour.precipitation,
|
||||
precipitation: hour.precipitation === null ? null : convertPrecipitation(hour.precipitation, precipitationUnit),
|
||||
precipitationProbability: hour.precipitationProbability,
|
||||
}));
|
||||
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
|
||||
const precipitationUnitLabel = getPrecipitationUnitLabel(precipitationUnit);
|
||||
const temperatureLegendItems: ChartLegendItem[] = [
|
||||
{ color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" },
|
||||
{ color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" },
|
||||
];
|
||||
const rainfallLegendItems: ChartLegendItem[] = [
|
||||
{ color: CHART_COLORS.rainfall, label: t("forecast.precipitation"), marker: "bar" },
|
||||
{ color: CHART_COLORS.probability, label: t("forecast.precipitationProbability"), marker: "line" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
@@ -33,52 +86,138 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
|
||||
<div className="mt-4 h-56 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||
formatter={(value) => [
|
||||
typeof value === "number"
|
||||
? formatTemperatureValue(value, language, temperatureUnit)
|
||||
: formatForecastTemperature(null, language, temperatureUnit),
|
||||
]}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={3}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit={temperatureUnitLabel}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: CHART_COLORS.grid, strokeDasharray: "4 4" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
valueFormatter={(entry) =>
|
||||
typeof entry.value === "number"
|
||||
? formatTemperatureValue(entry.value, language, temperatureUnit)
|
||||
: formatForecastTemperature(null, language, temperatureUnit)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="temperature"
|
||||
name={t("forecast.temperature")}
|
||||
stroke={CHART_COLORS.temperature}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="feelsLike"
|
||||
name={t("forecast.apparentTemperature")}
|
||||
stroke={CHART_COLORS.feelsLike}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
|
||||
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartLegend items={temperatureLegendItems} />
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
|
||||
<div className="mt-4 h-56 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
||||
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
||||
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||
formatter={(value, name) => [
|
||||
name === t("forecast.precipitation")
|
||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
||||
: `${typeof value === "number" ? value : "—"}%`,
|
||||
name,
|
||||
]}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={3}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="rainfall"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit={` ${precipitationUnitLabel}`}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="probability"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit="%"
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "hsl(var(--border) / 0.18)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
valueFormatter={(entry) =>
|
||||
entry.dataKey === "precipitation"
|
||||
? formatChartPrecipitation(
|
||||
typeof entry.value === "number" ? entry.value : null,
|
||||
language,
|
||||
precipitationUnitLabel,
|
||||
)
|
||||
: `${typeof entry.value === "number" ? entry.value : "—"}%`
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="rainfall"
|
||||
dataKey="precipitation"
|
||||
name={t("forecast.precipitation")}
|
||||
fill={CHART_COLORS.rainfall}
|
||||
radius={[5, 5, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="probability"
|
||||
type="monotone"
|
||||
dataKey="precipitationProbability"
|
||||
name={t("forecast.precipitationProbability")}
|
||||
stroke={CHART_COLORS.probability}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
|
||||
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartLegend items={rainfallLegendItems} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,29 +2,54 @@
|
||||
|
||||
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { ChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
|
||||
import {
|
||||
convertPrecipitation,
|
||||
convertWindSpeed,
|
||||
convertWindSpeedToBeaufort,
|
||||
getPrecipitationUnitLabel,
|
||||
getWindSpeedUnitLabel,
|
||||
} from "@/lib/weather-utils";
|
||||
|
||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||
const SNAPSHOT_COLORS = [
|
||||
"hsl(var(--chart-temperature))",
|
||||
"hsl(var(--chart-feels-like))",
|
||||
"hsl(var(--chart-rainfall))",
|
||||
];
|
||||
const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
|
||||
|
||||
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||
const { t } = useI18n();
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const windDigits = windSpeedUnit === "ms" ? 1 : 0;
|
||||
const windSpeed = station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
|
||||
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
|
||||
const windSpeed =
|
||||
station.windSpeed === null
|
||||
? null
|
||||
: windSpeedUnit === "bft"
|
||||
? convertWindSpeedToBeaufort(station.windSpeed)
|
||||
: Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
|
||||
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : windSpeedUnit === "bft" ? 12 : 45;
|
||||
const rainfall =
|
||||
station.rainfall === null ? null : Number(convertPrecipitation(station.rainfall, precipitationUnit).toFixed(2));
|
||||
const rows = [
|
||||
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
|
||||
{ name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, color: SNAPSHOT_COLORS[1] },
|
||||
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
|
||||
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||
{
|
||||
name: t("weather.wind"),
|
||||
value: windSpeed,
|
||||
unit: getWindSpeedUnitLabel(windSpeedUnit),
|
||||
max: windMax,
|
||||
color: SNAPSHOT_COLORS[1],
|
||||
},
|
||||
{
|
||||
name: t("weather.rainfall"),
|
||||
value: rainfall,
|
||||
unit: getPrecipitationUnitLabel(precipitationUnit),
|
||||
max: convertPrecipitation(30, precipitationUnit),
|
||||
color: SNAPSHOT_COLORS[2],
|
||||
},
|
||||
]
|
||||
.filter((row) => row.value !== null)
|
||||
.map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
@@ -32,13 +57,38 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
|
||||
<div className="mt-5 h-52 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||
<XAxis type="number" hide domain={[0, 100]} />
|
||||
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={86}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
valueFormatter={(entry) => {
|
||||
const row = entry.payload;
|
||||
return `${row?.value ?? "—"} ${row?.unit ?? ""}`.trim();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
||||
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
|
||||
{rows.map((row) => (
|
||||
<Cell fill={row.color} key={row.name} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -18,6 +18,12 @@ import { getUpcomingHourlyForecast } from "@/lib/forecast-utils";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import {
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
type DashboardSectionId,
|
||||
} from "@/lib/dashboard-sections";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -25,33 +31,94 @@ export function DashboardPage() {
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||
?? stations?.[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||
const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
|
||||
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
|
||||
const selectedStation =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
|
||||
stations?.[0];
|
||||
const activeLocation = selectedLocation;
|
||||
const activeRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||
const hasActiveLocationCoordinates =
|
||||
Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude);
|
||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude);
|
||||
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null;
|
||||
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||
const forecastLocationName = hasActiveLocationCoordinates
|
||||
? (activeLocation?.name ?? selectedStation?.name)
|
||||
: selectedStation?.name;
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(
|
||||
forecastLatitude,
|
||||
forecastLongitude,
|
||||
activeRegion,
|
||||
);
|
||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion);
|
||||
const currentForecastWeatherCode = forecast
|
||||
? (getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null)
|
||||
: null;
|
||||
const isCurrentWeatherLoading =
|
||||
Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||
if (isPending) return <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;
|
||||
|
||||
const renderDashboardSection = (sectionId: DashboardSectionId) => {
|
||||
if (!dashboardSections[sectionId]) return null;
|
||||
if (sectionId === "warnings") return <DashboardWarnings key={sectionId} />;
|
||||
if (sectionId === "weatherBrief")
|
||||
return (
|
||||
<WeatherBriefCard
|
||||
key={sectionId}
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
);
|
||||
if (sectionId === "forecast")
|
||||
return (
|
||||
<ForecastPanel
|
||||
key={sectionId}
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
);
|
||||
if (sectionId === "favorites") return <FavoritesSection key={sectionId} stations={stations} />;
|
||||
if (sectionId === "featuredStations") return <FeaturedStationsSection key={sectionId} stations={stations} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<DashboardWarnings />
|
||||
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<FavoritesSection stations={stations} />
|
||||
<FeaturedStationsSection stations={stations} />
|
||||
<WeatherHero
|
||||
station={heroStation}
|
||||
currentWeather={currentWeather}
|
||||
currentWeatherLoading={isCurrentWeatherLoading}
|
||||
currentForecastWeatherCode={currentForecastWeatherCode}
|
||||
locationName={activeLocation?.name}
|
||||
distanceKm={activeLocation?.distanceKm}
|
||||
/>
|
||||
{dashboardSectionOrder.map(renderDashboardSection)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,25 +13,83 @@ import { useI18n } from "@/lib/i18n";
|
||||
import { getProvinceForSelection } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||
export function WeatherBriefCard({
|
||||
latitude,
|
||||
longitude,
|
||||
region = "PL",
|
||||
locationName,
|
||||
}: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
region?: WeatherRegion;
|
||||
locationName: string;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
||||
const { data: warnings = [] } = useWarnings();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const isGlobalLocation = region === "GLOBAL";
|
||||
const province = isGlobalLocation
|
||||
? null
|
||||
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const relevantWarnings = useMemo(() => (isGlobalLocation ? [] : warnings), [isGlobalLocation, warnings]);
|
||||
|
||||
const brief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||
return buildWeatherBrief({
|
||||
forecast,
|
||||
warnings: relevantWarnings,
|
||||
province,
|
||||
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
});
|
||||
}, [
|
||||
forecast,
|
||||
isGlobalLocation,
|
||||
language,
|
||||
locationName,
|
||||
province,
|
||||
relevantWarnings,
|
||||
selectedLocation?.countyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
]);
|
||||
const tomorrowBrief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||
return buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings: relevantWarnings,
|
||||
province,
|
||||
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
});
|
||||
}, [
|
||||
forecast,
|
||||
isGlobalLocation,
|
||||
language,
|
||||
locationName,
|
||||
province,
|
||||
relevantWarnings,
|
||||
selectedLocation?.countyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
]);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
||||
return <LoadingSkeleton className="h-48" />;
|
||||
@@ -58,11 +116,25 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
|
||||
return (
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}>
|
||||
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
|
||||
<div
|
||||
className={
|
||||
isWarning
|
||||
? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning"
|
||||
: "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"
|
||||
}
|
||||
>
|
||||
{isWarning ? (
|
||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<CloudSun className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}>
|
||||
<p
|
||||
className={
|
||||
isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"
|
||||
}
|
||||
>
|
||||
{t("brief.label")}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
|
||||
@@ -72,7 +144,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{brief.body.slice(0, 4).map((line) => (
|
||||
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
|
||||
<li
|
||||
key={line}
|
||||
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
@@ -84,7 +159,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
|
||||
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{tomorrowBrief.body.slice(0, 3).map((line) => (
|
||||
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
|
||||
<li
|
||||
key={line}
|
||||
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
formatForecastRainfall,
|
||||
formatForecastTemperature,
|
||||
formatForecastWind,
|
||||
getEffectiveHourlyWeatherCode,
|
||||
getForecastCondition,
|
||||
isForecastNight,
|
||||
} from "@/lib/forecast-utils";
|
||||
@@ -57,6 +58,7 @@ export function DayForecastModal({
|
||||
const { language, locale, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const portalRoot = typeof document === "undefined" ? null : document.body;
|
||||
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
|
||||
@@ -81,7 +83,9 @@ export function DayForecastModal({
|
||||
}, [day, onClose]);
|
||||
|
||||
const formattedDate = day
|
||||
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(new Date(`${day.date}T12:00:00Z`))
|
||||
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(
|
||||
new Date(`${day.date}T12:00:00Z`),
|
||||
)
|
||||
: "";
|
||||
|
||||
const modal = (
|
||||
@@ -113,7 +117,9 @@ export function DayForecastModal({
|
||||
<CloudSun className="size-4" />
|
||||
{t("forecast.dayDetails")}
|
||||
</p>
|
||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{locationName}
|
||||
</h2>
|
||||
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -133,13 +139,25 @@ export function DayForecastModal({
|
||||
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
||||
<div className="mt-2 flex items-end gap-4">
|
||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</p>
|
||||
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p>
|
||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
|
||||
{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mb-2 text-2xl text-muted">
|
||||
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
||||
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} />
|
||||
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
|
||||
<DayMetric
|
||||
icon={Droplets}
|
||||
label={t("forecast.precipitation")}
|
||||
value={formatForecastRainfall(day.precipitation, language, precipitationUnit)}
|
||||
/>
|
||||
<DayMetric
|
||||
icon={Wind}
|
||||
label={t("forecast.maxWind")}
|
||||
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
|
||||
/>
|
||||
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
|
||||
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
|
||||
</div>
|
||||
@@ -150,21 +168,30 @@ export function DayForecastModal({
|
||||
<h3 className="text-sm font-semibold">{t("forecast.hourlyForDay")}</h3>
|
||||
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{hours.map((hour) => (
|
||||
{hours.map((hour) => {
|
||||
const weatherCode = getEffectiveHourlyWeatherCode(hour);
|
||||
return (
|
||||
<li
|
||||
key={hour.time}
|
||||
className="w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center"
|
||||
title={getForecastCondition(hour.weatherCode, language)}
|
||||
title={getForecastCondition(weatherCode, language)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||
<ForecastIcon code={hour.weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
|
||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||
<ForecastIcon
|
||||
code={weatherCode}
|
||||
isNight={isForecastNight(hour.time, day)}
|
||||
className="mx-auto my-3 size-6 text-accent"
|
||||
/>
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||
<Droplets className="size-3" />
|
||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudMoon, CloudRain, CloudSnow, CloudSun, MoonStar, Sun } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
CloudDrizzle,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudSun,
|
||||
MoonStar,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ForecastIcon({ code, className = "", isNight = false }: { code: number | null; className?: string; isNight?: boolean }) {
|
||||
const Icon = code === 0 ? isNight ? MoonStar : Sun
|
||||
: code === 1 || code === 2 ? isNight ? CloudMoon : CloudSun
|
||||
: code === 45 || code === 48 ? CloudFog
|
||||
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle
|
||||
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain
|
||||
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow
|
||||
: code !== null && code >= 95 ? CloudLightning
|
||||
export function ForecastIcon({
|
||||
code,
|
||||
className = "",
|
||||
isNight = false,
|
||||
}: {
|
||||
code: number | null;
|
||||
className?: string;
|
||||
isNight?: boolean;
|
||||
}) {
|
||||
const Icon =
|
||||
code === 0
|
||||
? isNight
|
||||
? MoonStar
|
||||
: Sun
|
||||
: code === 1 || code === 2
|
||||
? isNight
|
||||
? CloudMoon
|
||||
: CloudSun
|
||||
: code === 45 || code === 48
|
||||
? CloudFog
|
||||
: code !== null && code >= 51 && code <= 57
|
||||
? CloudDrizzle
|
||||
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))
|
||||
? CloudRain
|
||||
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)
|
||||
? CloudSnow
|
||||
: code !== null && code >= 95
|
||||
? CloudLightning
|
||||
: Cloud;
|
||||
return <Icon className={className} strokeWidth={1.45} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Droplets,
|
||||
RefreshCw,
|
||||
ThermometerSun,
|
||||
Wind,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||
@@ -18,6 +29,7 @@ import {
|
||||
formatForecastTemperature,
|
||||
formatForecastWind,
|
||||
getDailyForecastForHour,
|
||||
getEffectiveHourlyWeatherCode,
|
||||
getForecastCondition,
|
||||
getHourlyForecastForDay,
|
||||
getUpcomingHourlyForecast,
|
||||
@@ -25,6 +37,7 @@ import {
|
||||
} from "@/lib/forecast-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
function formatHour(value: string) {
|
||||
return value.slice(11, 16);
|
||||
@@ -64,31 +77,56 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
|
||||
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
|
||||
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
|
||||
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
|
||||
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||
const temperatureRange = minimumTemperature === null || maximumTemperature === null
|
||||
const temperatureRange =
|
||||
minimumTemperature === null || maximumTemperature === null
|
||||
? "—"
|
||||
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
||||
|
||||
return (
|
||||
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{t("forecast.nextHoursOverview")}
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
|
||||
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
|
||||
<HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} />
|
||||
<HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} />
|
||||
<HourlySummaryMetric
|
||||
icon={Wind}
|
||||
label={t("forecast.maxWind")}
|
||||
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
|
||||
/>
|
||||
<HourlySummaryMetric
|
||||
icon={CloudRain}
|
||||
label={t("forecast.rainfallTotal")}
|
||||
value={formatForecastRainfall(rainfallTotal, language, precipitationUnit)}
|
||||
/>
|
||||
<HourlySummaryMetric
|
||||
icon={Droplets}
|
||||
label={t("forecast.maxProbability")}
|
||||
value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
||||
function DailyForecastRow({
|
||||
day,
|
||||
index,
|
||||
onSelect,
|
||||
}: {
|
||||
day: DailyForecast;
|
||||
index: number;
|
||||
onSelect: (day: DailyForecast) => void;
|
||||
}) {
|
||||
const { language, locale, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
||||
return (
|
||||
<motion.li
|
||||
@@ -107,21 +145,43 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
||||
<p className="text-sm font-semibold capitalize">{label}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
|
||||
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{getForecastCondition(day.weatherCode, language)} ·{" "}
|
||||
{formatForecastRainfall(day.precipitation, language, precipitationUnit)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</span></p>
|
||||
<span className="hidden items-center gap-1 text-xs text-accent sm:flex">
|
||||
<Droplets className="size-3" />
|
||||
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-sm">
|
||||
<strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong>
|
||||
<span className="ml-2 text-muted">
|
||||
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
|
||||
</span>
|
||||
</p>
|
||||
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
||||
</motion.button>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||
export function ForecastPanel({
|
||||
latitude,
|
||||
longitude,
|
||||
region = "PL",
|
||||
locationName,
|
||||
}: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
region?: WeatherRegion;
|
||||
locationName: string;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
||||
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
||||
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
||||
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
|
||||
@@ -131,9 +191,14 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<CloudSun className="size-4" />
|
||||
{t("forecast.label")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">
|
||||
{t("forecast.description", { location: locationName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
||||
@@ -144,7 +209,10 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
) : isError || !forecast ? (
|
||||
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-muted">{t("forecast.error")}</p>
|
||||
<Button variant="glass" className="mt-4" onClick={() => refetch()}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-4" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : !forecast.hourly.length || !forecast.daily.length ? (
|
||||
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
|
||||
@@ -152,11 +220,15 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
<div className="space-y-3">
|
||||
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
|
||||
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Clock3 className="size-4 text-accent" />
|
||||
{t("forecast.hourly")}
|
||||
</h3>
|
||||
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{upcomingHours.map((hour, index) => {
|
||||
const day = getDailyForecastForHour(forecast.daily, hour.time);
|
||||
const weatherCode = getEffectiveHourlyWeatherCode(hour);
|
||||
return (
|
||||
<motion.li
|
||||
key={hour.time}
|
||||
@@ -164,14 +236,26 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
||||
className="w-[4.6rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center lg:w-[5.5rem] lg:py-4"
|
||||
title={getForecastCondition(hour.weatherCode, language)}
|
||||
title={getForecastCondition(weatherCode, language)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||
<ForecastIcon code={hour.weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
|
||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||
<ForecastIcon
|
||||
code={weatherCode}
|
||||
isNight={isForecastNight(hour.time, day)}
|
||||
className="mx-auto my-3 size-6 text-accent"
|
||||
/>
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||
<Droplets className="size-3" />
|
||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||
</p>
|
||||
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
||||
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
||||
<p
|
||||
className="flex items-center justify-center gap-1"
|
||||
title={t("forecast.apparentTemperature")}
|
||||
>
|
||||
<ThermometerSun className="size-3 text-accent" />
|
||||
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
|
||||
</p>
|
||||
@@ -181,7 +265,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
||||
<CloudRain className="size-3 text-accent" />
|
||||
{formatForecastRainfall(hour.precipitation, language)}
|
||||
{formatForecastRainfall(hour.precipitation, language, precipitationUnit)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.li>
|
||||
@@ -192,8 +276,15 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
<HourlyForecastSummary hours={upcomingHours} />
|
||||
</Card>
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
|
||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CalendarDays className="size-4 text-accent" />
|
||||
{t("forecast.daily")}
|
||||
</h3>
|
||||
<ul className="mt-2">
|
||||
{forecast.daily.map((day, index) => (
|
||||
<DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
<DayForecastCharts hours={todayHours} />
|
||||
@@ -202,7 +293,13 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
|
||||
{forecast && <ForecastSources sources={forecast.sources} />}
|
||||
|
||||
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
|
||||
<DayForecastModal
|
||||
day={selectedDay}
|
||||
hours={selectedDayHours}
|
||||
locationName={locationName}
|
||||
sources={forecast?.sources ?? []}
|
||||
onClose={closeDayDetails}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,26 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
|
||||
{t("forecast.source")}{" "}
|
||||
{hasImgw && (
|
||||
<>
|
||||
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||
<a
|
||||
href="https://meteo.imgw.pl/pogoda/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
IMGW ALARO <ExternalLink className="size-3" />
|
||||
</a>
|
||||
{", "}
|
||||
</>
|
||||
)}
|
||||
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||
<a
|
||||
href="https://open-meteo.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
Open-Meteo <ExternalLink className="size-3" />
|
||||
</a>
|
||||
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ export function HydroPage() {
|
||||
const { data: stations, isPending, isError, refetch } = useHydroStations();
|
||||
const [query, setQuery] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
|
||||
const filteredStations = useMemo(
|
||||
() =>
|
||||
(stations ?? []).filter((station) => {
|
||||
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
|
||||
return haystack.includes(query.trim().toLocaleLowerCase(locale));
|
||||
}), [locale, query, stations]);
|
||||
}),
|
||||
[locale, query, stations],
|
||||
);
|
||||
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />;
|
||||
@@ -34,13 +38,38 @@ export function HydroPage() {
|
||||
<label className="glass relative block rounded-panel p-3">
|
||||
<span className="sr-only">{t("hydro.searchLabel")}</span>
|
||||
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
||||
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}}
|
||||
placeholder={t("hydro.searchPlaceholder")}
|
||||
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
||||
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
||||
<p className="text-xs text-muted">
|
||||
{t("hydro.results", {
|
||||
total: filteredStations.length,
|
||||
visible: Math.min(visibleCount, filteredStations.length),
|
||||
})}
|
||||
</p>
|
||||
{!filteredStations.length ? (
|
||||
<EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
||||
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredStations.slice(0, visibleCount).map((station, index) => (
|
||||
<HydroStationCard key={station.id} station={station} index={index} />
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < filteredStations.length && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
|
||||
{t("hydro.more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
return (
|
||||
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}
|
||||
>
|
||||
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
||||
<div>
|
||||
<div>
|
||||
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
|
||||
<MapPin className="size-3" />
|
||||
{station.river ?? t("hydro.riverUnavailable")}
|
||||
{station.province ? ` · ${station.province}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
|
||||
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language, temperatureUnit)} />
|
||||
<HydroMetric
|
||||
icon={Droplets}
|
||||
label={t("hydro.level")}
|
||||
value={formatWaterLevel(station.waterLevel, language)}
|
||||
/>
|
||||
<HydroMetric
|
||||
icon={Thermometer}
|
||||
label={t("hydro.water")}
|
||||
value={formatTemperature(station.waterTemperature, language, temperatureUnit)}
|
||||
/>
|
||||
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
||||
</div>
|
||||
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
||||
<p className="mt-4 text-[0.7rem] text-muted">
|
||||
{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.article>
|
||||
);
|
||||
@@ -34,8 +52,13 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
||||
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-card bg-surface-muted/60 p-2.5">
|
||||
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
|
||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
||||
<p className="flex items-center gap-1 text-[0.65rem] text-muted">
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,63 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CloudSun, Droplets, Settings, TriangleAlert } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CloudSun, Droplets, Settings, TriangleAlert, WifiOff } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { isAppNavItemVisible } from "@/lib/app-sections";
|
||||
import { NAV_ITEMS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
|
||||
const icons = [CloudSun, TriangleAlert, Droplets, Settings];
|
||||
const iconsByHref: Record<string, LucideIcon> = {
|
||||
"/": CloudSun,
|
||||
"/warnings": TriangleAlert,
|
||||
"/hydro": Droplets,
|
||||
"/settings": Settings,
|
||||
};
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useI18n();
|
||||
const appSections = useWeatherStore((state) => state.appSections);
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => isAppNavItemVisible(item.href, appSections));
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateConnectionStatus = () => setIsOffline(!navigator.onLine);
|
||||
updateConnectionStatus();
|
||||
window.addEventListener("online", updateConnectionStatus);
|
||||
window.addEventListener("offline", updateConnectionStatus);
|
||||
return () => {
|
||||
window.removeEventListener("online", updateConnectionStatus);
|
||||
window.removeEventListener("offline", updateConnectionStatus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-x-hidden bg-background">
|
||||
<header className="app-header-safe sticky top-0 z-40 border-b border-border/70 bg-surface">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
wtr<span className="text-accent">.</span>
|
||||
</Link>
|
||||
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}>
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70",
|
||||
)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
@@ -35,13 +69,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{isOffline && (
|
||||
<div className="border-b border-warning/25 bg-warning/10 px-4 py-2 text-sm font-medium text-warning sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-2">
|
||||
<WifiOff className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("offline.banner")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
||||
<nav aria-label={t("nav.mobile")} className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden">
|
||||
{NAV_ITEMS.map((item, index) => {
|
||||
const Icon = icons[index];
|
||||
<nav
|
||||
aria-label={t("nav.mobile")}
|
||||
className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"
|
||||
>
|
||||
{visibleNavItems.map((item) => {
|
||||
const Icon = iconsByHref[item.href] ?? CloudSun;
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={cn("flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
active ? "bg-foreground text-background" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="max-w-full truncate">{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
CloudRain,
|
||||
Gauge,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
MapPinned,
|
||||
Palette,
|
||||
Ruler,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Thermometer,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -9,17 +27,41 @@ import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
||||
import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
|
||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import {
|
||||
DASHBOARD_SECTION_SETTINGS,
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
} from "@/lib/dashboard-sections";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||
import {
|
||||
decodeBase64UrlKey,
|
||||
deletePushSubscription,
|
||||
fetchVapidPublicKey,
|
||||
savePushSubscription,
|
||||
sendTestPushNotification,
|
||||
} from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
||||
import type { Province } from "@/types/province";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import {
|
||||
DISTANCE_UNITS,
|
||||
PRECIPITATION_UNITS,
|
||||
PRESSURE_UNITS,
|
||||
TEMPERATURE_UNITS,
|
||||
WIND_SPEED_UNITS,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
type NotificationSupportStatus =
|
||||
| "checking"
|
||||
| "unconfigured"
|
||||
| "unsupported"
|
||||
| "needs-install"
|
||||
| "default"
|
||||
| "denied"
|
||||
| "granted";
|
||||
|
||||
function isStandaloneApp() {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -37,12 +79,23 @@ function isAppleMobilePlatform() {
|
||||
|
||||
function getNotificationSupportStatus(): NotificationSupportStatus {
|
||||
if (typeof window === "undefined") return "checking";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
|
||||
return "unsupported";
|
||||
if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) {
|
||||
function SettingsRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Settings;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -63,24 +116,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
|
||||
checked
|
||||
? "border-accent/55 bg-accent"
|
||||
: "border-border/70 bg-surface-muted"
|
||||
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
|
||||
} ${disabled ? "opacity-55" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`} />
|
||||
<span
|
||||
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSwitchLabel({ icon: Icon, title, description, checked, disabled, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
|
||||
function NotificationSwitchLabel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
|
||||
checked
|
||||
? "border-accent/45 bg-accent/10"
|
||||
: "border-border/60 bg-surface"
|
||||
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
|
||||
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
|
||||
>
|
||||
<input
|
||||
@@ -102,10 +167,19 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
|
||||
);
|
||||
}
|
||||
|
||||
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = {
|
||||
const windSpeedUnitKeys: Record<
|
||||
WindSpeedUnit,
|
||||
| "settings.units.wind.ms"
|
||||
| "settings.units.wind.kmh"
|
||||
| "settings.units.wind.mph"
|
||||
| "settings.units.wind.kt"
|
||||
| "settings.units.wind.bft"
|
||||
> = {
|
||||
ms: "settings.units.wind.ms",
|
||||
kmh: "settings.units.wind.kmh",
|
||||
mph: "settings.units.wind.mph",
|
||||
kt: "settings.units.wind.kt",
|
||||
bft: "settings.units.wind.bft",
|
||||
};
|
||||
|
||||
const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c" | "settings.units.temperature.f"> = {
|
||||
@@ -113,6 +187,33 @@ const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c
|
||||
f: "settings.units.temperature.f",
|
||||
};
|
||||
|
||||
const precipitationUnitKeys: Record<
|
||||
PrecipitationUnit,
|
||||
"settings.units.precipitation.mm" | "settings.units.precipitation.cm" | "settings.units.precipitation.in"
|
||||
> = {
|
||||
mm: "settings.units.precipitation.mm",
|
||||
cm: "settings.units.precipitation.cm",
|
||||
in: "settings.units.precipitation.in",
|
||||
};
|
||||
|
||||
const pressureUnitKeys: Record<
|
||||
PressureUnit,
|
||||
| "settings.units.pressure.hpa"
|
||||
| "settings.units.pressure.kpa"
|
||||
| "settings.units.pressure.inhg"
|
||||
| "settings.units.pressure.mmhg"
|
||||
> = {
|
||||
hpa: "settings.units.pressure.hpa",
|
||||
kpa: "settings.units.pressure.kpa",
|
||||
inhg: "settings.units.pressure.inhg",
|
||||
mmhg: "settings.units.pressure.mmhg",
|
||||
};
|
||||
|
||||
const distanceUnitKeys: Record<DistanceUnit, "settings.units.distance.km" | "settings.units.distance.mi"> = {
|
||||
km: "settings.units.distance.km",
|
||||
mi: "settings.units.distance.mi",
|
||||
};
|
||||
|
||||
export function SettingsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
||||
@@ -120,6 +221,7 @@ export function SettingsPage() {
|
||||
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||
const [provinceDropdownOpen, setProvinceDropdownOpen] = useState(false);
|
||||
const { data: stations } = useWeatherStations();
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
@@ -131,6 +233,12 @@ export function SettingsPage() {
|
||||
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
|
||||
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
|
||||
const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
|
||||
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
|
||||
const appSections = useWeatherStore((state) => state.appSections);
|
||||
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
||||
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
|
||||
@@ -138,21 +246,48 @@ export function SettingsPage() {
|
||||
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
||||
const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit);
|
||||
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
|
||||
const setPrecipitationUnit = useWeatherStore((state) => state.setPrecipitationUnit);
|
||||
const setPressureUnit = useWeatherStore((state) => state.setPressureUnit);
|
||||
const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit);
|
||||
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
|
||||
const moveDashboardSection = useWeatherStore((state) => state.moveDashboardSection);
|
||||
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
|
||||
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||
?? stations?.[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||
const selectedStation =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
|
||||
stations?.[0];
|
||||
const activeLocation = selectedLocation;
|
||||
const selectedRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude)
|
||||
? activeLocation?.latitude
|
||||
: (stationPosition?.latitude ?? null);
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
|
||||
? activeLocation?.longitude
|
||||
: (stationPosition?.longitude ?? null);
|
||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||
const notificationCountyTeryt = activeLocation?.countyTeryt ?? null;
|
||||
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
|
||||
const selectedProvince =
|
||||
selectedRegion === "PL"
|
||||
? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID)
|
||||
: null;
|
||||
const effectiveProvince =
|
||||
selectedRegion === "PL" ? (provinceMode === "manual" ? manualProvince : selectedProvince) : null;
|
||||
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
|
||||
const effectiveProvinceLabel =
|
||||
selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince
|
||||
? formatProvinceName(effectiveProvince, language)
|
||||
: t("settings.notifications.noProvince");
|
||||
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
||||
const orderedDashboardSections = dashboardSectionOrder
|
||||
.map((sectionId) => DASHBOARD_SECTION_SETTINGS.find((section) => section.id === sectionId))
|
||||
.filter((section): section is (typeof DASHBOARD_SECTION_SETTINGS)[number] => Boolean(section));
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
@@ -172,7 +307,7 @@ export function SettingsPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !vapidPublicKey || notificationStatus !== "granted") return;
|
||||
const province = effectiveProvince;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -181,14 +316,20 @@ export function SettingsPage() {
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription || cancelled) return;
|
||||
await savePushSubscription(subscription, province, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
pressureUnit,
|
||||
distanceUnit,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -196,7 +337,38 @@ export function SettingsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||
}, [
|
||||
canUseOfficialWarnings,
|
||||
effectiveProvince,
|
||||
language,
|
||||
morningBriefEnabled,
|
||||
notificationCountyTeryt,
|
||||
notificationLatitude,
|
||||
notificationLocationName,
|
||||
notificationLongitude,
|
||||
notificationStatus,
|
||||
notificationsEnabled,
|
||||
notificationTimezone,
|
||||
selectedRegion,
|
||||
temperatureUnit,
|
||||
precipitationUnit,
|
||||
pressureUnit,
|
||||
distanceUnit,
|
||||
tomorrowBriefEnabled,
|
||||
vapidPublicKey,
|
||||
windSpeedUnit,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provinceDropdownOpen) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setProvinceDropdownOpen(false);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [provinceDropdownOpen]);
|
||||
|
||||
const notificationStatusLabel = useMemo(() => {
|
||||
switch (notificationStatus) {
|
||||
@@ -217,15 +389,22 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
const canUsePush = Boolean(
|
||||
vapidPublicKey &&
|
||||
(selectedRegion === "GLOBAL" || effectiveProvince) &&
|
||||
notificationStatus !== "unsupported" &&
|
||||
notificationStatus !== "needs-install" &&
|
||||
notificationStatus !== "denied" &&
|
||||
notificationStatus !== "unconfigured",
|
||||
);
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register("/sw.js"));
|
||||
}
|
||||
|
||||
async function enableWarningNotifications() {
|
||||
if (!vapidPublicKey || !effectiveProvince) {
|
||||
if (!vapidPublicKey || (selectedRegion === "PL" && !effectiveProvince)) {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
return;
|
||||
}
|
||||
@@ -244,19 +423,27 @@ export function SettingsPage() {
|
||||
}
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
|
||||
const subscription =
|
||||
existingSubscription ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
}));
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings,
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
pressureUnit,
|
||||
distanceUnit,
|
||||
});
|
||||
setNotificationsEnabled(true);
|
||||
setNotificationMessage(t("settings.notifications.saveSuccess"));
|
||||
@@ -307,20 +494,27 @@ export function SettingsPage() {
|
||||
|
||||
async function updateMorningBriefPreference(enabled: boolean) {
|
||||
setMorningBriefEnabled(enabled);
|
||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||
try {
|
||||
const preferences = useWeatherStore.getState();
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
morningBriefEnabled: enabled,
|
||||
tomorrowBriefEnabled,
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
|
||||
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
pressureUnit,
|
||||
distanceUnit,
|
||||
});
|
||||
} catch {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
@@ -329,20 +523,27 @@ export function SettingsPage() {
|
||||
|
||||
async function updateTomorrowBriefPreference(enabled: boolean) {
|
||||
setTomorrowBriefEnabled(enabled);
|
||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||
try {
|
||||
const preferences = useWeatherStore.getState();
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled: enabled,
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
|
||||
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
precipitationUnit,
|
||||
pressureUnit,
|
||||
distanceUnit,
|
||||
});
|
||||
} catch {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
@@ -352,20 +553,35 @@ export function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<p className="section-kicker flex items-center gap-2"><Settings className="size-4" />{t("settings.section")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<Settings className="size-4" />
|
||||
{t("settings.section")}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
|
||||
</section>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<SettingsRow icon={Languages} title={t("settings.language.title")} description={t("settings.language.description")}>
|
||||
<SettingsRow
|
||||
icon={Languages}
|
||||
title={t("settings.language.title")}
|
||||
description={t("settings.language.description")}
|
||||
>
|
||||
<LanguageToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
||||
<ThemeToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
|
||||
<SettingsRow
|
||||
icon={Thermometer}
|
||||
title={t("settings.units.temperature.title")}
|
||||
description={t("settings.units.temperature.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.temperature.label")}
|
||||
>
|
||||
{TEMPERATURE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -384,8 +600,16 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
|
||||
<SettingsRow
|
||||
icon={Wind}
|
||||
title={t("settings.units.wind.title")}
|
||||
description={t("settings.units.wind.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.wind.label")}
|
||||
>
|
||||
{WIND_SPEED_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -404,6 +628,181 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
icon={CloudRain}
|
||||
title={t("settings.units.precipitation.title")}
|
||||
description={t("settings.units.precipitation.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.precipitation.label")}
|
||||
>
|
||||
{PRECIPITATION_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={precipitationUnit === unit}
|
||||
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||
precipitationUnit === unit
|
||||
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
||||
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setPrecipitationUnit(unit)}
|
||||
>
|
||||
{t(precipitationUnitKeys[unit])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
icon={Gauge}
|
||||
title={t("settings.units.pressure.title")}
|
||||
description={t("settings.units.pressure.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.pressure.label")}
|
||||
>
|
||||
{PRESSURE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={pressureUnit === unit}
|
||||
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||
pressureUnit === unit
|
||||
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
||||
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setPressureUnit(unit)}
|
||||
>
|
||||
{t(pressureUnitKeys[unit])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
icon={Ruler}
|
||||
title={t("settings.units.distance.title")}
|
||||
description={t("settings.units.distance.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.distance.label")}
|
||||
>
|
||||
{DISTANCE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={distanceUnit === unit}
|
||||
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||
distanceUnit === unit
|
||||
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
||||
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setDistanceUnit(unit)}
|
||||
>
|
||||
{t(distanceUnitKeys[unit])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
||||
<LayoutDashboard className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("settings.dashboardSections.title")}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.dashboardSections.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{orderedDashboardSections.map((section, index) => {
|
||||
const sectionTitle = t(section.titleKey);
|
||||
const checked = dashboardSections[section.id];
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`rounded-card border px-3 py-3 transition-colors ${
|
||||
checked ? "border-accent/35 bg-accent/5" : "border-border/60 bg-surface"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-4">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">{sectionTitle}</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-muted">{t(section.descriptionKey)}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => setDashboardSectionVisible(section.id, event.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<SwitchIndicator checked={checked} />
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-center" aria-label={sectionTitle}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
className="size-9"
|
||||
disabled={index === 0}
|
||||
aria-label={t("settings.dashboardSections.moveUp", { section: sectionTitle })}
|
||||
onClick={() => moveDashboardSection(section.id, -1)}
|
||||
>
|
||||
<ArrowUp className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
className="size-9"
|
||||
disabled={index === orderedDashboardSections.length - 1}
|
||||
aria-label={t("settings.dashboardSections.moveDown", { section: sectionTitle })}
|
||||
onClick={() => moveDashboardSection(section.id, 1)}
|
||||
>
|
||||
<ArrowDown className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("settings.appSections.title")}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.appSections.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{APP_SECTION_SETTINGS.map((section) => (
|
||||
<NotificationSwitchLabel
|
||||
key={section.id}
|
||||
icon={Settings}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descriptionKey)}
|
||||
checked={appSections[section.id]}
|
||||
onChange={(checked) => setAppSectionVisible(section.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
@@ -424,7 +823,9 @@ export function SettingsPage() {
|
||||
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">
|
||||
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -434,12 +835,17 @@ export function SettingsPage() {
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "selected"}
|
||||
disabled={selectedRegion === "GLOBAL"}
|
||||
onChange={() => setProvinceMode("selected")}
|
||||
className="mt-1 accent-accent"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedProvince ? formatProvinceName(selectedProvince, language) : t("settings.notifications.noProvince")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{selectedProvince
|
||||
? formatProvinceName(selectedProvince, language)
|
||||
: t("settings.notifications.noProvince")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
@@ -447,6 +853,7 @@ export function SettingsPage() {
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "manual"}
|
||||
disabled={selectedRegion === "GLOBAL"}
|
||||
onChange={() => {
|
||||
setProvinceMode("manual");
|
||||
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
||||
@@ -455,19 +862,61 @@ export function SettingsPage() {
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{t("settings.notifications.regionManual")}</span>
|
||||
<select
|
||||
<span className="relative mt-2 block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.notifications.regionManual")}
|
||||
value={manualProvince ?? selectedProvince ?? "mazowieckie"}
|
||||
disabled={provinceMode !== "manual"}
|
||||
onChange={(event) => setManualProvince(event.target.value as Province)}
|
||||
className="surface-control mt-2 w-full rounded-control px-3 py-2 text-sm disabled:opacity-60"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={provinceDropdownOpen}
|
||||
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
|
||||
setProvinceDropdownOpen(false);
|
||||
}}
|
||||
onClick={() => setProvinceDropdownOpen((current) => !current)}
|
||||
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="truncate">{formatProvinceName(manualProvinceValue, language)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
{provinceDropdownOpen && provinceMode === "manual" && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("settings.notifications.regionManual")}
|
||||
className="surface-control absolute inset-x-0 top-[calc(100%+0.5rem)] z-30 max-h-72 overflow-y-auto rounded-panel p-1.5 shadow-card"
|
||||
>
|
||||
{PROVINCES.map((province) => (
|
||||
<option key={province} value={province}>{formatProvinceName(province, language)}</option>
|
||||
<button
|
||||
key={province}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={manualProvinceValue === province}
|
||||
onClick={() => {
|
||||
setManualProvince(province);
|
||||
setProvinceDropdownOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-card px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
manualProvinceValue === province
|
||||
? "bg-accent/10 font-semibold text-accent"
|
||||
: "text-foreground hover:bg-surface-muted/70"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{formatProvinceName(province, language)}</span>
|
||||
{manualProvinceValue === province && (
|
||||
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -495,9 +944,15 @@ export function SettingsPage() {
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<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 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-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-accent">
|
||||
{isSavingSubscription
|
||||
? t("settings.notifications.saving")
|
||||
@@ -506,7 +961,10 @@ export function SettingsPage() {
|
||||
: t("settings.notifications.disabledStatus")}
|
||||
</span>
|
||||
</span>
|
||||
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} />
|
||||
<SwitchIndicator
|
||||
checked={notificationsEnabled}
|
||||
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
|
||||
/>
|
||||
</button>
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
|
||||
@@ -529,13 +987,22 @@ export function SettingsPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
|
||||
onClick={sendTestNotification}
|
||||
>
|
||||
<BellRing className="size-4" />
|
||||
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
|
||||
{isSendingTestNotification
|
||||
? t("settings.notifications.sendingTest")
|
||||
: t("settings.notifications.sendTest")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{t("settings.notifications.implementationNote") && (
|
||||
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { CircleCheckBig } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = CircleCheckBig,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div>
|
||||
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
|
||||
</Card>
|
||||
|
||||
@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function ErrorState({ title, description, onRetry }: { title?: string; description?: string; onRetry: () => void }) {
|
||||
export function ErrorState({
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div>
|
||||
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning">
|
||||
<TriangleAlert className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
|
||||
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
|
||||
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||
<Button variant="glass" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />;
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)}
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoadingSkeleton() {
|
||||
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
|
||||
<div className="space-y-5" aria-busy="true">
|
||||
<LoadingSkeleton className="h-[25rem]" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => <LoadingSkeleton className="h-36" key={index} />)}
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<LoadingSkeleton className="h-36" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Languages } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Languages } from "lucide-react";
|
||||
import { useI18n, type Language } from "@/lib/i18n";
|
||||
|
||||
const languageOptions: Language[] = ["pl", "en"];
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const labels: Record<Language, string> = {
|
||||
pl: t("language.polish"),
|
||||
en: t("language.english"),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<label className="relative flex items-center">
|
||||
<span className="sr-only">{t("language.label")}</span>
|
||||
<Languages className="pointer-events-none absolute left-3 size-4 text-muted" />
|
||||
<select
|
||||
<div ref={rootRef} className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("language.label")}
|
||||
value={language}
|
||||
onChange={(event) => setLanguage(event.target.value as Language)}
|
||||
className="surface-control h-10 appearance-none rounded-control py-2 pl-9 pr-3 text-xs font-semibold uppercase text-foreground transition hover:bg-surface-raised/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="surface-control flex h-11 min-w-36 items-center justify-between gap-3 rounded-control px-3.5 py-2 text-left text-sm font-medium text-foreground transition hover:bg-surface-raised/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<option value="pl">{t("language.polish")}</option>
|
||||
<option value="en">{t("language.english")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<Languages className="size-4 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span className="truncate">{labels[language]}</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("language.label")}
|
||||
className="surface-control absolute right-0 top-[calc(100%+0.5rem)] z-30 w-44 overflow-hidden rounded-panel p-1.5 shadow-card"
|
||||
>
|
||||
{languageOptions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={language === option}
|
||||
onClick={() => {
|
||||
setLanguage(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-card px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
language === option
|
||||
? "bg-accent/10 font-semibold text-accent"
|
||||
: "text-foreground hover:bg-surface-muted/70"
|
||||
}`}
|
||||
>
|
||||
{labels[option]}
|
||||
{language === option && <span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function DashboardWarnings() {
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
const isGlobalLocation = selectedLocation?.region === "GLOBAL";
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => setNow(Date.now()), 0);
|
||||
@@ -52,20 +53,28 @@ export function DashboardWarnings() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null;
|
||||
const province = isGlobalLocation
|
||||
? null
|
||||
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const regionLabel = selectedLocation?.countyTeryt
|
||||
? (selectedLocation.district ?? selectedLocation.name)
|
||||
: province
|
||||
? formatProvinceName(province, language)
|
||||
: null;
|
||||
const relevantWarnings = useMemo(() => {
|
||||
if (!warnings || !province || now === null) return [];
|
||||
if (isGlobalLocation || !warnings || !province || now === null) return [];
|
||||
|
||||
return warnings
|
||||
.filter((warning) => {
|
||||
const validTo = getTimestamp(warning.validTo);
|
||||
return warning.kind === "meteo"
|
||||
&& warningMatchesLocalSelection(warning, province, selectedLocation)
|
||||
&& (validTo === null || validTo > now);
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
warningMatchesLocalSelection(warning, province, selectedLocation) &&
|
||||
(validTo === null || validTo > now)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => compareDashboardWarnings(a, b, now));
|
||||
}, [now, province, selectedLocation, warnings]);
|
||||
}, [isGlobalLocation, now, province, selectedLocation, warnings]);
|
||||
|
||||
if (!province || !regionLabel || !relevantWarnings.length || now === null) return null;
|
||||
|
||||
@@ -86,12 +95,8 @@ export function DashboardWarnings() {
|
||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
|
||||
IMGW · {regionLabel}
|
||||
</p>
|
||||
<h2 className="mt-1 text-base font-semibold text-foreground">
|
||||
{t("warnings.dashboard.title")}
|
||||
</h2>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
|
||||
<h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
@@ -108,13 +113,11 @@ export function DashboardWarnings() {
|
||||
const active = isWarningActive(warning, now);
|
||||
const validityLabel = active
|
||||
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) })
|
||||
: warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
|
||||
: warning.validFrom &&
|
||||
t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
|
||||
|
||||
return (
|
||||
<article
|
||||
key={warning.id}
|
||||
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
|
||||
>
|
||||
<article key={warning.id} className="rounded-card border border-warning/20 bg-surface px-3.5 py-3">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
|
||||
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
|
||||
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
|
||||
|
||||
@@ -12,25 +12,57 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
|
||||
const { language, t } = useI18n();
|
||||
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
|
||||
const level = warning.level;
|
||||
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
|
||||
const areasLabel = warning.areas.length > 8
|
||||
const levelLabel =
|
||||
level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
|
||||
const areasLabel =
|
||||
warning.areas.length > 8
|
||||
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
|
||||
: warning.areas.join("; ");
|
||||
return (
|
||||
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}
|
||||
>
|
||||
<Card className="h-full overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
|
||||
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
|
||||
<div className="rounded-card bg-warning/10 p-2.5 text-warning">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-control border px-2.5 py-1 text-xs font-semibold",
|
||||
level === -1
|
||||
? "border-warning/30 bg-warning/10 text-warning"
|
||||
: "border-warning/25 bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{levelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">
|
||||
{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">
|
||||
{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}
|
||||
</h2>
|
||||
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
|
||||
<div className="mt-5 space-y-2 text-xs text-muted">
|
||||
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
||||
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
||||
<p className="flex items-start gap-2">
|
||||
<CalendarClock className="mt-0.5 size-3.5 shrink-0" />
|
||||
{formatDateTime(warning.validFrom, language)} —{" "}
|
||||
{warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}
|
||||
</p>
|
||||
<p className="flex items-start gap-2">
|
||||
<MapPinned className="mt-0.5 size-3.5 shrink-0" />
|
||||
{areasLabel || t("warnings.areaUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>}
|
||||
{warning.probability !== null && (
|
||||
<p className="mt-4 text-xs font-medium text-muted">
|
||||
{t("warnings.probability", { value: warning.probability })}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.article>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
|
||||
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{warnings.map((warning, index) => <WarningCard key={warning.id} warning={warning} index={index + indexOffset} />)}
|
||||
{warnings.map((warning, index) => (
|
||||
<WarningCard key={warning.id} warning={warning} index={index + indexOffset} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,35 +28,61 @@ export function WarningsPanel() {
|
||||
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
if (selectedLocation?.region === "GLOBAL") {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("warnings.globalUnavailableTitle")}
|
||||
description={t("warnings.globalUnavailableDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
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")} />;
|
||||
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
if (!province) return <WarningGrid warnings={warnings} />;
|
||||
|
||||
const provinceLabel = formatProvinceName(province, language);
|
||||
const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel;
|
||||
const localLabel = selectedLocation?.countyTeryt
|
||||
? (selectedLocation.district ?? selectedLocation.name)
|
||||
: provinceLabel;
|
||||
const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation));
|
||||
const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation));
|
||||
const otherWarnings = warnings.filter(
|
||||
(warning) => !warningMatchesLocalSelection(warning, province, selectedLocation),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-9">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<MapPinned className="size-4" />
|
||||
{t("warnings.myProvince")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
{t("warnings.myProvinceDescription", { province: localLabel })}
|
||||
</p>
|
||||
</div>
|
||||
{localWarnings.length
|
||||
? <WarningGrid warnings={localWarnings} />
|
||||
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />}
|
||||
{localWarnings.length ? (
|
||||
<WarningGrid warnings={localWarnings} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t("warnings.myProvinceEmptyTitle")}
|
||||
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{otherWarnings.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
<Map className="size-4" />
|
||||
{t("warnings.otherRegions")}
|
||||
</p>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
|
||||
</div>
|
||||
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
|
||||
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils";
|
||||
import {
|
||||
calculateFeelsLike,
|
||||
formatHumidity,
|
||||
formatPressure,
|
||||
formatRainfall,
|
||||
formatTemperature,
|
||||
formatWind,
|
||||
getWindDirection,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { MetricCard } from "@/components/weather/metric-card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
@@ -11,19 +19,61 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
|
||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||
const metrics = [
|
||||
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language, temperatureUnit), detail: t("weather.feelsLikeDetail") },
|
||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
|
||||
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
|
||||
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") },
|
||||
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") },
|
||||
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
|
||||
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language, temperatureUnit), detail: t("weather.temperatureDetail") },
|
||||
{
|
||||
icon: Thermometer,
|
||||
label: t("weather.feelsLike"),
|
||||
value: formatTemperature(feelsLike, language, temperatureUnit),
|
||||
detail: t("weather.feelsLikeDetail"),
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
label: t("weather.humidity"),
|
||||
value: formatHumidity(station.humidity, language),
|
||||
detail: t("weather.humidityDetail"),
|
||||
},
|
||||
{
|
||||
icon: Gauge,
|
||||
label: t("weather.pressure"),
|
||||
value: formatPressure(station.pressure, language, pressureUnit),
|
||||
detail: t("weather.pressureDetail"),
|
||||
},
|
||||
{
|
||||
icon: Wind,
|
||||
label: t("weather.windSpeed"),
|
||||
value: formatWind(station.windSpeed, null, language, windSpeedUnit),
|
||||
detail: t("weather.windSpeedDetail"),
|
||||
},
|
||||
{
|
||||
icon: Navigation,
|
||||
label: t("weather.windDirection"),
|
||||
value:
|
||||
station.windDirection === null
|
||||
? t("common.noData")
|
||||
: `${station.windDirection}° ${getWindDirection(station.windDirection)}`,
|
||||
detail: t("weather.windDirectionDetail"),
|
||||
},
|
||||
{
|
||||
icon: Umbrella,
|
||||
label: t("weather.rainfallTotal"),
|
||||
value: formatRainfall(station.rainfall, language, precipitationUnit),
|
||||
detail: t("weather.rainfallDetail"),
|
||||
},
|
||||
{
|
||||
icon: CloudSun,
|
||||
label: t("weather.airTemperature"),
|
||||
value: formatTemperature(station.temperature, language, temperatureUnit),
|
||||
detail: t("weather.temperatureDetail"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{metrics.map((metric, index) => <MetricCard {...metric} index={index} key={metric.label} />)}
|
||||
{metrics.map((metric, index) => (
|
||||
<MetricCard {...metric} index={index} key={metric.label} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { fetchReverseLocation } from "@/lib/location-api";
|
||||
import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils";
|
||||
import { createSelectedLocation, type LocatedSynopStation } from "@/lib/location-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
|
||||
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
|
||||
@@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
setMessage(t("location.gpsUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!stations.length) {
|
||||
setMessage(t("location.gpsStationsPending"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async ({ coords }) => {
|
||||
@@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
name: t("location.gpsFallbackName"),
|
||||
province: null,
|
||||
district: null,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
admin1: null,
|
||||
admin2: null,
|
||||
timezone: null,
|
||||
region: "GLOBAL" as const,
|
||||
}));
|
||||
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations);
|
||||
if (!nearest) {
|
||||
setMessage(t("location.gpsStationsPending"));
|
||||
return;
|
||||
}
|
||||
selectLocation(nearest);
|
||||
setMessage(t("location.gpsSelected", { location: nearest.name }));
|
||||
const selected = createSelectedLocation({ ...place, latitude, longitude }, stations);
|
||||
selectLocation(selected);
|
||||
setMessage(t("location.gpsSelected", { location: selected.name }));
|
||||
} catch {
|
||||
setMessage(t("location.gpsPositionUnavailable"));
|
||||
} finally {
|
||||
@@ -70,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
},
|
||||
(error) => {
|
||||
setIsLocating(false);
|
||||
setMessage(error.code === error.PERMISSION_DENIED
|
||||
setMessage(
|
||||
error.code === error.PERMISSION_DENIED
|
||||
? t("location.gpsDenied")
|
||||
: error.code === error.TIMEOUT
|
||||
? t("location.gpsTimeout")
|
||||
: t("location.gpsPositionUnavailable"));
|
||||
: t("location.gpsPositionUnavailable"),
|
||||
);
|
||||
},
|
||||
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
|
||||
);
|
||||
@@ -89,30 +88,43 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return;
|
||||
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
|
||||
if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
|
||||
navigator.permissions
|
||||
.query({ name: "geolocation" })
|
||||
.then((permission) => {
|
||||
if (permission.state !== "granted" || autoLocated.current) return;
|
||||
autoLocated.current = true;
|
||||
locate();
|
||||
}).catch(() => undefined);
|
||||
}, [locate, stations.length]);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [locate]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{showPrompt && (
|
||||
<div className="glass-subtle rounded-card p-3.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div>
|
||||
<div className="rounded-control bg-accent/10 p-2 text-accent">
|
||||
<MapPinned className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</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">
|
||||
<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 ? t("location.gpsLocating") : t("location.gpsAllow")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button>
|
||||
<Button type="button" variant="ghost" onClick={dismissPrompt}>
|
||||
<X className="size-4" />
|
||||
{t("location.gpsNotNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,15 +132,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
)}
|
||||
{!showPrompt && (
|
||||
<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 ? t("location.gpsLocating") : t("location.gpsUse")}
|
||||
</Button>
|
||||
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>}
|
||||
{message && (
|
||||
<p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[0.68rem] text-muted">
|
||||
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
|
||||
{t("location.gpsAttribution")}{" "}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
OpenStreetMap <ExternalLink className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 fill-rose-500 text-rose-500" />{t("favorites.title")}</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{favorites.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Heart className="size-4 fill-rose-500 text-rose-500" />
|
||||
{t("favorites.title")}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{favorites.map((station, index) => (
|
||||
<StationCard key={station.id} station={station} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<MapPinned className="size-4" />
|
||||
{t("featured.label")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
|
||||
@@ -30,11 +33,16 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
||||
type="button"
|
||||
key={station.id}
|
||||
onClick={() => selectStation(station.id)}
|
||||
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")}
|
||||
className={cn(
|
||||
"glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
active && "border-accent/60 bg-surface-raised/90",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
|
||||
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language, temperatureUnit)}</span>
|
||||
<span className="mt-1 block text-xl font-semibold tracking-tight">
|
||||
{formatTemperature(station.temperature, language, temperatureUnit)}
|
||||
</span>
|
||||
</span>
|
||||
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
|
||||
</button>
|
||||
|
||||
@@ -2,28 +2,41 @@
|
||||
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
|
||||
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
||||
import { useLocationSearch } from "@/hooks/use-location-search";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { formatDistance } from "@/lib/weather-utils";
|
||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
||||
|
||||
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
||||
export function LocationSearch({
|
||||
stations,
|
||||
positions,
|
||||
}: {
|
||||
stations: SynopStation[];
|
||||
positions: MeteoStationPosition[];
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
|
||||
const selectLocation = useWeatherStore((state) => state.selectLocation);
|
||||
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
|
||||
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
||||
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
(locations ?? []).map((location) => ({
|
||||
location,
|
||||
nearest: findNearestSynopStation(location, locatedStations),
|
||||
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
|
||||
selected: createSelectedLocation(location, locatedStations),
|
||||
})),
|
||||
[locatedStations, locations],
|
||||
);
|
||||
const showSuggestions = isFocused && query.trim().length >= 2;
|
||||
const isPreparingStations = positions.length === 0;
|
||||
const isPreparingStations =
|
||||
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
|
||||
|
||||
function chooseLocation(location: SelectedLocation) {
|
||||
selectLocation(location);
|
||||
@@ -32,7 +45,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
const firstSuggestion = suggestions[0]?.nearest;
|
||||
const firstSuggestion = suggestions[0]?.selected;
|
||||
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
|
||||
event.preventDefault();
|
||||
chooseLocation(firstSuggestion);
|
||||
@@ -48,7 +61,11 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
<div className="relative z-20">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">{t("location.searchLabel")}</span>
|
||||
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
|
||||
{isFetching || isPreparingStations ? (
|
||||
<LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" />
|
||||
) : (
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
||||
)}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -57,40 +74,84 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
||||
placeholder={t("location.placeholder")}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
|
||||
/>
|
||||
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("location.clear")}
|
||||
onClick={() => setQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
{showSuggestions && (
|
||||
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
|
||||
{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> :
|
||||
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
||||
suggestions.map(({ location, nearest }) => nearest && (
|
||||
{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>
|
||||
) : !isFetching && !suggestions.length ? (
|
||||
<p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p>
|
||||
) : (
|
||||
suggestions.map(({ location, selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
</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} · {formatDistance(selected.distanceKm, language, distanceUnit)}
|
||||
</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>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CurrentLocationControl stations={locatedStations} />
|
||||
{selectedLocation && (
|
||||
<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: formatDistance(selectedLocation.distanceKm, language, distanceUnit),
|
||||
})
|
||||
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 px-1 text-[0.68rem] text-muted">
|
||||
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
|
||||
{t("location.attribution")}{" "}
|
||||
<a
|
||||
href="https://open-meteo.com/en/docs/geocoding-api"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
Open-Meteo / GeoNames
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { icon: LucideIcon; label: string; value: string; detail?: string; index?: number }) {
|
||||
export function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
index = 0,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
index?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.04, duration: 0.35 }}
|
||||
>
|
||||
<Card className="h-full p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
|
||||
<Icon className="size-4 text-accent" />
|
||||
|
||||
@@ -4,7 +4,13 @@ import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { Droplets, Gauge, Heart, Wind } from "lucide-react";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils";
|
||||
import {
|
||||
formatHumidity,
|
||||
formatPressure,
|
||||
formatTemperature,
|
||||
formatWind,
|
||||
getWeatherMoodFromData,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,28 +25,61 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
||||
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
|
||||
const favorite = favorites.includes(station.id);
|
||||
const mood = getWeatherMoodFromData(station);
|
||||
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}
|
||||
>
|
||||
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<Link
|
||||
href={`/station/${station.id}`}
|
||||
onClick={() => selectStation(station.id)}
|
||||
className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<p className="truncate text-sm font-semibold">{station.name}</p>
|
||||
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language, temperatureUnit)}</p>
|
||||
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">
|
||||
{formatTemperature(station.temperature, language, temperatureUnit)}
|
||||
</p>
|
||||
</Link>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<WeatherIcon mood={mood} className="size-9 text-accent" />
|
||||
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 p-0"
|
||||
aria-label={
|
||||
favorite
|
||||
? t("favorites.removeStation", { name: station.name })
|
||||
: t("favorites.addStation", { name: station.name })
|
||||
}
|
||||
onClick={() => toggleFavorite(station.id)}
|
||||
>
|
||||
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
|
||||
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span>
|
||||
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
|
||||
<Link
|
||||
href={`/station/${station.id}`}
|
||||
onClick={() => selectStation(station.id)}
|
||||
className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<Droplets className="size-3" />
|
||||
{formatHumidity(station.humidity, language)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Wind className="size-3" />
|
||||
{compactWind}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Gauge className="size-3" />
|
||||
{station.pressure === null ? "—" : formatPressure(station.pressure, language, pressureUnit)}
|
||||
</span>
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("station.all")}
|
||||
</Link>
|
||||
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
|
||||
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||
{favorite ? t("favorites.remove") : t("favorites.add")}
|
||||
@@ -43,12 +49,21 @@ export function StationDetailPage({ id }: { id: string }) {
|
||||
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
<SnapshotChart station={station} />
|
||||
<Card className="p-5">
|
||||
<div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div>
|
||||
<div className="section-kicker flex items-center gap-2">
|
||||
<ShieldCheck className="size-5" />
|
||||
<p>{t("station.quality")}</p>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
|
||||
<dl className="mt-6 space-y-3 text-sm">
|
||||
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
|
||||
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
|
||||
<div>
|
||||
<dt className="text-muted">{t("station.lastMeasurement")}</dt>
|
||||
<dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">{t("station.source")}</dt>
|
||||
<dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,13 @@ const lightningBolts = [
|
||||
},
|
||||
];
|
||||
|
||||
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
|
||||
export function WeatherEffects({
|
||||
precipitation10m,
|
||||
thunderstorm = false,
|
||||
}: {
|
||||
precipitation10m?: number | null;
|
||||
thunderstorm?: boolean;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const isRaining = (precipitation10m ?? 0) > 0;
|
||||
|
||||
@@ -55,7 +61,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
|
||||
<motion.span
|
||||
key={`rain-${index}`}
|
||||
initial={{ y: "-18vh", opacity: 0 }}
|
||||
animate={reduceMotion ? { y: `${(index % 8) * 14}vh`, opacity: 0.28 } : { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }}
|
||||
animate={
|
||||
reduceMotion
|
||||
? { y: `${(index % 8) * 14}vh`, opacity: 0.28 }
|
||||
: { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }
|
||||
}
|
||||
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
|
||||
className={`absolute -top-12 w-0.5 rotate-[10deg] rounded-full bg-foreground/70 shadow-[0_0_8px_hsl(var(--foreground)/0.18)] ${drop.lengthClass}`}
|
||||
style={{ left: drop.left }}
|
||||
@@ -65,7 +75,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
|
||||
<motion.span
|
||||
key={`mist-${index}`}
|
||||
initial={{ y: "-10vh", opacity: 0 }}
|
||||
animate={reduceMotion ? { y: `${(index % 6) * 18}vh`, opacity: 0.16 } : { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }}
|
||||
animate={
|
||||
reduceMotion
|
||||
? { y: `${(index % 6) * 18}vh`, opacity: 0.16 }
|
||||
: { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }
|
||||
}
|
||||
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55"
|
||||
style={{ left: drop.left }}
|
||||
@@ -80,7 +94,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
|
||||
key={`lightning-${index}`}
|
||||
viewBox="0 0 96 176"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
animate={reduceMotion ? { opacity: bolt.opacity * 0.22 } : { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }}
|
||||
animate={
|
||||
reduceMotion
|
||||
? { opacity: bolt.opacity * 0.22 }
|
||||
: { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }
|
||||
}
|
||||
transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }}
|
||||
className={`absolute text-foreground dark:text-white ${bolt.className}`}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } fr
|
||||
import {
|
||||
calculateFeelsLike,
|
||||
formatDateTime,
|
||||
formatDistance,
|
||||
formatHumidity,
|
||||
formatPressure,
|
||||
formatRainfall,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
getWeatherDescription,
|
||||
getWeatherMoodFromData,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { SynopStation, WeatherMood } from "@/types/imgw";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import type { ImgwCurrentWeather } from "@/types/imgw-current";
|
||||
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||
import { WeatherEffects } from "@/components/weather/weather-effects";
|
||||
@@ -23,17 +24,6 @@ import { useWeatherStore } from "@/lib/store";
|
||||
|
||||
type DevWeatherEffectOverride = "rain" | "thunderstorm" | "none";
|
||||
|
||||
function moodAccentClass(mood: WeatherMood) {
|
||||
return {
|
||||
warm: "border-accent/25 bg-accent/10 text-accent",
|
||||
cloudy: "border-border/70 bg-surface-muted text-muted",
|
||||
wind: "border-border/70 bg-surface-muted text-muted",
|
||||
cold: "border-border/70 bg-surface-muted text-muted",
|
||||
night: "border-border/70 bg-surface-muted text-muted",
|
||||
mild: "border-accent/25 bg-accent/10 text-accent",
|
||||
}[mood];
|
||||
}
|
||||
|
||||
function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
|
||||
if (process.env.NODE_ENV !== "development" || typeof window === "undefined") return null;
|
||||
const effect = new URLSearchParams(window.location.search).get("effect");
|
||||
@@ -42,40 +32,86 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number }) {
|
||||
export function WeatherHero({
|
||||
station,
|
||||
currentWeather,
|
||||
currentWeatherLoading = false,
|
||||
currentForecastWeatherCode,
|
||||
locationName,
|
||||
distanceKm,
|
||||
}: {
|
||||
station: SynopStation;
|
||||
currentWeather?: ImgwCurrentWeather | null;
|
||||
currentWeatherLoading?: boolean;
|
||||
currentForecastWeatherCode?: number | null;
|
||||
locationName?: string;
|
||||
distanceKm?: number | null;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
|
||||
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
|
||||
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
||||
const displayedLocationName = locationName ?? station.name;
|
||||
const hasGlobalModel = currentWeather?.coverage === "global-model";
|
||||
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
||||
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
||||
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
|
||||
const displayedStation = currentWeather ? {
|
||||
const hasDistantFallback =
|
||||
!hasGlobalModel &&
|
||||
!hasFullHybridAnalysis &&
|
||||
!currentWeatherLoading &&
|
||||
distanceKm !== undefined &&
|
||||
distanceKm !== null &&
|
||||
distanceKm >= 30;
|
||||
const formattedDistance = formatDistance(distanceKm ?? null, language, distanceUnit);
|
||||
const displayedStation = currentWeather
|
||||
? {
|
||||
...station,
|
||||
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
|
||||
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
|
||||
temperature: currentWeather.temperature ?? station.temperature,
|
||||
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
||||
windDirection: currentWeather.windDirection ?? station.windDirection,
|
||||
humidity: currentWeather.humidity ?? station.humidity,
|
||||
pressure: currentWeather.pressure ?? station.pressure,
|
||||
rainfall: currentWeather.precipitation10m ?? station.rainfall,
|
||||
} : station;
|
||||
}
|
||||
: station;
|
||||
const mood = getWeatherMoodFromData(displayedStation);
|
||||
const moodAccent = moodAccentClass(mood);
|
||||
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
|
||||
const feelsLike =
|
||||
currentWeather?.feelsLike ??
|
||||
calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
|
||||
const metrics = [
|
||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
|
||||
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) },
|
||||
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
|
||||
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
|
||||
{
|
||||
icon: Wind,
|
||||
label: t("weather.wind"),
|
||||
value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit),
|
||||
},
|
||||
{
|
||||
icon: Umbrella,
|
||||
label: hasGlobalModel
|
||||
? t("weather.currentPrecipitation")
|
||||
: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined
|
||||
? t("weather.rainfall10m")
|
||||
: t("weather.rainfallTotal"),
|
||||
value: formatRainfall(displayedStation.rainfall, language, precipitationUnit),
|
||||
},
|
||||
{
|
||||
icon: Gauge,
|
||||
label: t("weather.pressure"),
|
||||
value: formatPressure(displayedStation.pressure, language, pressureUnit),
|
||||
},
|
||||
];
|
||||
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
||||
const effectPrecipitation10m =
|
||||
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
||||
? 0.1
|
||||
: devEffectOverride === "none"
|
||||
? 0
|
||||
: currentWeather?.precipitation10m;
|
||||
const effectThunderstorm = devEffectOverride === "thunderstorm"
|
||||
const effectThunderstorm =
|
||||
devEffectOverride === "thunderstorm"
|
||||
? true
|
||||
: devEffectOverride === "rain" || devEffectOverride === "none"
|
||||
? false
|
||||
@@ -108,23 +144,37 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
||||
<div className="relative z-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span>
|
||||
<span className={`rounded-control border px-2.5 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${moodAccent}`}>
|
||||
{weatherDescription}
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted">
|
||||
<MapPin className="size-4" />
|
||||
{displayedLocationName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
<p>{currentWeatherLoading
|
||||
<p>
|
||||
{currentWeatherLoading
|
||||
? t("location.heroHybridLoading", { station: station.name })
|
||||
: hasGlobalModel
|
||||
? t("location.heroGlobalModelSource", { location: displayedLocationName })
|
||||
: hasFullHybridAnalysis
|
||||
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||
: hasPartialHybridAnalysis
|
||||
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
|
||||
? t("location.heroHybridPartial", { station: station.name, distance: formattedDistance })
|
||||
: locationName
|
||||
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
|
||||
: t("location.heroStationFallback", { station: station.name })}</p>
|
||||
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
|
||||
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
|
||||
? t("location.heroStationFallbackWithDistance", {
|
||||
station: station.name,
|
||||
distance: formattedDistance,
|
||||
})
|
||||
: t("location.heroStationFallback", { station: station.name })}
|
||||
</p>
|
||||
{hasFullHybridAnalysis && locationName && (
|
||||
<p>{t("location.heroNearestStation", { station: station.name, distance: formattedDistance })}</p>
|
||||
)}
|
||||
{hasDistantFallback && (
|
||||
<p className="flex items-start gap-1.5 text-warning">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("location.heroDistantFallback")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||
@@ -133,14 +183,25 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
||||
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
|
||||
</div>
|
||||
<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>
|
||||
<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 className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
|
||||
{metrics.map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted"><Icon className="size-3.5 text-accent" />{label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Icon className="size-3.5 text-accent" />
|
||||
{label}
|
||||
</div>
|
||||
<p className="mt-2 text-base font-semibold">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,8 +2,23 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
|
||||
import type { WeatherMood } from "@/types/imgw";
|
||||
import type { CurrentWeatherCondition } from "@/types/imgw-current";
|
||||
|
||||
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
|
||||
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
|
||||
export function WeatherIcon({
|
||||
mood,
|
||||
condition,
|
||||
className = "",
|
||||
}: {
|
||||
mood: WeatherMood;
|
||||
condition?: CurrentWeatherCondition;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon =
|
||||
condition === "thunderstorm"
|
||||
? CloudLightning
|
||||
: condition === "rain"
|
||||
? CloudRain
|
||||
: condition === "snow"
|
||||
? Snowflake
|
||||
: {
|
||||
warm: ThermometerSun,
|
||||
cloudy: Cloud,
|
||||
wind: Wind,
|
||||
|
||||
16
docs/api.md
16
docs/api.md
@@ -5,11 +5,12 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
|
||||
## Dane Pogodowe i Lokalizacje
|
||||
|
||||
| Metoda | Endpoint | Przeznaczenie |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/forecast?latitude={lat}&longitude={lon}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Łączy IMGW ALARO z Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| ------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/current-weather?latitude={lat}&longitude={lon}®ion={PL\|GLOBAL}` | Zwraca znormalizowane bieżące warunki. Dla `PL` używa IMGW Hybrid, dla `GLOBAL` modelowych warunków Open-Meteo. |
|
||||
| `GET` | `/api/forecast?latitude={lat}&longitude={lon}®ion={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw/{path}` | Proxy allowlistowanych endpointów IMGW `danepubliczne.imgw.pl`. Obsługuje kolekcje `synop`, `hydro`, `meteo`, `warningsmeteo`, `warningshydro`, `product` oraz szczegół `synop/id/{id}`. Nieobsługiwana ścieżka zwraca `404`. |
|
||||
| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości w Polsce przez Open-Meteo Geocoding. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. |
|
||||
| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości globalnie przez Open-Meteo Geocoding. Wyniki zawierają `countryCode`, `admin1`, `admin2`, `timezone` i region `PL` albo `GLOBAL`. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. |
|
||||
| `GET` | `/api/locations/reverse?latitude={lat}&longitude={lon}&language={pl\|en}` | Ustala nazwę miejsca dla pozycji GPS przez Nominatim / OpenStreetMap. Współrzędne są zaokrąglane do trzech miejsc po przecinku. |
|
||||
|
||||
## Powiadomienia
|
||||
@@ -17,9 +18,9 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
|
||||
Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i lokalnego magazynu SQLite dla subskrypcji.
|
||||
|
||||
| Metoda | Endpoint | Przeznaczenie |
|
||||
| --- | --- | --- |
|
||||
| -------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. |
|
||||
| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo i opcjonalny powiat TERYT. |
|
||||
| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje region `PL`/`GLOBAL`, preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo dla `PL` i opcjonalny powiat TERYT. |
|
||||
| `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. |
|
||||
| `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. |
|
||||
| `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. |
|
||||
@@ -46,8 +47,9 @@ Jeśli `NOTIFICATIONS_CRON_SECRET` nie jest ustawiony, endpointy harmonogramu s
|
||||
## Cache i Odpowiedzi
|
||||
|
||||
| Endpoint | Cache |
|
||||
| --- | --- |
|
||||
| ------------------------ | ------------------------------------------------- |
|
||||
| `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` |
|
||||
| `/api/current-weather` | `s-maxage=120`, `stale-while-revalidate=300` |
|
||||
| `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` |
|
||||
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
|
||||
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
||||
@@ -60,7 +62,7 @@ Route handlery zwracają kontrolowane odpowiedzi JSON. Błędy zewnętrznych us
|
||||
Publiczne endpointy ostrzeżeń IMGW potrafią zwrócić HTTP `404` z treścią:
|
||||
|
||||
```json
|
||||
{"status":false,"message":"No products were found"}
|
||||
{ "status": false, "message": "No products were found" }
|
||||
```
|
||||
|
||||
Dla `warningsmeteo` i `warningshydro` aplikacja traktuje ten wariant jako poprawną pustą listę ostrzeżeń, a nie awarię źródła danych.
|
||||
|
||||
@@ -22,7 +22,7 @@ Najważniejsze widoki:
|
||||
- `/` - dashboard pogody, wyszukiwarka lokalizacji, hero, prognoza, briefy i ostrzeżenia regionalne,
|
||||
- `/warnings` - pełny widok ostrzeżeń meteo i hydro,
|
||||
- `/hydro` - stacje hydrologiczne,
|
||||
- `/settings` - język, motyw, lokalizacja powiadomień i Web Push,
|
||||
- `/settings` - język, motyw, widoczność sekcji aplikacji i dashboardu, lokalizacja powiadomień i Web Push,
|
||||
- `/station/[id]` - szczegóły stacji,
|
||||
- `/offline` - fallback offline.
|
||||
|
||||
@@ -42,7 +42,9 @@ Zustand w `lib/store.ts` przechowuje trwałe preferencje użytkownika w `localSt
|
||||
- ulubione stacje,
|
||||
- wybraną stację albo lokalizację,
|
||||
- ustawienia powiadomień,
|
||||
- tryb wyboru województwa dla alertów.
|
||||
- tryb wyboru województwa dla alertów,
|
||||
- widoczność opcjonalnych sekcji dashboardu,
|
||||
- widoczność opcjonalnych sekcji aplikacji w nawigacji.
|
||||
|
||||
Język interfejsu jest przechowywany osobno w `localStorage` pod kluczem `wtr:language`.
|
||||
|
||||
@@ -53,8 +55,9 @@ Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.j
|
||||
Service worker:
|
||||
|
||||
- cache'uje powłokę aplikacji,
|
||||
- obsługuje fallback `/offline` dla nawigacji,
|
||||
- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/forecast`,
|
||||
- obsługuje fallback `/offline` dla nawigacji i zapisuje udane nawigacje do cache,
|
||||
- cache'uje statyczne assety Next.js, style, skrypty, fonty, obrazy, manifest i ikony,
|
||||
- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`,
|
||||
- obsługuje zdarzenia `push` i kliknięcia w powiadomienia.
|
||||
|
||||
Rejestracja service workera działa w buildzie produkcyjnym.
|
||||
@@ -66,6 +69,7 @@ Interfejs jest dostępny po polsku i angielsku. Teksty są w `lib/i18n.tsx`. Naz
|
||||
## Zasady Integracji
|
||||
|
||||
- Dane zewnętrzne przechodzą przez route handlery Next.js.
|
||||
- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń.
|
||||
- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW.
|
||||
- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń dla Polski.
|
||||
- Poza Polską Open-Meteo jest źródłem modelowych bieżących warunków i prognozy.
|
||||
- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW ani oficjalny alert.
|
||||
- Braki danych są pokazywane jawnie, bez generowania fikcyjnych wartości.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## IMGW
|
||||
|
||||
| Dane | Źródło |
|
||||
| --- | --- |
|
||||
| ----------------------------- | ------------------------------------------------------ |
|
||||
| Bieżąca analiza IMGW Hybrid | `https://meteo.imgw.pl/api/v1/forecast/fcapi` |
|
||||
| Prognoza godzinowa IMGW ALARO | `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro` |
|
||||
| Dane synoptyczne | `https://danepubliczne.imgw.pl/api/data/synop` |
|
||||
@@ -18,13 +18,27 @@
|
||||
|
||||
IMGW jest traktowane jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognoza modelowa jest w interfejsie rozdzielona od pomiarów.
|
||||
|
||||
IMGW jest używane tylko dla lokalizacji w Polsce. Dla lokalizacji globalnych oficjalne ostrzeżenia IMGW, dane hydrologiczne IMGW, TERYT i powiatowe filtrowanie ostrzeżeń są niedostępne.
|
||||
|
||||
## Open-Meteo
|
||||
|
||||
Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada.
|
||||
Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada. Poza Polską dostarcza także modelowe warunki bieżące dla dashboardu.
|
||||
|
||||
Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do wyszukiwania miejscowości w Polsce.
|
||||
Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do globalnego wyszukiwania miejscowości. Wynik zawiera m.in. `countryCode`, `admin1`, `admin2`, `timezone` i współrzędne, które aplikacja zapisuje w modelu lokalizacji.
|
||||
|
||||
Przed większym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania z Open-Meteo albo zastąpić usługę własnym dostawcą.
|
||||
Przed większym, publicznym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania, limity i licencję Open-Meteo albo zastąpić usługę własnym dostawcą.
|
||||
|
||||
## Capabilities per Region
|
||||
|
||||
| Capability | Polska | Global |
|
||||
| --------------------- | ------------------------------ | ---------------------------- |
|
||||
| Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model |
|
||||
| Prognoza godzinowa | IMGW ALARO + Open-Meteo | Open-Meteo |
|
||||
| Prognoza dzienna | IMGW ALARO/Open-Meteo | Open-Meteo |
|
||||
| Oficjalne ostrzeżenia | IMGW | niedostępne |
|
||||
| Filtrowanie powiatowe | TERYT | niedostępne |
|
||||
| Briefy | tak | tak, bez oficjalnych alertów |
|
||||
| Push alerty oficjalne | IMGW | niedostępne |
|
||||
|
||||
## Nominatim / OpenStreetMap
|
||||
|
||||
@@ -41,14 +55,15 @@ Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użyc
|
||||
## Cache
|
||||
|
||||
| Dane | Cache |
|
||||
| --- | --- |
|
||||
| ----------------------------------- | ------------------------------------------ |
|
||||
| IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` |
|
||||
| Modelowe warunki bieżące Open-Meteo | 120 sekund, `stale-while-revalidate=300` |
|
||||
| Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` |
|
||||
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
|
||||
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
| Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
|
||||
Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią.
|
||||
Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią.
|
||||
|
||||
## Ograniczenia Źródeł
|
||||
|
||||
|
||||
@@ -76,10 +76,10 @@ Repozytorium używa Gitea Actions jako końcowej bramki jakości po pushu. Lokal
|
||||
|
||||
- dokumentacja, teksty i komentarze: przegląd diffu oraz opcjonalnie `git diff --check`,
|
||||
- drobne zmiany wizualne: bez rutynowego builda; `npm run lint` tylko przy ryzyku błędu składni lub reguł lintingu,
|
||||
- zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`,
|
||||
- routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run typecheck` oraz `npm run build`.
|
||||
- zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`; dodaj `npm run test`, jeśli zmiana dotyka logiki objętej testami,
|
||||
- routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm run test` oraz `npm run build`.
|
||||
|
||||
Repozytorium nie ma obecnie skryptu testów ani formattera.
|
||||
Testy jednostkowe korzystają z Vitest (`npm run test`) i obejmują kluczową logikę pogodową. Formatowanie jest obsługiwane przez Prettier (`npm run format` i `npm run format:check`).
|
||||
|
||||
## Bezpieczeństwo Zależności
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Powiadomienia obejmują:
|
||||
|
||||
- nowe ostrzeżenia meteorologiczne IMGW,
|
||||
- nowe ostrzeżenia meteorologiczne IMGW dla Polski,
|
||||
- poranny brief dnia,
|
||||
- wieczorny brief z prognozą na jutro,
|
||||
- testowe powiadomienie wysyłane z `/settings`.
|
||||
@@ -44,18 +44,20 @@ Jeśli zmienna nie jest ustawiona, aplikacja używa `./data/wtr.sqlite` względe
|
||||
Subskrypcja Web Push zapisuje:
|
||||
|
||||
- endpoint subskrypcji przeglądarki,
|
||||
- województwo,
|
||||
- region pogodowy `PL` albo `GLOBAL`,
|
||||
- województwo dla oficjalnych ostrzeżeń IMGW w Polsce,
|
||||
- język interfejsu,
|
||||
- czy ostrzeżenia są aktywne,
|
||||
- czy aktywny jest brief poranny,
|
||||
- czy aktywny jest brief na jutro,
|
||||
- współrzędne i nazwę lokalizacji,
|
||||
- opcjonalny powiat TERYT,
|
||||
- wybraną jednostkę temperatury dla briefów wysyłanych z serwera,
|
||||
- wybraną jednostkę wiatru dla briefów wysyłanych z serwera.
|
||||
- wybrane jednostki prezentacyjne dla briefów wysyłanych z serwera: temperaturę, wiatr, opad, ciśnienie i dystans.
|
||||
|
||||
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.
|
||||
|
||||
Dla subskrypcji `GLOBAL` oficjalne ostrzeżenia IMGW są wyłączone, `province` może być puste, a powiadomienia mogą obejmować brief poranny i brief na jutro oparte o prognozę modelową Open-Meteo.
|
||||
|
||||
## Worker Self-Hosted
|
||||
|
||||
Worker powiadomień uruchamia się osobno od aplikacji:
|
||||
@@ -84,6 +86,7 @@ Domyślna konfiguracja:
|
||||
|
||||
```bash
|
||||
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
|
||||
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||
```
|
||||
@@ -91,12 +94,22 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
|
||||
Worker:
|
||||
|
||||
- sprawdza nowe ostrzeżenia co 5 minut,
|
||||
- po 07:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/daily-brief`,
|
||||
- po 18:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/tomorrow-brief`,
|
||||
- co 5 minut odpytuje endpointy briefów,
|
||||
- endpoint `/api/notifications/daily-brief` wysyła po 07:00 lokalnego czasu zapisanego w subskrypcji,
|
||||
- endpoint `/api/notifications/tomorrow-brief` wysyła po 18:00 lokalnego czasu zapisanego w subskrypcji,
|
||||
- nie blokuje jednego harmonogramu błędem drugiego.
|
||||
|
||||
Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona.
|
||||
|
||||
Endpointy briefów zwracają diagnostykę JSON z polami `subscriptions`, `eligibleSubscriptions`, `sent`, `skipped`,
|
||||
`failed` oraz `skippedReasons`. Dla problemów z harmonogramem najważniejsze powody to:
|
||||
|
||||
- `disabled` - subskrypcja istnieje, ale dany brief nie jest włączony w zapisanym stanie serwera,
|
||||
- `missingLocation` - brief jest włączony, ale subskrypcja nie ma współrzędnych,
|
||||
- `beforeTime` - lokalny czas subskrypcji jest jeszcze przed skonfigurowaną godziną,
|
||||
- `alreadySent` - brief dla tej lokalnej daty został już oznaczony jako wysłany,
|
||||
- `noBrief` - prognoza nie pozwoliła zbudować treści briefu.
|
||||
|
||||
## Zewnętrzny Cron
|
||||
|
||||
Jeśli nie używasz `npm run notifications:worker`, zewnętrzny cron powinien wywoływać:
|
||||
|
||||
@@ -2,9 +2,29 @@
|
||||
|
||||
`wtr.` rozdziela bieżące pomiary, lokalną analizę IMGW Hybrid, prognozę modelową i ostrzeżenia. Prognoza nie jest przedstawiana jako pomiar IMGW.
|
||||
|
||||
## Region i Kontrakt Danych
|
||||
|
||||
Aplikacja rozpoznaje dwa regiony pogodowe:
|
||||
|
||||
- `PL` - lokalizacje w Polsce,
|
||||
- `GLOBAL` - wszystkie pozostałe lokalizacje.
|
||||
|
||||
Region jest częścią modelu lokalizacji i odpowiedzi pogodowych. UI powinien bazować na capabilities i metadanych źródła, a nie rozsiewać własne warunki po kraju.
|
||||
|
||||
Każda znormalizowana odpowiedź bieżących warunków zawiera metadane:
|
||||
|
||||
- `source`, np. `IMGW` albo `OPEN_METEO`,
|
||||
- `sourceLabel` dla UI,
|
||||
- `sourceType`, np. `hybrid` albo `model`,
|
||||
- `isOfficial`,
|
||||
- `region`,
|
||||
- `fetchedAt` i `measuredAt`/czas aktualizacji.
|
||||
|
||||
## Bieżące Warunki
|
||||
|
||||
Dashboard hero korzysta z publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl` przez `/api/imgw-current`.
|
||||
Dashboard hero korzysta z `/api/current-weather`.
|
||||
|
||||
Dla `PL` endpoint używa publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl`. Dla `GLOBAL` używa modelowych bieżących warunków Open-Meteo i nie opisuje ich jako pomiaru.
|
||||
|
||||
Normalizacja w `lib/imgw-current-api.ts`:
|
||||
|
||||
@@ -19,13 +39,15 @@ Jeśli Hybrid nie dostarcza pełnych danych, UI jawnie korzysta z fallbacku `syn
|
||||
|
||||
## Prognoza Modelowa
|
||||
|
||||
Route handler `/api/forecast` pobiera równolegle:
|
||||
Route handler `/api/forecast` dla `PL` pobiera równolegle:
|
||||
|
||||
- pełne 7 dni Open-Meteo,
|
||||
- godzinowe IMGW ALARO.
|
||||
|
||||
W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo. Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont do 7 dni. Awaria ALARO pozostawia działający fallback Open-Meteo.
|
||||
|
||||
Dla `GLOBAL` route handler nie odpytuje IMGW ALARO i używa Open-Meteo z `timezone=auto`.
|
||||
|
||||
Dashboard pokazuje:
|
||||
|
||||
- regułowy brief dnia,
|
||||
@@ -38,6 +60,8 @@ Widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||
|
||||
Ostrzeżenia meteorologiczne IMGW zawierają kody powiatów TERYT. Ostrzeżenia hydrologiczne zawierają jawne województwa. Normalizacja odbywa się przez `lib/provinces.ts` i `lib/warning-regions.ts`.
|
||||
|
||||
Ostrzeżenia są capability tylko dla `PL`. Dla `GLOBAL` widok `/warnings` pokazuje stan niedostępności oficjalnych ostrzeżeń i nie traktuje prognozy modelowej jako oficjalnego alertu.
|
||||
|
||||
Zasady UI:
|
||||
|
||||
- lokalny obszar jest priorytetyzowany,
|
||||
@@ -55,6 +79,8 @@ Brief dnia analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i na
|
||||
|
||||
Brief na jutro analizuje pełny jutrzejszy dzień prognozy. Może informować o burzach na podstawie kodów pogodowych modelu nawet wtedy, gdy IMGW nie opublikowało oficjalnego ostrzeżenia.
|
||||
|
||||
Dla `GLOBAL` briefy korzystają z Open-Meteo i pomijają oficjalne ostrzeżenia IMGW.
|
||||
|
||||
## Opad i Pomiary Synoptyczne
|
||||
|
||||
Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu.
|
||||
@@ -66,7 +92,7 @@ IMGW Hybrid dostarcza m.in. opad 10-minutowy. Ten parametr jest prezentowany odd
|
||||
Prognoza godzinowa i dzienna rozpoznaje:
|
||||
|
||||
| Stan | Opis w interfejsie |
|
||||
| --- | --- |
|
||||
| -------------- | ---------------------- |
|
||||
| `clear` | Bezchmurnie |
|
||||
| `partlyCloudy` | Częściowe zachmurzenie |
|
||||
| `cloudy` | Pochmurno |
|
||||
@@ -89,19 +115,19 @@ Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i bu
|
||||
Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba:
|
||||
|
||||
| Cloud | Opis w hero |
|
||||
| --- | --- |
|
||||
| ------- | -------------------------------- |
|
||||
| `>= 75` | Pochmurno |
|
||||
| `>= 25` | Częściowe zachmurzenie |
|
||||
| `< 25` | brak osobnego opisu zachmurzenia |
|
||||
|
||||
Jednostki temperatury i wiatru wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`.
|
||||
Jednostki temperatury, wiatru, opadu, ciśnienia i dystansu wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`.
|
||||
|
||||
## Mood i Efekty Wizualne
|
||||
|
||||
Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu.
|
||||
|
||||
| Mood | Reguła |
|
||||
| --- | --- |
|
||||
| -------- | ------------------------------------ |
|
||||
| `night` | godzina przed `06:00` lub od `21:00` |
|
||||
| `wind` | wiatr od `8 m/s` |
|
||||
| `cold` | temperatura do `3°C` |
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchImgwCurrentWeather } from "@/lib/imgw-current-api";
|
||||
import { fetchCurrentWeather } from "@/lib/imgw-current-api";
|
||||
import { QUERY_GC_TIME } from "@/lib/constants";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000;
|
||||
|
||||
export function useCurrentWeather(latitude?: number, longitude?: number) {
|
||||
export function useCurrentWeather(latitude?: number, longitude?: number, region: WeatherRegion = "PL") {
|
||||
return useQuery({
|
||||
queryKey: ["imgw-current-weather", latitude, longitude],
|
||||
queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal),
|
||||
queryKey: ["current-weather", region, latitude, longitude],
|
||||
queryFn: ({ signal }) => fetchCurrentWeather(latitude as number, longitude as number, region, signal),
|
||||
staleTime: CURRENT_WEATHER_STALE_TIME,
|
||||
gcTime: QUERY_GC_TIME,
|
||||
retry: 1,
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchForecast } from "@/lib/forecast-api";
|
||||
import { QUERY_GC_TIME } from "@/lib/constants";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export function useForecast(latitude?: number, longitude?: number) {
|
||||
export function useForecast(latitude?: number, longitude?: number, region: WeatherRegion = "PL") {
|
||||
return useQuery({
|
||||
queryKey: ["forecast", latitude, longitude],
|
||||
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal),
|
||||
queryKey: ["forecast", region, latitude, longitude],
|
||||
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, region, signal),
|
||||
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
|
||||
staleTime: 15 * 60 * 1000,
|
||||
gcTime: QUERY_GC_TIME,
|
||||
|
||||
52
lib/app-sections.ts
Normal file
52
lib/app-sections.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { TranslationKey } from "@/lib/i18n";
|
||||
|
||||
export const APP_SECTION_SETTINGS = [
|
||||
{
|
||||
id: "warnings",
|
||||
href: "/warnings",
|
||||
titleKey: "settings.appSections.warnings.title",
|
||||
descriptionKey: "settings.appSections.warnings.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "hydro",
|
||||
href: "/hydro",
|
||||
titleKey: "settings.appSections.hydro.title",
|
||||
descriptionKey: "settings.appSections.hydro.description",
|
||||
defaultVisible: false,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
href: string;
|
||||
titleKey: TranslationKey;
|
||||
descriptionKey: TranslationKey;
|
||||
defaultVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type AppSectionId = (typeof APP_SECTION_SETTINGS)[number]["id"];
|
||||
|
||||
export type AppSectionVisibility = Record<AppSectionId, boolean>;
|
||||
|
||||
export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} as AppSectionVisibility,
|
||||
);
|
||||
|
||||
export function normalizeAppSectionVisibility(value: unknown): AppSectionVisibility {
|
||||
const stored = value && typeof value === "object" ? (value as Partial<Record<AppSectionId, unknown>>) : {};
|
||||
return APP_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}),
|
||||
{} as AppSectionVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppNavItemVisible(href: string, visibility: AppSectionVisibility) {
|
||||
const section = APP_SECTION_SETTINGS.find((candidate) => candidate.href === href);
|
||||
return section ? visibility[section.id] : true;
|
||||
}
|
||||
78
lib/dashboard-sections.ts
Normal file
78
lib/dashboard-sections.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { TranslationKey } from "@/lib/i18n";
|
||||
|
||||
export const DASHBOARD_SECTION_SETTINGS = [
|
||||
{
|
||||
id: "warnings",
|
||||
titleKey: "settings.dashboardSections.warnings.title",
|
||||
descriptionKey: "settings.dashboardSections.warnings.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "weatherBrief",
|
||||
titleKey: "settings.dashboardSections.weatherBrief.title",
|
||||
descriptionKey: "settings.dashboardSections.weatherBrief.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "forecast",
|
||||
titleKey: "settings.dashboardSections.forecast.title",
|
||||
descriptionKey: "settings.dashboardSections.forecast.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "favorites",
|
||||
titleKey: "settings.dashboardSections.favorites.title",
|
||||
descriptionKey: "settings.dashboardSections.favorites.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "featuredStations",
|
||||
titleKey: "settings.dashboardSections.featuredStations.title",
|
||||
descriptionKey: "settings.dashboardSections.featuredStations.description",
|
||||
defaultVisible: false,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
titleKey: TranslationKey;
|
||||
descriptionKey: TranslationKey;
|
||||
defaultVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"];
|
||||
|
||||
export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>;
|
||||
export type DashboardSectionOrder = DashboardSectionId[];
|
||||
|
||||
export const DEFAULT_DASHBOARD_SECTION_ORDER = DASHBOARD_SECTION_SETTINGS.map(
|
||||
(section) => section.id,
|
||||
) as DashboardSectionOrder;
|
||||
|
||||
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} as DashboardSectionVisibility,
|
||||
);
|
||||
|
||||
export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility {
|
||||
const stored = value && typeof value === "object" ? (value as Partial<Record<DashboardSectionId, unknown>>) : {};
|
||||
return DASHBOARD_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}),
|
||||
{} as DashboardSectionVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder {
|
||||
const allowedIds = new Set<DashboardSectionId>(DASHBOARD_SECTION_SETTINGS.map((section) => section.id));
|
||||
const stored = Array.isArray(value)
|
||||
? value.filter((sectionId): sectionId is DashboardSectionId => allowedIds.has(sectionId as DashboardSectionId))
|
||||
: [];
|
||||
return [
|
||||
...stored,
|
||||
...DEFAULT_DASHBOARD_SECTION_ORDER.filter((sectionId) => !stored.includes(sectionId)),
|
||||
] as DashboardSectionOrder;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
import { getWeatherCapabilities } from "@/types/weather-region";
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
@@ -17,12 +19,14 @@ function normalizeSources(value: unknown): ForecastSource[] {
|
||||
}
|
||||
|
||||
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<HourlyForecast>;
|
||||
const time = readString(row.time);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
@@ -31,17 +35,21 @@ function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
windSpeed: readNumber(row.windSpeed),
|
||||
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||
}];
|
||||
}) : [];
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<DailyForecast>;
|
||||
const date = readString(row.date);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
@@ -51,27 +59,36 @@ function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||
sunrise: readString(row.sunrise),
|
||||
sunset: readString(row.sunset),
|
||||
sources: normalizeSources(row.sources),
|
||||
}];
|
||||
}) : [];
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
|
||||
const latitude = Number(raw.latitude);
|
||||
const longitude = Number(raw.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||
throw new Error("Forecast service returned invalid coordinates.");
|
||||
const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL";
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
||||
hourly: normalizeHourlyForecast(raw.hourly),
|
||||
daily: normalizeDailyForecast(raw.daily),
|
||||
sources: normalizeSources(raw.sources),
|
||||
capabilities: raw.capabilities ?? getWeatherCapabilities(region),
|
||||
unavailableReasons: Array.isArray(raw.unavailableReasons)
|
||||
? raw.unavailableReasons.filter((value): value is string => typeof value === "string")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) {
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||
export async function fetchForecast(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
|
||||
const response = await fetch(`/api/forecast?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Unable to load forecast.");
|
||||
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
||||
return normalizeForecast((await response.json()) as Partial<WeatherForecast>);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RawWeatherForecast,
|
||||
WeatherForecast,
|
||||
} from "@/types/forecast";
|
||||
import { getWeatherCapabilities, type WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
const WARSAW_TIME_ZONE = "Europe/Warsaw";
|
||||
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
@@ -65,7 +66,8 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const time = readString(series, "time", index);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
@@ -74,7 +76,8 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
source: "open-meteo" as const,
|
||||
}];
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +85,8 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const date = readString(series, "time", index);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
@@ -92,21 +96,26 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
|
||||
sunrise: readString(series, "sunrise", index),
|
||||
sunset: readString(series, "sunset", index),
|
||||
sources: ["open-meteo" as const],
|
||||
}];
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast {
|
||||
function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast {
|
||||
const latitude = Number(raw.latitude);
|
||||
const longitude = Number(raw.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||
throw new Error("Forecast service returned invalid coordinates.");
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
|
||||
hourly: normalizeOpenMeteoHourly(raw.hourly),
|
||||
daily: normalizeOpenMeteoDaily(raw.daily),
|
||||
sources: ["open-meteo"],
|
||||
capabilities: getWeatherCapabilities(region),
|
||||
unavailableReasons: region === "GLOBAL" ? ["official-warnings"] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,9 +174,11 @@ function getWeatherCodePriority(code: number | null) {
|
||||
|
||||
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
|
||||
if (!hours.length) return fallback;
|
||||
return hours.reduce((selected, hour) => (
|
||||
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
|
||||
), null as number | null);
|
||||
return hours.reduce(
|
||||
(selected, hour) =>
|
||||
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected,
|
||||
null as number | null,
|
||||
);
|
||||
}
|
||||
|
||||
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
|
||||
@@ -179,17 +190,33 @@ function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecas
|
||||
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
|
||||
return {
|
||||
...day,
|
||||
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
|
||||
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin),
|
||||
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability),
|
||||
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation),
|
||||
temperatureMax: getMaximum(
|
||||
dayHours.map((hour) => hour.temperature),
|
||||
day.temperatureMax,
|
||||
),
|
||||
temperatureMin: getMinimum(
|
||||
dayHours.map((hour) => hour.temperature),
|
||||
day.temperatureMin,
|
||||
),
|
||||
precipitationProbability: getMaximum(
|
||||
dayHours.map((hour) => hour.precipitationProbability),
|
||||
day.precipitationProbability,
|
||||
),
|
||||
precipitation: getTotal(
|
||||
dayHours.map((hour) => hour.precipitation),
|
||||
day.precipitation,
|
||||
),
|
||||
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
|
||||
sources: getSources(dayHours, day.sources),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast {
|
||||
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
|
||||
export function mergeForecastSources(
|
||||
openMeteoPayload: RawWeatherForecast,
|
||||
imgwPayload?: RawImgwForecastResponse | null,
|
||||
region: WeatherRegion = "PL",
|
||||
): WeatherForecast {
|
||||
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
|
||||
const imgwHours = normalizeImgwHourly(imgwPayload);
|
||||
let hasImgwHours = false;
|
||||
const hourly = openMeteoForecast.hourly.map((hour) => {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Language, TranslationKey } from "@/lib/i18n";
|
||||
import { translate } from "@/lib/i18n";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature, formatWindSpeed } from "@/lib/weather-utils";
|
||||
import {
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
formatPrecipitation,
|
||||
formatTemperature,
|
||||
formatWindSpeed,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
export function getForecastConditionKey(code: number | null): TranslationKey {
|
||||
if (code === 0) return "forecast.condition.clear";
|
||||
@@ -20,17 +27,42 @@ export function getForecastCondition(code: number | null, language: Language) {
|
||||
return translate(language, getForecastConditionKey(code));
|
||||
}
|
||||
|
||||
export function formatForecastTemperature(value: number | null, language: Language, unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
function isDryWeatherCode(code: number | null) {
|
||||
return code === null || (code >= 0 && code <= 3) || code === 45 || code === 48;
|
||||
}
|
||||
|
||||
export function getEffectiveHourlyWeatherCode(
|
||||
hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">,
|
||||
) {
|
||||
const hasMeasuredPrecipitation = (hour.precipitation ?? 0) > 0;
|
||||
const hasLikelyPrecipitation = (hour.precipitationProbability ?? 0) >= 70;
|
||||
if (isDryWeatherCode(hour.weatherCode) && (hasMeasuredPrecipitation || hasLikelyPrecipitation)) return 61;
|
||||
return hour.weatherCode;
|
||||
}
|
||||
|
||||
export function formatForecastTemperature(
|
||||
value: number | null,
|
||||
language: Language,
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
if (value === null) return "—";
|
||||
return formatTemperature(value, language, unit);
|
||||
}
|
||||
|
||||
export function formatForecastRainfall(value: number | null, language: Language) {
|
||||
export function formatForecastRainfall(
|
||||
value: number | null,
|
||||
language: Language,
|
||||
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
||||
) {
|
||||
if (value === null) return "—";
|
||||
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
|
||||
return formatPrecipitation(value, language, unit);
|
||||
}
|
||||
|
||||
export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function formatForecastWind(
|
||||
value: number | null,
|
||||
language: Language,
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (value === null) return "—";
|
||||
return formatWindSpeed(value, language, unit);
|
||||
}
|
||||
@@ -65,7 +97,8 @@ function getForecastHourOfDay(time: string) {
|
||||
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
|
||||
const currentHour = getWarsawForecastHour();
|
||||
const currentTimestamp = parseForecastHour(currentHour);
|
||||
const upcomingHours = currentTimestamp === null
|
||||
const upcomingHours =
|
||||
currentTimestamp === null
|
||||
? hours.filter((hour) => hour.time >= currentHour)
|
||||
: hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
@@ -75,7 +108,8 @@ export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
|
||||
if (upcomingHours.length) return upcomingHours.slice(0, limit);
|
||||
|
||||
const currentHourOfDay = getForecastHourOfDay(currentHour);
|
||||
const fallbackHours = currentHourOfDay === null
|
||||
const fallbackHours =
|
||||
currentHourOfDay === null
|
||||
? hours
|
||||
: hours.filter((hour) => {
|
||||
const forecastHourOfDay = getForecastHourOfDay(hour.time);
|
||||
|
||||
293
lib/i18n.tsx
293
lib/i18n.tsx
@@ -21,36 +21,89 @@ const translations = {
|
||||
"theme.dark": "Włącz ciemny motyw",
|
||||
"settings.section": "Preferencje",
|
||||
"settings.title": "Ustawienia",
|
||||
"settings.description": "Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
|
||||
"settings.description": "Dostosuj wygląd, język, sekcje i powiadomienia.",
|
||||
"settings.language.title": "Język",
|
||||
"settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||
"settings.language.description":
|
||||
"Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||
"settings.theme.title": "Motyw",
|
||||
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
|
||||
"settings.dashboardSections.title": "Strona główna",
|
||||
"settings.dashboardSections.description": "Wybierz widoczność i kolejność sekcji pod główną kartą pogody.",
|
||||
"settings.dashboardSections.moveUp": "Przenieś sekcję {section} wyżej",
|
||||
"settings.dashboardSections.moveDown": "Przenieś sekcję {section} niżej",
|
||||
"settings.dashboardSections.warnings.title": "Ostrzeżenia",
|
||||
"settings.dashboardSections.warnings.description":
|
||||
"Pokazuje aktywne i nadchodzące ostrzeżenia dla wybranej lokalizacji. Na stronie głównej sekcja pojawia się tylko wtedy, gdy jest aktywne ostrzeżenie meteo.",
|
||||
"settings.dashboardSections.weatherBrief.title": "Brief dnia",
|
||||
"settings.dashboardSections.weatherBrief.description":
|
||||
"Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
|
||||
"settings.dashboardSections.forecast.title": "Prognoza 7-dniowa",
|
||||
"settings.dashboardSections.forecast.description":
|
||||
"Pokazuje najbliższe godziny, prognozę tygodniową i wykresy dnia.",
|
||||
"settings.dashboardSections.favorites.title": "Ulubione",
|
||||
"settings.dashboardSections.favorites.description": "Pokazuje zapisane lokalizacje i stacje na stronie głównej.",
|
||||
"settings.dashboardSections.featuredStations.title": "Szybki wybór",
|
||||
"settings.dashboardSections.featuredStations.description":
|
||||
"Pokazuje listę popularnych lokalizacji na dole strony głównej.",
|
||||
"settings.appSections.title": "Sekcje aplikacji",
|
||||
"settings.appSections.description": "Wybierz, które dodatkowe widoki mają być widoczne w nawigacji.",
|
||||
"settings.appSections.warnings.title": "Ostrzeżenia",
|
||||
"settings.appSections.warnings.description": "Pokazuje widok oficjalnych ostrzeżeń IMGW w głównej nawigacji.",
|
||||
"settings.appSections.hydro.title": "Hydro",
|
||||
"settings.appSections.hydro.description": "Pokazuje widok monitoringu hydrologicznego IMGW w głównej nawigacji.",
|
||||
"settings.units.temperature.title": "Jednostka temperatury",
|
||||
"settings.units.temperature.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.temperature.description":
|
||||
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.temperature.label": "Wybierz jednostkę temperatury",
|
||||
"settings.units.temperature.c": "°C",
|
||||
"settings.units.temperature.f": "°F",
|
||||
"settings.units.wind.title": "Jednostka wiatru",
|
||||
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.wind.description":
|
||||
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.wind.label": "Wybierz jednostkę wiatru",
|
||||
"settings.units.wind.ms": "m/s",
|
||||
"settings.units.wind.kmh": "km/h",
|
||||
"settings.units.wind.mph": "mph",
|
||||
"settings.units.wind.kt": "kt",
|
||||
"settings.units.wind.bft": "Bft",
|
||||
"settings.units.precipitation.title": "Jednostka opadu",
|
||||
"settings.units.precipitation.description":
|
||||
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.precipitation.label": "Wybierz jednostkę opadu",
|
||||
"settings.units.precipitation.mm": "mm",
|
||||
"settings.units.precipitation.cm": "cm",
|
||||
"settings.units.precipitation.in": "in",
|
||||
"settings.units.pressure.title": "Jednostka ciśnienia",
|
||||
"settings.units.pressure.description": "Stosowana w bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.pressure.label": "Wybierz jednostkę ciśnienia",
|
||||
"settings.units.pressure.hpa": "hPa",
|
||||
"settings.units.pressure.kpa": "kPa",
|
||||
"settings.units.pressure.inhg": "inHg",
|
||||
"settings.units.pressure.mmhg": "mm Hg",
|
||||
"settings.units.distance.title": "Jednostka dystansu",
|
||||
"settings.units.distance.description": "Stosowana przy odległości do najbliższej stacji.",
|
||||
"settings.units.distance.label": "Wybierz jednostkę dystansu",
|
||||
"settings.units.distance.km": "km",
|
||||
"settings.units.distance.mi": "mi",
|
||||
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
|
||||
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
|
||||
"settings.notifications.description":
|
||||
"Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
|
||||
"settings.notifications.regionTitle": "Obszar alertów",
|
||||
"settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
|
||||
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
|
||||
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
|
||||
"settings.notifications.noProvince": "brak wybranego województwa",
|
||||
"settings.notifications.globalRegion": "lokalizacja poza Polską",
|
||||
"settings.notifications.globalAlertsUnavailable":
|
||||
"Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.",
|
||||
"settings.notifications.deviceTitle": "To urządzenie",
|
||||
"settings.notifications.enable": "Alerty IMGW",
|
||||
"settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.",
|
||||
"settings.notifications.enableDescription": "Otrzymuj alerty dla wybranego obszaru.",
|
||||
"settings.notifications.enableGlobalDescription": "Otrzymuj briefy dla wybranej lokalizacji.",
|
||||
"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": "Krótkie podsumowanie pogody na dziś.",
|
||||
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
|
||||
"settings.notifications.tomorrowBriefDescription": "Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
|
||||
"settings.notifications.tomorrowBriefDescription": "Wieczorna prognoza na kolejny dzień.",
|
||||
"settings.notifications.enableDevice": "Włącz na tym urządzeniu",
|
||||
"settings.notifications.disable": "Wyłącz na tym urządzeniu",
|
||||
"settings.notifications.enabledStatus": "Włączone",
|
||||
@@ -65,13 +118,15 @@ const translations = {
|
||||
"settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.",
|
||||
"settings.notifications.requestPermission": "Sprawdź zgodę",
|
||||
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
|
||||
"settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.",
|
||||
"settings.notifications.statusUnconfigured": "Powiadomienia są niedostępne.",
|
||||
"settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.",
|
||||
"settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
|
||||
"settings.notifications.statusNeedsInstall":
|
||||
"Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
|
||||
"settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
|
||||
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
|
||||
"settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.",
|
||||
"settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW przez Web Push. W aktualnej wersji subskrypcje są przechowywane w pamięci procesu, więc produkcja wymaga podmiany magazynu na trwałą bazę lub KV.",
|
||||
"settings.notifications.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": "",
|
||||
"pwa.install": "Zainstaluj",
|
||||
"common.noData": "Brak danych",
|
||||
"common.loading": "Ładowanie danych",
|
||||
@@ -80,40 +135,51 @@ const translations = {
|
||||
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
|
||||
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
|
||||
"brief.label": "Brief dnia",
|
||||
"brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
|
||||
"brief.description": "Podsumowanie dla lokalizacji: {location}.",
|
||||
"brief.tomorrowLabel": "Jutro",
|
||||
"brief.pushHint": "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
|
||||
"brief.pushHint":
|
||||
"Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
|
||||
"brief.error": "Nie udało się przygotować briefu dnia.",
|
||||
"brief.emptyTitle": "Brak briefu",
|
||||
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
|
||||
"location.label": "Twoja lokalizacja",
|
||||
"location.searchLabel": "Szukaj miejscowości w Polsce",
|
||||
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
|
||||
"location.searchLabel": "Szukaj miejscowości",
|
||||
"location.placeholder": "Wpisz miejscowość",
|
||||
"location.clear": "Wyczyść wyszukiwanie",
|
||||
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
|
||||
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
|
||||
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
|
||||
"location.empty": "Nie znaleziono pasującej miejscowości.",
|
||||
"location.nearest": "Najbliższa stacja IMGW",
|
||||
"location.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.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}.",
|
||||
"location.currentSourceGlobal":
|
||||
"{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.",
|
||||
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
|
||||
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
|
||||
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
|
||||
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
|
||||
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
|
||||
"location.heroHybridPartial":
|
||||
"Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance}",
|
||||
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance}",
|
||||
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
|
||||
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
|
||||
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance}",
|
||||
"location.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.",
|
||||
"location.attribution": "Wyszukiwanie miejscowości:",
|
||||
"location.gpsUse": "Użyj mojej lokalizacji",
|
||||
"location.gpsLocating": "Ustalam lokalizację…",
|
||||
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
|
||||
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.",
|
||||
"location.gpsPromptDescription":
|
||||
"Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.",
|
||||
"location.gpsAllow": "Użyj GPS",
|
||||
"location.gpsNotNow": "Nie teraz",
|
||||
"location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.",
|
||||
"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.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.",
|
||||
"location.gpsDenied": "Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
|
||||
"location.gpsDenied":
|
||||
"Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
|
||||
"location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.",
|
||||
"location.gpsPositionUnavailable": "Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
|
||||
"location.gpsPositionUnavailable":
|
||||
"Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
|
||||
"location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.",
|
||||
"location.gpsFallbackName": "Bieżąca lokalizacja",
|
||||
"location.gpsSelected": "Wybrano lokalizację: {location}.",
|
||||
@@ -129,9 +195,11 @@ const translations = {
|
||||
"weather.wind": "Wiatr",
|
||||
"weather.rainfall": "Suma opadu",
|
||||
"weather.rainfall10m": "Opad 10 min",
|
||||
"weather.currentPrecipitation": "Opad",
|
||||
"weather.pressure": "Ciśnienie",
|
||||
"weather.feelsLike": "Odczuwalna",
|
||||
"weather.measurement": "pomiar",
|
||||
"weather.modelUpdate": "aktualizacja modelu",
|
||||
"weather.windDirection": "Kierunek wiatru",
|
||||
"weather.calm": "Pogodnie",
|
||||
"weather.humid": "Wilgotno",
|
||||
@@ -151,7 +219,7 @@ const translations = {
|
||||
"weather.temperatureDetail": "Temperatura powietrza",
|
||||
"forecast.label": "Prognoza modelowa",
|
||||
"forecast.title": "Najbliższe godziny i dni",
|
||||
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
|
||||
"forecast.description": "Prognoza dla lokalizacji: {location}.",
|
||||
"forecast.hourly": "Najbliższe 24 godziny",
|
||||
"forecast.daily": "Prognoza 7-dniowa",
|
||||
"forecast.today": "Dzisiaj",
|
||||
@@ -176,8 +244,8 @@ const translations = {
|
||||
"forecast.maxProbability": "Maks. szansa opadu",
|
||||
"forecast.pastHour": "Miniona godzina",
|
||||
"forecast.source": "Źródło prognozy:",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
|
||||
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO i Open-Meteo.",
|
||||
"forecast.sourceFallbackDescription": "Open-Meteo.",
|
||||
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
|
||||
"forecast.emptyTitle": "Brak prognozy",
|
||||
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
|
||||
@@ -194,7 +262,8 @@ const translations = {
|
||||
"station.all": "Wszystkie stacje",
|
||||
"station.label": "Stacja {name}",
|
||||
"station.parameters": "Aktualne parametry",
|
||||
"station.parametersDescription": "Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
|
||||
"station.parametersDescription":
|
||||
"Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
|
||||
"station.error": "Nie udało się pobrać danych wybranej stacji IMGW.",
|
||||
"station.quality": "Jakość danych",
|
||||
"station.lastMeasurementImgw": "Ostatni pomiar IMGW",
|
||||
@@ -204,19 +273,24 @@ const translations = {
|
||||
"station.publicApi": "Publiczne API IMGW",
|
||||
"snapshot.label": "Snapshot pomiarowy",
|
||||
"snapshot.title": "Aktualne proporcje parametrów",
|
||||
"snapshot.description": "Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
|
||||
"snapshot.description":
|
||||
"Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
|
||||
"warnings.section": "Komunikaty IMGW",
|
||||
"warnings.title": "Ostrzeżenia",
|
||||
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
|
||||
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne IMGW.",
|
||||
"warnings.myProvince": "Mój obszar",
|
||||
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceDescription":
|
||||
"Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
|
||||
"warnings.myProvinceEmptyDescription":
|
||||
"IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
|
||||
"warnings.otherRegions": "Pozostałe regiony",
|
||||
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
|
||||
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
|
||||
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||
"warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski",
|
||||
"warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia są obecnie dostępne tylko dla Polski.",
|
||||
"warnings.drought": "Susza hydrologiczna",
|
||||
"warnings.levelUnknown": "Poziom nieokreślony",
|
||||
"warnings.level": "Stopień {level}",
|
||||
@@ -237,7 +311,7 @@ const translations = {
|
||||
"warnings.dashboard.viewAll": "Zobacz wszystkie",
|
||||
"hydro.section": "Monitoring wód IMGW",
|
||||
"hydro.title": "Hydro",
|
||||
"hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
|
||||
"hydro.description": "Najnowsze pomiary poziomu, temperatury i przepływu wody.",
|
||||
"hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
|
||||
"hydro.searchLabel": "Szukaj stacji hydrologicznej",
|
||||
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
|
||||
@@ -250,8 +324,10 @@ const translations = {
|
||||
"hydro.flow": "Przepływ",
|
||||
"hydro.levelMeasurement": "Pomiar poziomu: {date}",
|
||||
"offline.title": "Brak połączenia",
|
||||
"offline.description": "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||
"offline.description":
|
||||
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||
"offline.back": "Wróć do aplikacji",
|
||||
"offline.banner": "Brak połączenia · dane mogą być nieaktualne",
|
||||
},
|
||||
en: {
|
||||
"nav.weather": "Weather",
|
||||
@@ -268,13 +344,38 @@ const translations = {
|
||||
"theme.dark": "Enable dark theme",
|
||||
"settings.section": "Preferences",
|
||||
"settings.title": "Settings",
|
||||
"settings.description": "Manage language, appearance and preparation for IMGW meteorological warning notifications.",
|
||||
"settings.description": "Customize appearance, language, sections and notifications.",
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||
"settings.language.description":
|
||||
"Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||
"settings.theme.title": "Theme",
|
||||
"settings.theme.description": "Switch the light or dark appearance on this device.",
|
||||
"settings.dashboardSections.title": "Home screen",
|
||||
"settings.dashboardSections.description": "Choose visibility and order for sections below the main weather card.",
|
||||
"settings.dashboardSections.moveUp": "Move {section} up",
|
||||
"settings.dashboardSections.moveDown": "Move {section} down",
|
||||
"settings.dashboardSections.warnings.title": "Warnings",
|
||||
"settings.dashboardSections.warnings.description":
|
||||
"Shows active and upcoming warnings for the selected location. On the home screen, this section appears only when an active weather warning exists.",
|
||||
"settings.dashboardSections.weatherBrief.title": "Daily brief",
|
||||
"settings.dashboardSections.weatherBrief.description":
|
||||
"Shows the deterministic daily summary and a short forecast for tomorrow.",
|
||||
"settings.dashboardSections.forecast.title": "7-day forecast",
|
||||
"settings.dashboardSections.forecast.description": "Shows the next hours, weekly forecast and daily charts.",
|
||||
"settings.dashboardSections.favorites.title": "Favorites",
|
||||
"settings.dashboardSections.favorites.description": "Shows saved places and stations on the home screen.",
|
||||
"settings.dashboardSections.featuredStations.title": "Quick select",
|
||||
"settings.dashboardSections.featuredStations.description":
|
||||
"Shows the popular locations list at the bottom of the home screen.",
|
||||
"settings.appSections.title": "App sections",
|
||||
"settings.appSections.description": "Choose which additional views are visible in navigation.",
|
||||
"settings.appSections.warnings.title": "Warnings",
|
||||
"settings.appSections.warnings.description": "Shows the official IMGW warnings view in main navigation.",
|
||||
"settings.appSections.hydro.title": "Hydro",
|
||||
"settings.appSections.hydro.description": "Shows the IMGW hydrological monitoring view in main navigation.",
|
||||
"settings.units.temperature.title": "Temperature unit",
|
||||
"settings.units.temperature.description": "Used in forecasts, briefs, notifications and current readings on this device.",
|
||||
"settings.units.temperature.description":
|
||||
"Used in forecasts, briefs, notifications and current readings on this device.",
|
||||
"settings.units.temperature.label": "Choose temperature unit",
|
||||
"settings.units.temperature.c": "°C",
|
||||
"settings.units.temperature.f": "°F",
|
||||
@@ -284,20 +385,46 @@ const translations = {
|
||||
"settings.units.wind.ms": "m/s",
|
||||
"settings.units.wind.kmh": "km/h",
|
||||
"settings.units.wind.mph": "mph",
|
||||
"settings.units.wind.kt": "kt",
|
||||
"settings.units.wind.bft": "Bft",
|
||||
"settings.units.precipitation.title": "Precipitation unit",
|
||||
"settings.units.precipitation.description":
|
||||
"Used in forecasts, briefs, notifications and current readings on this device.",
|
||||
"settings.units.precipitation.label": "Choose precipitation unit",
|
||||
"settings.units.precipitation.mm": "mm",
|
||||
"settings.units.precipitation.cm": "cm",
|
||||
"settings.units.precipitation.in": "in",
|
||||
"settings.units.pressure.title": "Pressure unit",
|
||||
"settings.units.pressure.description": "Used in current readings on this device.",
|
||||
"settings.units.pressure.label": "Choose pressure unit",
|
||||
"settings.units.pressure.hpa": "hPa",
|
||||
"settings.units.pressure.kpa": "kPa",
|
||||
"settings.units.pressure.inhg": "inHg",
|
||||
"settings.units.pressure.mmhg": "mm Hg",
|
||||
"settings.units.distance.title": "Distance unit",
|
||||
"settings.units.distance.description": "Used for the distance to the nearest station.",
|
||||
"settings.units.distance.label": "Choose distance unit",
|
||||
"settings.units.distance.km": "km",
|
||||
"settings.units.distance.mi": "mi",
|
||||
"settings.notifications.title": "Weather warning notifications",
|
||||
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
|
||||
"settings.notifications.description":
|
||||
"Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
|
||||
"settings.notifications.regionTitle": "Alert area",
|
||||
"settings.notifications.regionDescription": "Current area for future notifications: {province}.",
|
||||
"settings.notifications.regionSelected": "Use the location selected in the app",
|
||||
"settings.notifications.regionManual": "Choose province manually",
|
||||
"settings.notifications.noProvince": "no province selected",
|
||||
"settings.notifications.globalRegion": "location outside Poland",
|
||||
"settings.notifications.globalAlertsUnavailable":
|
||||
"Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.",
|
||||
"settings.notifications.deviceTitle": "This device",
|
||||
"settings.notifications.enable": "IMGW alerts",
|
||||
"settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.",
|
||||
"settings.notifications.enableDescription": "Receive alerts for the selected area.",
|
||||
"settings.notifications.enableGlobalDescription": "Receive briefs for the selected location.",
|
||||
"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": "A short weather summary for today.",
|
||||
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
|
||||
"settings.notifications.tomorrowBriefDescription": "Sends a short evening forecast for the next day, including storm and rain signals from the model.",
|
||||
"settings.notifications.tomorrowBriefDescription": "An evening forecast for the next day.",
|
||||
"settings.notifications.enableDevice": "Enable on this device",
|
||||
"settings.notifications.disable": "Disable on this device",
|
||||
"settings.notifications.enabledStatus": "Enabled",
|
||||
@@ -312,13 +439,15 @@ const translations = {
|
||||
"settings.notifications.testError": "Unable to send the test notification.",
|
||||
"settings.notifications.requestPermission": "Check permission",
|
||||
"settings.notifications.statusChecking": "Checking notification support in this browser.",
|
||||
"settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.",
|
||||
"settings.notifications.statusUnconfigured": "Notifications are unavailable.",
|
||||
"settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.",
|
||||
"settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
|
||||
"settings.notifications.statusNeedsInstall":
|
||||
"On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
|
||||
"settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.",
|
||||
"settings.notifications.statusGranted": "This device has notification permission.",
|
||||
"settings.notifications.statusReady": "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.",
|
||||
"settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings through Web Push. This version stores subscriptions in process memory, so production needs a durable database or KV store.",
|
||||
"settings.notifications.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": "",
|
||||
"pwa.install": "Install",
|
||||
"common.noData": "No data",
|
||||
"common.loading": "Loading data",
|
||||
@@ -327,40 +456,51 @@ const translations = {
|
||||
"error.description": "Check your connection and try again.",
|
||||
"dashboard.error": "Unable to load the IMGW synoptic station list.",
|
||||
"brief.label": "Daily brief",
|
||||
"brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.",
|
||||
"brief.description": "Summary for: {location}.",
|
||||
"brief.tomorrowLabel": "Tomorrow",
|
||||
"brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.",
|
||||
"brief.error": "Unable to prepare the daily brief.",
|
||||
"brief.emptyTitle": "Brief unavailable",
|
||||
"brief.emptyDescription": "The forecast does not contain enough data for the next day.",
|
||||
"location.label": "Your location",
|
||||
"location.searchLabel": "Search places in Poland",
|
||||
"location.placeholder": "Enter a place, e.g. Piaseczno…",
|
||||
"location.searchLabel": "Search places",
|
||||
"location.placeholder": "Enter a place",
|
||||
"location.clear": "Clear search",
|
||||
"location.error": "Unable to search for places. Try again.",
|
||||
"location.preparing": "Preparing the nearest IMGW stations…",
|
||||
"location.empty": "No matching place was found in Poland.",
|
||||
"location.empty": "No matching place was found.",
|
||||
"location.nearest": "Nearest IMGW station",
|
||||
"location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.",
|
||||
"location.modelSource": "Model source",
|
||||
"location.currentSource":
|
||||
"{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} away.",
|
||||
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
|
||||
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
|
||||
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
|
||||
"location.currentSourceGlobal":
|
||||
"{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
|
||||
"location.heroGlobalModelSource": "Open-Meteo model current conditions for: {location}",
|
||||
"location.heroHybridLoading":
|
||||
"Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
|
||||
"location.heroHybridPartial":
|
||||
"Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} away",
|
||||
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} away",
|
||||
"location.heroStationFallback": "Fallback data from IMGW station: {station}",
|
||||
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||
"location.heroStationFallbackWithDistance":
|
||||
"Fallback data from IMGW station: {station} · approximately {distance} away",
|
||||
"location.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
|
||||
"location.attribution": "Place search:",
|
||||
"location.gpsUse": "Use my location",
|
||||
"location.gpsLocating": "Finding your location…",
|
||||
"location.gpsPromptTitle": "Show weather for your location?",
|
||||
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.",
|
||||
"location.gpsPromptDescription":
|
||||
"With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.",
|
||||
"location.gpsAllow": "Use GPS",
|
||||
"location.gpsNotNow": "Not now",
|
||||
"location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.",
|
||||
"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.gpsUnavailable": "This browser does not provide GPS location access.",
|
||||
"location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.",
|
||||
"location.gpsTimeout": "Your location could not be determined in time. Try again.",
|
||||
"location.gpsPositionUnavailable": "Your GPS location could not be determined. Check your device settings and try again.",
|
||||
"location.gpsPositionUnavailable":
|
||||
"Your GPS location could not be determined. Check your device settings and try again.",
|
||||
"location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.",
|
||||
"location.gpsFallbackName": "Current location",
|
||||
"location.gpsSelected": "Selected location: {location}.",
|
||||
@@ -376,9 +516,11 @@ const translations = {
|
||||
"weather.wind": "Wind",
|
||||
"weather.rainfall": "Rainfall total",
|
||||
"weather.rainfall10m": "Rainfall 10 min",
|
||||
"weather.currentPrecipitation": "Precipitation",
|
||||
"weather.pressure": "Pressure",
|
||||
"weather.feelsLike": "Feels like",
|
||||
"weather.measurement": "measurement",
|
||||
"weather.modelUpdate": "model update",
|
||||
"weather.windDirection": "Wind direction",
|
||||
"weather.calm": "Fair",
|
||||
"weather.humid": "Humid",
|
||||
@@ -394,11 +536,12 @@ const translations = {
|
||||
"weather.pressureDetail": "Atmospheric pressure",
|
||||
"weather.windSpeedDetail": "Current IMGW reading",
|
||||
"weather.windDirectionDetail": "Direction the wind is coming from",
|
||||
"weather.rainfallDetail": "Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.",
|
||||
"weather.rainfallDetail":
|
||||
"Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.",
|
||||
"weather.temperatureDetail": "Air temperature",
|
||||
"forecast.label": "Model forecast",
|
||||
"forecast.title": "Upcoming hours and days",
|
||||
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||
"forecast.description": "Forecast for: {location}.",
|
||||
"forecast.hourly": "Next 24 hours",
|
||||
"forecast.daily": "7-day forecast",
|
||||
"forecast.today": "Today",
|
||||
@@ -423,8 +566,8 @@ const translations = {
|
||||
"forecast.maxProbability": "Max. rain chance",
|
||||
"forecast.pastHour": "Past hour",
|
||||
"forecast.source": "Forecast source:",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
|
||||
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO and Open-Meteo.",
|
||||
"forecast.sourceFallbackDescription": "Open-Meteo.",
|
||||
"forecast.error": "Unable to load the model forecast.",
|
||||
"forecast.emptyTitle": "Forecast unavailable",
|
||||
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
|
||||
@@ -441,7 +584,8 @@ const translations = {
|
||||
"station.all": "All stations",
|
||||
"station.label": "Station {name}",
|
||||
"station.parameters": "Current parameters",
|
||||
"station.parametersDescription": "The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
|
||||
"station.parametersDescription":
|
||||
"The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
|
||||
"station.error": "Unable to load data for the selected IMGW station.",
|
||||
"station.quality": "Data details",
|
||||
"station.lastMeasurementImgw": "Latest IMGW measurement",
|
||||
@@ -451,12 +595,14 @@ const translations = {
|
||||
"station.publicApi": "Public IMGW API",
|
||||
"snapshot.label": "Measurement snapshot",
|
||||
"snapshot.title": "Current parameter proportions",
|
||||
"snapshot.description": "Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
|
||||
"snapshot.description":
|
||||
"Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
|
||||
"warnings.section": "IMGW notices",
|
||||
"warnings.title": "Warnings",
|
||||
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
|
||||
"warnings.description": "Current IMGW meteorological and hydrological warnings.",
|
||||
"warnings.myProvince": "My area",
|
||||
"warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceDescription":
|
||||
"IMGW notices for {province} are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceEmptyTitle": "No warnings for your area",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
|
||||
"warnings.otherRegions": "Other regions",
|
||||
@@ -464,6 +610,8 @@ const translations = {
|
||||
"warnings.error": "Unable to load meteorological or hydrological warnings.",
|
||||
"warnings.emptyTitle": "No active warnings",
|
||||
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
|
||||
"warnings.globalUnavailableTitle": "Official warnings only for Poland",
|
||||
"warnings.globalUnavailableDescription": "Official warnings are currently available only for Poland.",
|
||||
"warnings.drought": "Hydrological drought",
|
||||
"warnings.levelUnknown": "Level not specified",
|
||||
"warnings.level": "Level {level}",
|
||||
@@ -484,7 +632,7 @@ const translations = {
|
||||
"warnings.dashboard.viewAll": "View all",
|
||||
"hydro.section": "IMGW water monitoring",
|
||||
"hydro.title": "Hydro",
|
||||
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||
"hydro.description": "Latest water level, temperature and flow readings.",
|
||||
"hydro.error": "Unable to load IMGW hydrological stations.",
|
||||
"hydro.searchLabel": "Search hydrological stations",
|
||||
"hydro.searchPlaceholder": "Search by station, river or province…",
|
||||
@@ -497,8 +645,10 @@ const translations = {
|
||||
"hydro.flow": "Flow",
|
||||
"hydro.levelMeasurement": "Level measurement: {date}",
|
||||
"offline.title": "No connection",
|
||||
"offline.description": "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||
"offline.description":
|
||||
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||
"offline.back": "Back to the app",
|
||||
"offline.banner": "Offline · data may be out of date",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -539,12 +689,15 @@ export function I18nProvider({ children }: PropsWithChildren) {
|
||||
setLanguageState(nextLanguage);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
language,
|
||||
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||
setLanguage,
|
||||
t: (key, params) => translate(language, key, params),
|
||||
}), [language, setLanguage]);
|
||||
}),
|
||||
[language, setLanguage],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
normalizeHydroStation,
|
||||
normalizeSynopStation,
|
||||
normalizeWarning,
|
||||
} from "@/lib/weather-utils";
|
||||
import { normalizeHydroStation, normalizeSynopStation, normalizeWarning } from "@/lib/weather-utils";
|
||||
import type {
|
||||
HydroStation,
|
||||
MeteoStationPosition,
|
||||
@@ -66,7 +62,7 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
|
||||
fetchWarningsByKind("meteo", signal),
|
||||
fetchWarningsByKind("hydro", signal),
|
||||
]);
|
||||
const warnings = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
||||
const warnings = results.flatMap((result) => (result.status === "fulfilled" ? result.value : []));
|
||||
if (results.every((result) => result.status === "rejected")) {
|
||||
throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toNumber } from "@/lib/weather-utils";
|
||||
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
@@ -52,19 +53,27 @@ function hasNumericValue(value: unknown) {
|
||||
}
|
||||
|
||||
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
|
||||
return hasNumericValue(candidate.Temperature)
|
||||
&& hasNumericValue(candidate.Chill)
|
||||
&& hasNumericValue(candidate.Humidity)
|
||||
&& hasNumericValue(candidate.Wind_Speed)
|
||||
&& hasNumericValue(candidate.PressureMSL);
|
||||
return (
|
||||
hasNumericValue(candidate.Temperature) &&
|
||||
hasNumericValue(candidate.Chill) &&
|
||||
hasNumericValue(candidate.Humidity) &&
|
||||
hasNumericValue(candidate.Wind_Speed) &&
|
||||
hasNumericValue(candidate.PressureMSL)
|
||||
);
|
||||
}
|
||||
|
||||
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
|
||||
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
||||
return (
|
||||
hasNumericValue(candidate.Precipitation10m) ||
|
||||
hasNumericValue(candidate.Rain10m) ||
|
||||
hasNumericValue(candidate.Snow10m)
|
||||
);
|
||||
}
|
||||
|
||||
function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
||||
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row));
|
||||
const tenMinuteRow = rows.find(
|
||||
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row),
|
||||
);
|
||||
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
|
||||
if (!tenMinuteRow) return hourlyRow ?? null;
|
||||
if (!hourlyRow) return tenMinuteRow;
|
||||
@@ -73,19 +82,20 @@ function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
||||
|
||||
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
|
||||
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
|
||||
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row));
|
||||
const precipitationRows = rows.filter(
|
||||
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row),
|
||||
);
|
||||
if (!precipitationRows.length) return null;
|
||||
if (!fullRow) return precipitationRows[0];
|
||||
return precipitationRows.reduce((best, candidate) => (
|
||||
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
|
||||
));
|
||||
return precipitationRows.reduce((best, candidate) =>
|
||||
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
||||
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||
|
||||
const rows = payload.data.Data
|
||||
.flatMap((candidate): NormalizedHybridRow[] => {
|
||||
const rows = payload.data.Data.flatMap((candidate): NormalizedHybridRow[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as RawImgwHybridWeatherRow;
|
||||
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||
@@ -104,6 +114,12 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
|
||||
|
||||
return {
|
||||
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||
region: "PL",
|
||||
source: "IMGW",
|
||||
sourceLabel: "IMGW Hybrid",
|
||||
sourceType: fullRow ? "hybrid" : "model",
|
||||
isOfficial: true,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
measuredAt: row.measuredAt,
|
||||
temperature: toCelsius(row.row.Temperature),
|
||||
feelsLike: toCelsius(row.row.Chill),
|
||||
@@ -124,5 +140,17 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||
const response = await fetch(`/api/imgw-current?${params}`, { signal });
|
||||
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,9 +1,11 @@
|
||||
export function isImgwNoProductsResponse(value: unknown) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const response = value as { status?: unknown; message?: unknown };
|
||||
return response.status === false
|
||||
&& typeof response.message === "string"
|
||||
&& response.message.toLocaleLowerCase("en-US").includes("no products were found");
|
||||
return (
|
||||
response.status === false &&
|
||||
typeof response.message === "string" &&
|
||||
response.message.toLocaleLowerCase("en-US").includes("no products were found")
|
||||
);
|
||||
}
|
||||
|
||||
export async function readImgwResponseBody(response: Response) {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { LocationSearchResult, ReverseLocationResult } from "@/types/location";
|
||||
|
||||
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
|
||||
export async function fetchLocations(
|
||||
query: string,
|
||||
language: Language,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LocationSearchResult[]> {
|
||||
const params = new URLSearchParams({ query, language });
|
||||
const response = await fetch(`/api/locations/search?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
|
||||
return response.json() as Promise<LocationSearchResult[]>;
|
||||
}
|
||||
|
||||
export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise<ReverseLocationResult> {
|
||||
export async function fetchReverseLocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
language: Language,
|
||||
): Promise<ReverseLocationResult> {
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language });
|
||||
const response = await fetch(`/api/locations/reverse?${params}`);
|
||||
if (!response.ok) throw new Error("Reverse location search is temporarily unavailable.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
|
||||
import { getCountyTerytForLocation } from "@/lib/warning-regions";
|
||||
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||
|
||||
const stationAliases: Record<string, string> = {
|
||||
gdansk: "gdanskrebiechowo",
|
||||
@@ -29,10 +30,11 @@ function normalizeName(value: string) {
|
||||
|
||||
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
|
||||
const earthRadiusKm = 6371;
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const toRadians = (value: number) => (value * Math.PI) / 180;
|
||||
const latitudeDistance = toRadians(latitudeB - latitudeA);
|
||||
const longitudeDistance = toRadians(longitudeB - longitudeA);
|
||||
const a = Math.sin(latitudeDistance / 2) ** 2 +
|
||||
const a =
|
||||
Math.sin(latitudeDistance / 2) ** 2 +
|
||||
Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -64,6 +66,12 @@ export function findNearestSynopStation(
|
||||
name: location.name,
|
||||
province: location.province,
|
||||
district: location.district,
|
||||
country: "Polska",
|
||||
countryCode: "PL",
|
||||
admin1: location.province,
|
||||
admin2: location.district,
|
||||
timezone: "Europe/Warsaw",
|
||||
region: "PL",
|
||||
countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
@@ -72,3 +80,50 @@ export function findNearestSynopStation(
|
||||
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 { Province } from "@/types/province";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
interface VapidKeyResponse {
|
||||
configured: boolean;
|
||||
@@ -14,17 +15,28 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
|
||||
}
|
||||
|
||||
interface SavePushSubscriptionOptions {
|
||||
region?: WeatherRegion;
|
||||
officialWarningsEnabled?: boolean;
|
||||
morningBriefEnabled?: boolean;
|
||||
tomorrowBriefEnabled?: boolean;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
locationName?: string | null;
|
||||
timezone?: string | null;
|
||||
countyTeryt?: string | null;
|
||||
temperatureUnit?: TemperatureUnit;
|
||||
windSpeedUnit?: WindSpeedUnit;
|
||||
precipitationUnit?: PrecipitationUnit;
|
||||
pressureUnit?: PressureUnit;
|
||||
distanceUnit?: DistanceUnit;
|
||||
}
|
||||
|
||||
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) {
|
||||
export async function savePushSubscription(
|
||||
subscription: PushSubscription,
|
||||
province: Province | null,
|
||||
language: Language,
|
||||
options: SavePushSubscriptionOptions = {},
|
||||
) {
|
||||
const response = await fetch("/api/notifications/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -32,15 +44,20 @@ export async function savePushSubscription(subscription: PushSubscription, provi
|
||||
subscription: subscription.toJSON(),
|
||||
province,
|
||||
language,
|
||||
enabled: true,
|
||||
region: options.region ?? "PL",
|
||||
enabled: options.officialWarningsEnabled ?? true,
|
||||
morningBriefEnabled: options.morningBriefEnabled === true,
|
||||
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
|
||||
latitude: options.latitude ?? null,
|
||||
longitude: options.longitude ?? null,
|
||||
locationName: options.locationName ?? null,
|
||||
timezone: options.timezone ?? null,
|
||||
countyTeryt: options.countyTeryt ?? null,
|
||||
temperatureUnit: options.temperatureUnit,
|
||||
windSpeedUnit: options.windSpeedUnit,
|
||||
precipitationUnit: options.precipitationUnit,
|
||||
pressureUnit: options.pressureUnit,
|
||||
distanceUnit: options.distanceUnit,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to save push subscription.");
|
||||
|
||||
@@ -85,22 +85,22 @@ const provinceByStationId: Record<string, Province> = {
|
||||
};
|
||||
|
||||
const provinceLabels: Record<Province, Record<Language, string>> = {
|
||||
"dolnośląskie": { pl: "dolnośląskie", en: "Lower Silesian" },
|
||||
dolnośląskie: { pl: "dolnośląskie", en: "Lower Silesian" },
|
||||
"kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" },
|
||||
"lubelskie": { pl: "lubelskie", en: "Lublin" },
|
||||
"lubuskie": { pl: "lubuskie", en: "Lubusz" },
|
||||
"łódzkie": { pl: "łódzkie", en: "Łódź" },
|
||||
"małopolskie": { pl: "małopolskie", en: "Lesser Poland" },
|
||||
"mazowieckie": { pl: "mazowieckie", en: "Masovian" },
|
||||
"opolskie": { pl: "opolskie", en: "Opole" },
|
||||
"podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" },
|
||||
"podlaskie": { pl: "podlaskie", en: "Podlaskie" },
|
||||
"pomorskie": { pl: "pomorskie", en: "Pomeranian" },
|
||||
"śląskie": { pl: "śląskie", en: "Silesian" },
|
||||
"świętokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" },
|
||||
lubelskie: { pl: "lubelskie", en: "Lublin" },
|
||||
lubuskie: { pl: "lubuskie", en: "Lubusz" },
|
||||
łódzkie: { pl: "łódzkie", en: "Łódź" },
|
||||
małopolskie: { pl: "małopolskie", en: "Lesser Poland" },
|
||||
mazowieckie: { pl: "mazowieckie", en: "Masovian" },
|
||||
opolskie: { pl: "opolskie", en: "Opole" },
|
||||
podkarpackie: { pl: "podkarpackie", en: "Subcarpathian" },
|
||||
podlaskie: { pl: "podlaskie", en: "Podlaskie" },
|
||||
pomorskie: { pl: "pomorskie", en: "Pomeranian" },
|
||||
śląskie: { pl: "śląskie", en: "Silesian" },
|
||||
świętokrzyskie: { pl: "świętokrzyskie", en: "Świętokrzyskie" },
|
||||
"warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" },
|
||||
"wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" },
|
||||
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
||||
wielkopolskie: { pl: "wielkopolskie", en: "Greater Poland" },
|
||||
zachodniopomorskie: { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
||||
};
|
||||
|
||||
export const PROVINCES = Object.keys(provinceLabels) as Province[];
|
||||
@@ -123,11 +123,11 @@ export function getProvinceFromTeryt(code: string) {
|
||||
}
|
||||
|
||||
export function getProvinceForStation(stationId: string | null) {
|
||||
return stationId ? provinceByStationId[stationId] ?? null : null;
|
||||
return stationId ? (provinceByStationId[stationId] ?? null) : null;
|
||||
}
|
||||
|
||||
export function normalizeProvinceName(value: string | null | undefined) {
|
||||
return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null;
|
||||
return value ? (provinceBySimplifiedName[simplifyProvinceName(value)] ?? null) : null;
|
||||
}
|
||||
|
||||
export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import webPush, { type PushSubscription } from "web-push";
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import { formatProvinceName } from "@/lib/provinces";
|
||||
import type { WeatherBrief } from "@/lib/weather-brief";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
import type { WarningPushSubscription } from "@/types/notifications";
|
||||
import type { WeatherBrief } from "@/lib/weather-brief";
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
|
||||
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY;
|
||||
@@ -49,13 +49,20 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) {
|
||||
}
|
||||
|
||||
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
|
||||
const province = formatProvinceName(preference.province, preference.language);
|
||||
const region = preference.province
|
||||
? formatProvinceName(preference.province, preference.language)
|
||||
: (preference.locationName ?? "wtr.");
|
||||
const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
|
||||
const validity = formatWarningValidity(warning, preference.language);
|
||||
const level = getWarningLevelLabel(warning, preference.language);
|
||||
const bodyParts = preference.language === "pl"
|
||||
? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
|
||||
: [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
|
||||
const bodyParts =
|
||||
preference.language === "pl"
|
||||
? [
|
||||
`${region}: ${level}`,
|
||||
validity,
|
||||
warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null,
|
||||
]
|
||||
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
|
||||
|
||||
return {
|
||||
title: `IMGW: ${title}`,
|
||||
@@ -73,12 +80,15 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
|
||||
|
||||
export async function sendTestNotification(preference: WarningPushSubscription) {
|
||||
ensureWebPushConfigured();
|
||||
const province = formatProvinceName(preference.province, preference.language);
|
||||
const location = preference.province
|
||||
? formatProvinceName(preference.province, preference.language)
|
||||
: (preference.locationName ?? "Open-Meteo");
|
||||
const payload = JSON.stringify({
|
||||
title: preference.language === "pl" ? "wtr.: test powiadomień IMGW" : "wtr.: IMGW notification test",
|
||||
body: preference.language === "pl"
|
||||
? `Powiadomienia są aktywne dla województwa: ${province}.`
|
||||
: `Notifications are active for: ${province}.`,
|
||||
title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
|
||||
body:
|
||||
preference.language === "pl"
|
||||
? `Powiadomienia są aktywne dla: ${location}.`
|
||||
: `Notifications are active for: ${location}.`,
|
||||
url: "/settings",
|
||||
});
|
||||
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||
import {
|
||||
DEFAULT_DISTANCE_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
DEFAULT_PRESSURE_UNIT,
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
isDistanceUnit,
|
||||
isPrecipitationUnit,
|
||||
isPressureUnit,
|
||||
isTemperatureUnit,
|
||||
isWindSpeedUnit,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { WarningPushSubscription } from "@/types/notifications";
|
||||
import type { Province } from "@/types/province";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
interface PushDatabase {
|
||||
database: Database.Database;
|
||||
@@ -13,6 +25,7 @@ interface PushSubscriptionRow {
|
||||
endpoint: string;
|
||||
subscription_json: string;
|
||||
province: string;
|
||||
region?: string;
|
||||
language: string;
|
||||
enabled: number;
|
||||
morning_brief_enabled: number;
|
||||
@@ -20,9 +33,13 @@ interface PushSubscriptionRow {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
location_name: string | null;
|
||||
timezone: string | null;
|
||||
county_teryt: string | null;
|
||||
temperature_unit: string | null;
|
||||
wind_speed_unit: string | null;
|
||||
precipitation_unit: string | null;
|
||||
pressure_unit: string | null;
|
||||
distance_unit: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -50,7 +67,8 @@ function initializeDatabase(database: Database.Database) {
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
subscription_json TEXT NOT NULL,
|
||||
province TEXT NOT NULL,
|
||||
province TEXT NOT NULL DEFAULT 'global',
|
||||
region TEXT NOT NULL DEFAULT 'PL',
|
||||
language TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -58,9 +76,13 @@ function initializeDatabase(database: Database.Database) {
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
location_name TEXT,
|
||||
timezone TEXT,
|
||||
county_teryt TEXT,
|
||||
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
|
||||
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}',
|
||||
precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}',
|
||||
pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}',
|
||||
distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -89,6 +111,34 @@ function initializeDatabase(database: Database.Database) {
|
||||
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
const columns = database.prepare("PRAGMA table_info(push_subscriptions)").all() as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === "region")) {
|
||||
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN region TEXT NOT NULL DEFAULT 'PL'").run();
|
||||
}
|
||||
if (!columns.some((column) => column.name === "timezone")) {
|
||||
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run();
|
||||
}
|
||||
if (!columns.some((column) => column.name === "precipitation_unit")) {
|
||||
database
|
||||
.prepare(
|
||||
`ALTER TABLE push_subscriptions ADD COLUMN precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}'`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
if (!columns.some((column) => column.name === "pressure_unit")) {
|
||||
database
|
||||
.prepare(
|
||||
`ALTER TABLE push_subscriptions ADD COLUMN pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}'`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
if (!columns.some((column) => column.name === "distance_unit")) {
|
||||
database
|
||||
.prepare(
|
||||
`ALTER TABLE push_subscriptions ADD COLUMN distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}'`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
function getDatabase() {
|
||||
@@ -105,10 +155,12 @@ function getDatabase() {
|
||||
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
|
||||
try {
|
||||
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
|
||||
const region: WeatherRegion = row.region === "GLOBAL" || row.province === "global" ? "GLOBAL" : "PL";
|
||||
return {
|
||||
endpoint: row.endpoint,
|
||||
subscription,
|
||||
province: row.province as Province,
|
||||
province: row.province === "global" ? null : (row.province as Province),
|
||||
region,
|
||||
language: row.language === "en" ? "en" : "pl",
|
||||
enabled: row.enabled === 1,
|
||||
morningBriefEnabled: row.morning_brief_enabled === 1,
|
||||
@@ -116,9 +168,15 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
locationName: row.location_name,
|
||||
timezone: row.timezone,
|
||||
countyTeryt: row.county_teryt,
|
||||
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: isPrecipitationUnit(row.precipitation_unit)
|
||||
? row.precipitation_unit
|
||||
: DEFAULT_PRECIPITATION_UNIT,
|
||||
pressureUnit: isPressureUnit(row.pressure_unit) ? row.pressure_unit : DEFAULT_PRESSURE_UNIT,
|
||||
distanceUnit: isDistanceUnit(row.distance_unit) ? row.distance_unit : DEFAULT_DISTANCE_UNIT,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -128,11 +186,14 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
|
||||
}
|
||||
|
||||
export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
getDatabase().prepare(`
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO push_subscriptions (
|
||||
endpoint,
|
||||
subscription_json,
|
||||
province,
|
||||
region,
|
||||
language,
|
||||
enabled,
|
||||
morning_brief_enabled,
|
||||
@@ -140,15 +201,20 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
latitude,
|
||||
longitude,
|
||||
location_name,
|
||||
timezone,
|
||||
county_teryt,
|
||||
temperature_unit,
|
||||
wind_speed_unit,
|
||||
precipitation_unit,
|
||||
pressure_unit,
|
||||
distance_unit,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@endpoint,
|
||||
@subscriptionJson,
|
||||
@province,
|
||||
@region,
|
||||
@language,
|
||||
@enabled,
|
||||
@morningBriefEnabled,
|
||||
@@ -156,15 +222,20 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
@latitude,
|
||||
@longitude,
|
||||
@locationName,
|
||||
@timezone,
|
||||
@countyTeryt,
|
||||
@temperatureUnit,
|
||||
@windSpeedUnit,
|
||||
@precipitationUnit,
|
||||
@pressureUnit,
|
||||
@distanceUnit,
|
||||
@createdAt,
|
||||
@updatedAt
|
||||
)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
subscription_json = excluded.subscription_json,
|
||||
province = excluded.province,
|
||||
region = excluded.region,
|
||||
language = excluded.language,
|
||||
enabled = excluded.enabled,
|
||||
morning_brief_enabled = excluded.morning_brief_enabled,
|
||||
@@ -172,14 +243,21 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
latitude = excluded.latitude,
|
||||
longitude = excluded.longitude,
|
||||
location_name = excluded.location_name,
|
||||
timezone = excluded.timezone,
|
||||
county_teryt = excluded.county_teryt,
|
||||
temperature_unit = excluded.temperature_unit,
|
||||
wind_speed_unit = excluded.wind_speed_unit,
|
||||
precipitation_unit = excluded.precipitation_unit,
|
||||
pressure_unit = excluded.pressure_unit,
|
||||
distance_unit = excluded.distance_unit,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
endpoint: subscription.endpoint,
|
||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||
province: subscription.province,
|
||||
province: subscription.province ?? "global",
|
||||
region: subscription.region,
|
||||
language: subscription.language,
|
||||
enabled: subscription.enabled ? 1 : 0,
|
||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||
@@ -187,9 +265,13 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
latitude: subscription.latitude,
|
||||
longitude: subscription.longitude,
|
||||
locationName: subscription.locationName,
|
||||
timezone: subscription.timezone,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
temperatureUnit: subscription.temperatureUnit,
|
||||
windSpeedUnit: subscription.windSpeedUnit,
|
||||
precipitationUnit: subscription.precipitationUnit,
|
||||
pressureUnit: subscription.pressureUnit,
|
||||
distanceUnit: subscription.distanceUnit,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
});
|
||||
|
||||
124
lib/server-current-weather.ts
Normal file
124
lib/server-current-weather.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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 type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
||||
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
|
||||
@@ -17,19 +18,20 @@ export function parseForecastCoordinate(value: string | null, min: number, max:
|
||||
async function readImgwPayload(response: Response | null) {
|
||||
if (!response?.ok) return null;
|
||||
try {
|
||||
return await response.json() as RawImgwForecastResponse;
|
||||
return (await response.json()) as RawImgwForecastResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServerForecast(latitude: number, longitude: number) {
|
||||
export async function fetchServerForecast(latitude: number, longitude: number, region: WeatherRegion = "PL") {
|
||||
const openMeteoParams = new URLSearchParams({
|
||||
latitude: String(latitude),
|
||||
longitude: String(longitude),
|
||||
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
||||
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
|
||||
timezone: "Europe/Warsaw",
|
||||
daily:
|
||||
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
|
||||
timezone: region === "PL" ? "Europe/Warsaw" : "auto",
|
||||
forecast_days: "7",
|
||||
wind_speed_unit: "ms",
|
||||
});
|
||||
@@ -41,12 +43,20 @@ export async function fetchServerForecast(latitude: number, longitude: number) {
|
||||
});
|
||||
|
||||
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
||||
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
|
||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, {
|
||||
next: { revalidate: 900 },
|
||||
signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS),
|
||||
}),
|
||||
region === "PL"
|
||||
? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, {
|
||||
next: { revalidate: 900 },
|
||||
signal: AbortSignal.timeout(IMGW_TIMEOUT_MS),
|
||||
}).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable.");
|
||||
|
||||
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
||||
const openMeteoPayload = (await openMeteoResponse.json()) as RawWeatherForecast;
|
||||
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||
return mergeForecastSources(openMeteoPayload, imgwPayload);
|
||||
return mergeForecastSources(openMeteoPayload, imgwPayload, region);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherW
|
||||
if (!response.ok) {
|
||||
const details = await readImgwResponseBody(response);
|
||||
if (response.status === 404 && isImgwNoProductsResponse(details.json)) return [];
|
||||
throw new Error(`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`);
|
||||
throw new Error(
|
||||
`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`,
|
||||
);
|
||||
}
|
||||
const rows = await response.json() as RawWarning[];
|
||||
const rows = (await response.json()) as RawWarning[];
|
||||
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];
|
||||
}
|
||||
|
||||
117
lib/store.ts
117
lib/store.ts
@@ -4,8 +4,36 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
import type { Province } from "@/types/province";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import {
|
||||
DEFAULT_APP_SECTION_VISIBILITY,
|
||||
normalizeAppSectionVisibility,
|
||||
type AppSectionId,
|
||||
type AppSectionVisibility,
|
||||
} from "@/lib/app-sections";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_SECTION_ORDER,
|
||||
DEFAULT_DASHBOARD_SECTION_VISIBILITY,
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
type DashboardSectionId,
|
||||
type DashboardSectionOrder,
|
||||
type DashboardSectionVisibility,
|
||||
} from "@/lib/dashboard-sections";
|
||||
import {
|
||||
DEFAULT_DISTANCE_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
DEFAULT_PRESSURE_UNIT,
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
isDistanceUnit,
|
||||
isPrecipitationUnit,
|
||||
isPressureUnit,
|
||||
isTemperatureUnit,
|
||||
isWindSpeedUnit,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
|
||||
|
||||
type NotificationProvinceMode = "selected" | "manual";
|
||||
|
||||
@@ -20,6 +48,12 @@ interface WeatherStore {
|
||||
warningNotificationProvince: Province | null;
|
||||
temperatureUnit: TemperatureUnit;
|
||||
windSpeedUnit: WindSpeedUnit;
|
||||
precipitationUnit: PrecipitationUnit;
|
||||
pressureUnit: PressureUnit;
|
||||
distanceUnit: DistanceUnit;
|
||||
dashboardSections: DashboardSectionVisibility;
|
||||
dashboardSectionOrder: DashboardSectionOrder;
|
||||
appSections: AppSectionVisibility;
|
||||
toggleFavorite: (id: string) => void;
|
||||
selectStation: (id: string) => void;
|
||||
selectLocation: (location: SelectedLocation) => void;
|
||||
@@ -30,6 +64,12 @@ interface WeatherStore {
|
||||
setWarningNotificationProvince: (province: Province | null) => void;
|
||||
setTemperatureUnit: (unit: TemperatureUnit) => void;
|
||||
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
|
||||
setPrecipitationUnit: (unit: PrecipitationUnit) => void;
|
||||
setPressureUnit: (unit: PressureUnit) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void;
|
||||
moveDashboardSection: (section: DashboardSectionId, direction: -1 | 1) => void;
|
||||
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const useWeatherStore = create<WeatherStore>()(
|
||||
@@ -45,6 +85,12 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
warningNotificationProvince: null,
|
||||
temperatureUnit: DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: DEFAULT_PRECIPITATION_UNIT,
|
||||
pressureUnit: DEFAULT_PRESSURE_UNIT,
|
||||
distanceUnit: DEFAULT_DISTANCE_UNIT,
|
||||
dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY,
|
||||
dashboardSectionOrder: DEFAULT_DASHBOARD_SECTION_ORDER,
|
||||
appSections: DEFAULT_APP_SECTION_VISIBILITY,
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
favorites: state.favorites.includes(id)
|
||||
@@ -60,7 +106,72 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
|
||||
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
|
||||
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
||||
setPrecipitationUnit: (unit) => set({ precipitationUnit: unit }),
|
||||
setPressureUnit: (unit) => set({ pressureUnit: unit }),
|
||||
setDistanceUnit: (unit) => set({ distanceUnit: unit }),
|
||||
setDashboardSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
dashboardSections: { ...state.dashboardSections, [section]: visible },
|
||||
})),
|
||||
moveDashboardSection: (section, direction) =>
|
||||
set((state) => {
|
||||
const order = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
|
||||
const index = order.indexOf(section);
|
||||
const nextIndex = index + direction;
|
||||
if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return state;
|
||||
const nextOrder = [...order];
|
||||
[nextOrder[index], nextOrder[nextIndex]] = [nextOrder[nextIndex], nextOrder[index]];
|
||||
return { dashboardSectionOrder: nextOrder };
|
||||
}),
|
||||
{ name: "wtr:preferences" },
|
||||
setAppSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
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) return persisted;
|
||||
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
|
||||
const dashboardSectionOrder = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
|
||||
const appSections = normalizeAppSectionVisibility(state.appSections);
|
||||
const unitPreferences = {
|
||||
temperatureUnit: isTemperatureUnit(state.temperatureUnit) ? state.temperatureUnit : DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: isWindSpeedUnit(state.windSpeedUnit) ? state.windSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit: isPrecipitationUnit(state.precipitationUnit)
|
||||
? state.precipitationUnit
|
||||
: DEFAULT_PRECIPITATION_UNIT,
|
||||
pressureUnit: isPressureUnit(state.pressureUnit) ? state.pressureUnit : DEFAULT_PRESSURE_UNIT,
|
||||
distanceUnit: isDistanceUnit(state.distanceUnit) ? state.distanceUnit : DEFAULT_DISTANCE_UNIT,
|
||||
};
|
||||
if (!location) return { ...state, ...unitPreferences, dashboardSections, dashboardSectionOrder, appSections };
|
||||
const countryCode = location.countryCode ?? (location.province ? "PL" : null);
|
||||
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
|
||||
return {
|
||||
...state,
|
||||
...unitPreferences,
|
||||
dashboardSections,
|
||||
dashboardSectionOrder,
|
||||
appSections,
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,7 +50,8 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
|
||||
};
|
||||
|
||||
function normalizeRegionName(value: string | null | undefined) {
|
||||
return value
|
||||
return (
|
||||
value
|
||||
?.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
@@ -62,7 +63,8 @@ function normalizeRegionName(value: string | null | undefined) {
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
|
||||
.trim() ?? "";
|
||||
.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTerytCountyCode(value: string) {
|
||||
@@ -70,7 +72,11 @@ function normalizeTerytCountyCode(value: string) {
|
||||
return /^\d{4}/.test(code) ? code.slice(0, 4) : null;
|
||||
}
|
||||
|
||||
export function getCountyTerytForLocation(province: string | null | undefined, district: string | null | undefined, locationName: string | null | undefined) {
|
||||
export function getCountyTerytForLocation(
|
||||
province: string | null | undefined,
|
||||
district: string | null | undefined,
|
||||
locationName: string | null | undefined,
|
||||
) {
|
||||
const normalizedProvince = getProvinceForSelection(province, null);
|
||||
if (normalizedProvince !== "mazowieckie") return null;
|
||||
|
||||
@@ -79,7 +85,7 @@ export function getCountyTerytForLocation(province: string | null | undefined, d
|
||||
if (districtMatch) return districtMatch;
|
||||
|
||||
const normalizedLocation = normalizeRegionName(locationName);
|
||||
return normalizedLocation ? mazowieckieCountyTerytByName[normalizedLocation] ?? null : null;
|
||||
return normalizedLocation ? (mazowieckieCountyTerytByName[normalizedLocation] ?? null) : null;
|
||||
}
|
||||
|
||||
export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: string | null | undefined) {
|
||||
@@ -87,7 +93,11 @@ export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: strin
|
||||
return warning.terytCodes.some((code) => normalizeTerytCountyCode(code) === countyTeryt);
|
||||
}
|
||||
|
||||
export function warningMatchesLocalSelection(warning: WeatherWarning, province: Province | null, selectedLocation?: SelectedLocation | null) {
|
||||
export function warningMatchesLocalSelection(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
selectedLocation?: SelectedLocation | null,
|
||||
) {
|
||||
if (warning.kind === "meteo" && selectedLocation?.countyTeryt) {
|
||||
return warningMatchesCounty(warning, selectedLocation.countyTeryt);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
import type { Province } from "@/types/province";
|
||||
import { warningMatchesCounty } from "@/lib/warning-regions";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import {
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
formatPrecipitation as formatDisplayPrecipitation,
|
||||
formatTemperature as formatDisplayTemperature,
|
||||
formatWindSpeed,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
|
||||
|
||||
@@ -28,19 +35,10 @@ interface BuildWeatherBriefInput {
|
||||
language: Language;
|
||||
temperatureUnit?: TemperatureUnit;
|
||||
windSpeedUnit?: WindSpeedUnit;
|
||||
precipitationUnit?: PrecipitationUnit;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
function formatNumber(value: number, language: Language, digits = 0) {
|
||||
return new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatRainfall(value: number, language: Language) {
|
||||
return `${formatNumber(value, language, 1)} mm`;
|
||||
}
|
||||
|
||||
function formatHour(value: string) {
|
||||
return value.slice(11, 16);
|
||||
}
|
||||
@@ -74,14 +72,17 @@ function getWarsawDateKey(date: Date, dayOffset = 0) {
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
|
||||
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
|
||||
const shiftedDate = new Date(
|
||||
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
|
||||
);
|
||||
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(shiftedDate);
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
|
||||
}
|
||||
|
||||
@@ -98,7 +99,9 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
|
||||
const currentTimestamp = parseForecastHour(currentHour);
|
||||
const upcoming = hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
return hourTimestamp === null || currentTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
||||
return hourTimestamp === null || currentTimestamp === null
|
||||
? hour.time >= currentHour
|
||||
: hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
return upcoming.slice(0, limit);
|
||||
}
|
||||
@@ -135,11 +138,18 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
|
||||
}, null);
|
||||
}
|
||||
|
||||
function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, now: number) {
|
||||
function isRelevantMeteoWarning(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
countyTeryt: string | null | undefined,
|
||||
now: number,
|
||||
) {
|
||||
const validTo = getTimestamp(warning.validTo);
|
||||
return warning.kind === "meteo"
|
||||
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
|
||||
&& (validTo === null || validTo > now);
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
|
||||
(validTo === null || validTo > now)
|
||||
);
|
||||
}
|
||||
|
||||
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
|
||||
@@ -148,10 +158,17 @@ function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
|
||||
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
|
||||
}
|
||||
|
||||
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) {
|
||||
return warning.kind === "meteo"
|
||||
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
|
||||
&& warningOverlapsWarsawDate(warning, dateKey);
|
||||
function isRelevantMeteoWarningForDate(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
countyTeryt: string | null | undefined,
|
||||
dateKey: string,
|
||||
) {
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
|
||||
warningOverlapsWarsawDate(warning, dateKey)
|
||||
);
|
||||
}
|
||||
|
||||
function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
|
||||
@@ -164,7 +181,12 @@ function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => bo
|
||||
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
|
||||
}
|
||||
|
||||
function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | null, maximumProbability: number | null, language: Language) {
|
||||
function getTomorrowCondition(
|
||||
hours: HourlyForecast[],
|
||||
rainfallTotal: number | null,
|
||||
maximumProbability: number | null,
|
||||
language: Language,
|
||||
) {
|
||||
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
|
||||
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
|
||||
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
|
||||
@@ -199,7 +221,18 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
|
||||
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
|
||||
}
|
||||
|
||||
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
export function buildWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
province,
|
||||
countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
||||
now = new Date(),
|
||||
}: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
const hours = getUpcomingHours(forecast.hourly, now, 24);
|
||||
if (!hours.length) return null;
|
||||
|
||||
@@ -214,12 +247,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
|
||||
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
|
||||
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
||||
const temperatureRange =
|
||||
minimumTemperature !== null && maximumTemperature !== null
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
|
||||
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
||||
const severity: WeatherBriefSeverity = topWarning ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
const severity: WeatherBriefSeverity = topWarning
|
||||
? "warning"
|
||||
: hasRainSignal || hasWindSignal
|
||||
? "attention"
|
||||
: "normal";
|
||||
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||
|
||||
const headline = topWarning
|
||||
@@ -241,45 +279,71 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
const body: string[] = [];
|
||||
if (topWarning) {
|
||||
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
|
||||
const probability = topWarning.probability !== null
|
||||
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
|
||||
const probability =
|
||||
topWarning.probability !== null
|
||||
? language === "pl"
|
||||
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
|
||||
: ` Probability: ${topWarning.probability}%.`
|
||||
: "";
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
|
||||
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
|
||||
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
|
||||
);
|
||||
}
|
||||
if (temperatureRange) {
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
|
||||
: `Temperature over the next 24 hours: ${temperatureRange}.`);
|
||||
: `Temperature over the next 24 hours: ${temperatureRange}.`,
|
||||
);
|
||||
}
|
||||
if (rainfallTotal !== null || maximumProbability !== null) {
|
||||
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
const rainWindow =
|
||||
rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl"
|
||||
? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
|
||||
: ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
: "";
|
||||
body.push(language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Rainfall: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
|
||||
);
|
||||
}
|
||||
if (maximumWind !== null) {
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
if (conditionPeakHour) {
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
|
||||
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`);
|
||||
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pushParts = [
|
||||
`${locationName}:`,
|
||||
temperatureRange,
|
||||
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null,
|
||||
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
|
||||
maximumProbability !== null
|
||||
? language === "pl"
|
||||
? `opad do ${maximumProbability}%`
|
||||
: `rain up to ${maximumProbability}%`
|
||||
: null,
|
||||
maximumWind !== null
|
||||
? language === "pl"
|
||||
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const warningPrefix = topWarning
|
||||
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
|
||||
? language === "pl"
|
||||
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
|
||||
: `IMGW: ${topWarning.title || "warning"}. `
|
||||
: "";
|
||||
|
||||
return {
|
||||
@@ -292,7 +356,18 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
export function buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
province,
|
||||
countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
||||
now = new Date(),
|
||||
}: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
const targetDateKey = getWarsawDateKey(now, 1);
|
||||
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
|
||||
if (!hours.length) return null;
|
||||
@@ -308,16 +383,19 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
|
||||
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
|
||||
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
|
||||
const hasRainSignal = hasThunderstorm
|
||||
|| (rainfallTotal ?? 0) >= 0.5
|
||||
|| (maximumProbability ?? 0) >= 35
|
||||
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
|
||||
const hasRainSignal =
|
||||
hasThunderstorm ||
|
||||
(rainfallTotal ?? 0) >= 0.5 ||
|
||||
(maximumProbability ?? 0) >= 35 ||
|
||||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
|
||||
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
||||
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
|
||||
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
||||
const temperatureRange =
|
||||
minimumTemperature !== null && maximumTemperature !== null
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
const severity: WeatherBriefSeverity =
|
||||
topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||
|
||||
const headline = topWarning
|
||||
@@ -343,49 +421,66 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
|
||||
const body: string[] = [];
|
||||
if (topWarning) {
|
||||
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
|
||||
const probability = topWarning.probability !== null
|
||||
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
|
||||
const probability =
|
||||
topWarning.probability !== null
|
||||
? language === "pl"
|
||||
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
|
||||
: ` Probability: ${topWarning.probability}%.`
|
||||
: "";
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
|
||||
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
|
||||
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
|
||||
);
|
||||
}
|
||||
if (temperatureRange) {
|
||||
body.push(language === "pl"
|
||||
? `Temperatura jutro: ${temperatureRange}.`
|
||||
: `Temperature tomorrow: ${temperatureRange}.`);
|
||||
body.push(
|
||||
language === "pl" ? `Temperatura jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
|
||||
);
|
||||
}
|
||||
body.push(language === "pl"
|
||||
? `Sygnał modelu: ${condition}.`
|
||||
: `Model signal: ${condition}.`);
|
||||
body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
|
||||
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
|
||||
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
const rainWindow =
|
||||
rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl"
|
||||
? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
|
||||
: ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
: "";
|
||||
body.push(language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
|
||||
);
|
||||
} else {
|
||||
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
|
||||
}
|
||||
if (maximumWind !== null) {
|
||||
body.push(language === "pl"
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
const rainPushPart =
|
||||
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)
|
||||
: null;
|
||||
const pushParts = [
|
||||
`${locationName}:`,
|
||||
temperatureRange,
|
||||
condition,
|
||||
rainPushPart,
|
||||
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
|
||||
maximumWind !== null
|
||||
? language === "pl"
|
||||
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const warningPrefix = topWarning
|
||||
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
|
||||
? language === "pl"
|
||||
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
|
||||
: `IMGW: ${topWarning.title || "warning"}. `
|
||||
: "";
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,13 +11,19 @@ import type {
|
||||
import { translate, type Language } from "@/lib/i18n";
|
||||
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
|
||||
import type { CurrentWeatherCondition } from "@/types/imgw-current";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
|
||||
export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c";
|
||||
export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"];
|
||||
export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "kmh";
|
||||
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
|
||||
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph", "kt", "bft"];
|
||||
export const DEFAULT_PRECIPITATION_UNIT: PrecipitationUnit = "mm";
|
||||
export const PRECIPITATION_UNITS: PrecipitationUnit[] = ["mm", "cm", "in"];
|
||||
export const DEFAULT_PRESSURE_UNIT: PressureUnit = "hpa";
|
||||
export const PRESSURE_UNITS: PressureUnit[] = ["hpa", "kpa", "inhg", "mmhg"];
|
||||
export const DEFAULT_DISTANCE_UNIT: DistanceUnit = "km";
|
||||
export const DISTANCE_UNITS: DistanceUnit[] = ["km", "mi"];
|
||||
|
||||
const temperatureUnitLabels: Record<TemperatureUnit, string> = {
|
||||
c: "°C",
|
||||
@@ -28,6 +34,26 @@ const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
|
||||
ms: "m/s",
|
||||
kmh: "km/h",
|
||||
mph: "mph",
|
||||
kt: "kt",
|
||||
bft: "Bft",
|
||||
};
|
||||
|
||||
const precipitationUnitLabels: Record<PrecipitationUnit, string> = {
|
||||
mm: "mm",
|
||||
cm: "cm",
|
||||
in: "in",
|
||||
};
|
||||
|
||||
const pressureUnitLabels: Record<PressureUnit, string> = {
|
||||
hpa: "hPa",
|
||||
kpa: "kPa",
|
||||
inhg: "inHg",
|
||||
mmhg: "mm Hg",
|
||||
};
|
||||
|
||||
const distanceUnitLabels: Record<DistanceUnit, string> = {
|
||||
km: "km",
|
||||
mi: "mi",
|
||||
};
|
||||
|
||||
export function toNumber(value: unknown): number | null {
|
||||
@@ -85,10 +111,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null
|
||||
}
|
||||
|
||||
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
|
||||
const provinces = [...new Set([
|
||||
const provinces = [
|
||||
...new Set(
|
||||
[
|
||||
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
||||
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
||||
].filter((province) => province !== null))];
|
||||
].filter((province) => province !== null),
|
||||
),
|
||||
];
|
||||
const describedAreas = (raw.obszary ?? [])
|
||||
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
||||
.filter((area): area is string => Boolean(area));
|
||||
@@ -106,7 +136,9 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
|
||||
publishedAt: normalizeDate(raw.opublikowano),
|
||||
probability: toNumber(raw.prawdopodobienstwo),
|
||||
areas,
|
||||
terytCodes: Array.isArray(raw.teryt) ? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0) : [],
|
||||
terytCodes: Array.isArray(raw.teryt)
|
||||
? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0)
|
||||
: [],
|
||||
provinces,
|
||||
office: raw.biuro?.trim() || null,
|
||||
};
|
||||
@@ -125,19 +157,25 @@ export function convertTemperature(value: number, unit: TemperatureUnit) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatTemperatureValue(value: number, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
export function formatTemperatureValue(
|
||||
value: number,
|
||||
language: Language = "pl",
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
const formattedValue = new Intl.NumberFormat(locales[language], {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit);
|
||||
}
|
||||
|
||||
export function formatPressure(value: number | null, language: Language = "pl") {
|
||||
return value === null ? translate(language, "common.noData") : `${value.toFixed(1)} hPa`;
|
||||
export function formatTemperature(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
return value === null
|
||||
? translate(language, "common.noData")
|
||||
: formatTemperatureValue(convertTemperature(value, unit), language, unit);
|
||||
}
|
||||
|
||||
export function formatHumidity(value: number | null, language: Language = "pl") {
|
||||
@@ -152,14 +190,60 @@ export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
|
||||
return windSpeedUnitLabels[unit];
|
||||
}
|
||||
|
||||
export function isPrecipitationUnit(value: unknown): value is PrecipitationUnit {
|
||||
return typeof value === "string" && PRECIPITATION_UNITS.includes(value as PrecipitationUnit);
|
||||
}
|
||||
|
||||
export function getPrecipitationUnitLabel(unit: PrecipitationUnit) {
|
||||
return precipitationUnitLabels[unit];
|
||||
}
|
||||
|
||||
export function isPressureUnit(value: unknown): value is PressureUnit {
|
||||
return typeof value === "string" && PRESSURE_UNITS.includes(value as PressureUnit);
|
||||
}
|
||||
|
||||
export function getPressureUnitLabel(unit: PressureUnit) {
|
||||
return pressureUnitLabels[unit];
|
||||
}
|
||||
|
||||
export function isDistanceUnit(value: unknown): value is DistanceUnit {
|
||||
return typeof value === "string" && DISTANCE_UNITS.includes(value as DistanceUnit);
|
||||
}
|
||||
|
||||
export function getDistanceUnitLabel(unit: DistanceUnit) {
|
||||
return distanceUnitLabels[unit];
|
||||
}
|
||||
|
||||
export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
|
||||
if (unit === "kmh") return speed * 3.6;
|
||||
if (unit === "mph") return speed * 2.2369362921;
|
||||
if (unit === "kt") return speed * 1.9438444924;
|
||||
return speed;
|
||||
}
|
||||
|
||||
export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function convertWindSpeedToBeaufort(speed: number) {
|
||||
if (speed < 0.3) return 0;
|
||||
if (speed < 1.6) return 1;
|
||||
if (speed < 3.4) return 2;
|
||||
if (speed < 5.5) return 3;
|
||||
if (speed < 8) return 4;
|
||||
if (speed < 10.8) return 5;
|
||||
if (speed < 13.9) return 6;
|
||||
if (speed < 17.2) return 7;
|
||||
if (speed < 20.8) return 8;
|
||||
if (speed < 24.5) return 9;
|
||||
if (speed < 28.5) return 10;
|
||||
if (speed < 32.7) return 11;
|
||||
return 12;
|
||||
}
|
||||
|
||||
export function formatWindSpeed(
|
||||
speed: number | null,
|
||||
language: Language = "pl",
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (speed === null) return translate(language, "common.noData");
|
||||
if (unit === "bft") return `${convertWindSpeedToBeaufort(speed)} ${getWindSpeedUnitLabel(unit)}`;
|
||||
const convertedSpeed = convertWindSpeed(speed, unit);
|
||||
const digits = unit === "ms" ? 1 : 0;
|
||||
const formattedSpeed = new Intl.NumberFormat(locales[language], {
|
||||
@@ -169,14 +253,84 @@ export function formatWindSpeed(speed: number | null, language: Language = "pl",
|
||||
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function formatWind(
|
||||
speed: number | null,
|
||||
direction?: number | null,
|
||||
language: Language = "pl",
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (speed === null) return translate(language, "common.noData");
|
||||
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
||||
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
|
||||
}
|
||||
|
||||
export function formatRainfall(value: number | null, language: Language = "pl") {
|
||||
return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
|
||||
export function formatRainfall(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
||||
) {
|
||||
return formatPrecipitation(value, language, unit);
|
||||
}
|
||||
|
||||
export function convertPrecipitation(value: number, unit: PrecipitationUnit) {
|
||||
if (unit === "cm") return value / 10;
|
||||
if (unit === "in") return value / 25.4;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatPrecipitation(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
||||
) {
|
||||
if (value === null) return translate(language, "common.noData");
|
||||
const convertedValue = convertPrecipitation(value, unit);
|
||||
const digits = unit === "mm" ? (convertedValue < 1 ? 2 : 1) : unit === "cm" ? 2 : 2;
|
||||
const formattedValue = new Intl.NumberFormat(locales[language], {
|
||||
minimumFractionDigits: unit === "in" ? 2 : 0,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(convertedValue);
|
||||
return `${formattedValue} ${getPrecipitationUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function convertPressure(value: number, unit: PressureUnit) {
|
||||
if (unit === "kpa") return value / 10;
|
||||
if (unit === "inhg") return value * 0.0295299830714;
|
||||
if (unit === "mmhg") return value * 0.750061683;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatPressure(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: PressureUnit = DEFAULT_PRESSURE_UNIT,
|
||||
) {
|
||||
if (value === null) return translate(language, "common.noData");
|
||||
const convertedValue = convertPressure(value, unit);
|
||||
const digits = unit === "inhg" ? 2 : unit === "kpa" ? 1 : unit === "mmhg" ? 0 : 1;
|
||||
const formattedValue = new Intl.NumberFormat(locales[language], {
|
||||
minimumFractionDigits: unit === "inhg" ? 2 : 0,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(convertedValue);
|
||||
return `${formattedValue} ${getPressureUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function convertDistance(value: number, unit: DistanceUnit) {
|
||||
if (unit === "mi") return value * 0.621371;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatDistance(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: DistanceUnit = DEFAULT_DISTANCE_UNIT,
|
||||
) {
|
||||
if (value === null) return translate(language, "common.noData");
|
||||
const convertedValue = convertDistance(value, unit);
|
||||
const formattedValue = new Intl.NumberFormat(locales[language], {
|
||||
maximumFractionDigits: convertedValue < 10 ? 1 : 0,
|
||||
}).format(convertedValue);
|
||||
return `${formattedValue} ${getDistanceUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function formatWaterLevel(value: number | null, language: Language = "pl") {
|
||||
@@ -187,7 +341,11 @@ export function formatFlow(value: number | null, language: Language = "pl") {
|
||||
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null, language: Language = "pl", fallback = translate(language, "common.noData")) {
|
||||
export function formatDateTime(
|
||||
value: string | null,
|
||||
language: Language = "pl",
|
||||
fallback = translate(language, "common.noData"),
|
||||
) {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
@@ -215,9 +373,17 @@ export function calculateFeelsLike(temperature: number | null, humidity: number
|
||||
const c7 = 0.002211732;
|
||||
const c8 = 0.00072546;
|
||||
const c9 = -0.000003582;
|
||||
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 +
|
||||
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 +
|
||||
c9 * temperature ** 2 * humidity ** 2;
|
||||
return (
|
||||
c1 +
|
||||
c2 * temperature +
|
||||
c3 * humidity +
|
||||
c4 * temperature * humidity +
|
||||
c5 * temperature ** 2 +
|
||||
c6 * humidity ** 2 +
|
||||
c7 * temperature ** 2 * humidity +
|
||||
c8 * temperature * humidity ** 2 +
|
||||
c9 * temperature ** 2 * humidity ** 2
|
||||
);
|
||||
}
|
||||
return temperature;
|
||||
}
|
||||
@@ -243,8 +409,10 @@ function getForecastConditionDescription(code: number | null, language: Language
|
||||
if (code === 3) return translate(language, "forecast.condition.cloudy");
|
||||
if (code === 45 || code === 48) return translate(language, "forecast.condition.fog");
|
||||
if (code !== null && code >= 51 && code <= 57) return translate(language, "forecast.condition.drizzle");
|
||||
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return translate(language, "forecast.condition.rain");
|
||||
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return translate(language, "forecast.condition.snow");
|
||||
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)))
|
||||
return translate(language, "forecast.condition.rain");
|
||||
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86))
|
||||
return translate(language, "forecast.condition.snow");
|
||||
if (code !== null && code >= 95) return translate(language, "forecast.condition.thunderstorm");
|
||||
return null;
|
||||
}
|
||||
@@ -270,7 +438,11 @@ export function getWeatherDescription(
|
||||
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
|
||||
const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, language);
|
||||
const cloudCoverDescription = getCloudCoverDescription(currentCloudCover, language);
|
||||
if (cloudCoverDescription && (currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)) return cloudCoverDescription;
|
||||
if (
|
||||
cloudCoverDescription &&
|
||||
(currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)
|
||||
)
|
||||
return cloudCoverDescription;
|
||||
if (currentDescription) return currentDescription;
|
||||
if (cloudCoverDescription) return cloudCoverDescription;
|
||||
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);
|
||||
|
||||
1087
package-lock.json
generated
1087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"notifications:worker": "node scripts/notification-worker.mjs"
|
||||
},
|
||||
@@ -35,7 +39,9 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.8.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
126
public/sw.js
126
public/sw.js
@@ -1,42 +1,136 @@
|
||||
const CACHE_NAME = "wtr-shell-v4";
|
||||
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 CACHE_VERSION = "v6";
|
||||
const CACHE_PREFIX = "wtr-";
|
||||
const SHELL_CACHE = `${CACHE_PREFIX}shell-${CACHE_VERSION}`;
|
||||
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${CACHE_VERSION}`;
|
||||
const API_CACHE = `${CACHE_PREFIX}api-${CACHE_VERSION}`;
|
||||
const ACTIVE_CACHES = new Set([SHELL_CACHE, RUNTIME_CACHE, API_CACHE]);
|
||||
|
||||
const SHELL = [
|
||||
"/",
|
||||
"/warnings",
|
||||
"/hydro",
|
||||
"/settings",
|
||||
"/offline",
|
||||
"/manifest.json",
|
||||
"/icons/icon.svg",
|
||||
"/icons/maskable.svg",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/maskable-512.png",
|
||||
];
|
||||
|
||||
function isCacheableResponse(response) {
|
||||
return response && response.ok && response.type !== "opaque";
|
||||
}
|
||||
|
||||
async function putInCache(cacheName, request, response) {
|
||||
if (!isCacheableResponse(response)) return;
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
|
||||
function isWeatherApiRequest(url) {
|
||||
return (
|
||||
url.pathname.startsWith("/api/imgw/") ||
|
||||
url.pathname === "/api/imgw-current" ||
|
||||
url.pathname === "/api/current-weather" ||
|
||||
url.pathname === "/api/forecast"
|
||||
);
|
||||
}
|
||||
|
||||
function isStaticAssetRequest(request, url) {
|
||||
return (
|
||||
url.origin === self.location.origin &&
|
||||
(url.pathname.startsWith("/_next/static/") ||
|
||||
url.pathname.startsWith("/icons/") ||
|
||||
url.pathname === "/manifest.json" ||
|
||||
["font", "image", "script", "style", "worker"].includes(request.destination))
|
||||
);
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
await putInCache(RUNTIME_CACHE, request, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
await putInCache(cacheName, request, response);
|
||||
return response;
|
||||
} catch {
|
||||
return (
|
||||
(await caches.match(request)) ||
|
||||
new Response(JSON.stringify({ error: "Offline." }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigation(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
await putInCache(SHELL_CACHE, request, response);
|
||||
return response;
|
||||
} catch {
|
||||
return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline"));
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(new Request(url, { cache: "reload" }))))),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && !ACTIVE_CACHES.has(key))
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request)),
|
||||
);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (isWeatherApiRequest(url)) {
|
||||
event.respondWith(networkFirst(event.request, API_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline"))));
|
||||
event.respondWith(handleNavigation(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticAssetRequest(event.request, url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const fallbackPayload = {
|
||||
title: "IMGW",
|
||||
body: "Nowe ostrzeżenie meteorologiczne.",
|
||||
body: "Nowe ostrze\u017cenie meteorologiczne.",
|
||||
url: "/warnings",
|
||||
};
|
||||
let payload = fallbackPayload;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const DEFAULT_APP_URL = "http://127.0.0.1:3000";
|
||||
const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
|
||||
const DEFAULT_BRIEF_INTERVAL_MINUTES = 5;
|
||||
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
||||
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
|
||||
const LOOP_INTERVAL_MS = 30_000;
|
||||
@@ -12,13 +13,26 @@ loadEnvFile(".env.local", true);
|
||||
|
||||
const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
|
||||
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
|
||||
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
|
||||
const 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 warningIntervalMinutes = readPositiveNumber(
|
||||
process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES,
|
||||
DEFAULT_WARNING_INTERVAL_MINUTES,
|
||||
);
|
||||
const briefIntervalMinutes = readPositiveNumber(
|
||||
process.env.NOTIFICATIONS_BRIEF_INTERVAL_MINUTES,
|
||||
DEFAULT_BRIEF_INTERVAL_MINUTES,
|
||||
);
|
||||
const morningBriefTime = normalizeTime(
|
||||
process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME,
|
||||
DEFAULT_MORNING_BRIEF_TIME,
|
||||
);
|
||||
const tomorrowBriefTime = normalizeTime(
|
||||
process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME,
|
||||
DEFAULT_TOMORROW_BRIEF_TIME,
|
||||
);
|
||||
|
||||
let lastWarningCheckAt = 0;
|
||||
let lastMorningBriefDate = "";
|
||||
let lastTomorrowBriefDate = "";
|
||||
let lastMorningBriefCheckAt = 0;
|
||||
let lastTomorrowBriefCheckAt = 0;
|
||||
let isRunningTick = false;
|
||||
|
||||
function loadEnvFile(path, overrideLoadedValues) {
|
||||
@@ -50,27 +64,6 @@ function normalizeTime(value, fallback) {
|
||||
return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getWarsawDateParts(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return {
|
||||
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
|
||||
time: `${part("hour")}:${part("minute")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isAtOrAfterTime(currentTime, targetTime) {
|
||||
return currentTime >= targetTime;
|
||||
}
|
||||
|
||||
async function callNotificationEndpoint(path) {
|
||||
const response = await fetch(`${appUrl}${path}`, {
|
||||
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
|
||||
@@ -106,6 +99,7 @@ async function tick() {
|
||||
isRunningTick = true;
|
||||
const now = Date.now();
|
||||
const warningIntervalMs = warningIntervalMinutes * 60_000;
|
||||
const briefIntervalMs = briefIntervalMinutes * 60_000;
|
||||
|
||||
try {
|
||||
if (now - lastWarningCheckAt >= warningIntervalMs) {
|
||||
@@ -113,15 +107,14 @@ async function tick() {
|
||||
await callNotificationEndpointSafely("/api/notifications/check");
|
||||
}
|
||||
|
||||
const warsawTime = getWarsawDateParts();
|
||||
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) {
|
||||
const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief");
|
||||
if (sentBrief) lastMorningBriefDate = warsawTime.dateKey;
|
||||
if (now - lastMorningBriefCheckAt >= briefIntervalMs) {
|
||||
lastMorningBriefCheckAt = now;
|
||||
await callNotificationEndpointSafely("/api/notifications/daily-brief");
|
||||
}
|
||||
|
||||
if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) {
|
||||
const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
|
||||
if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey;
|
||||
if (now - lastTomorrowBriefCheckAt >= briefIntervalMs) {
|
||||
lastTomorrowBriefCheckAt = now;
|
||||
await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(formatWorkerError(error));
|
||||
@@ -131,6 +124,8 @@ async function tick() {
|
||||
}
|
||||
|
||||
console.log(`wtr. notification worker: ${appUrl}`);
|
||||
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, tomorrow brief at ${tomorrowBriefTime} Europe/Warsaw.`);
|
||||
console.log(
|
||||
`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`,
|
||||
);
|
||||
void tick();
|
||||
setInterval(tick, LOOP_INTERVAL_MS);
|
||||
|
||||
@@ -2,12 +2,7 @@ import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./hooks/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./hooks/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
33
tests/dashboard-sections.test.ts
Normal file
33
tests/dashboard-sections.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_DASHBOARD_SECTION_ORDER,
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
} from "@/lib/dashboard-sections";
|
||||
|
||||
describe("dashboard sections", () => {
|
||||
it("keeps stored section order and appends missing defaults", () => {
|
||||
expect(normalizeDashboardSectionOrder(["forecast", "weatherBrief", "unknown"])).toEqual([
|
||||
"forecast",
|
||||
"weatherBrief",
|
||||
"warnings",
|
||||
"favorites",
|
||||
"featuredStations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the default order for invalid values", () => {
|
||||
expect(normalizeDashboardSectionOrder(null)).toEqual(DEFAULT_DASHBOARD_SECTION_ORDER);
|
||||
});
|
||||
|
||||
it("normalizes visibility for older stored preferences", () => {
|
||||
expect(normalizeDashboardSectionVisibility({ weatherBrief: false })).toEqual({
|
||||
warnings: true,
|
||||
weatherBrief: false,
|
||||
forecast: true,
|
||||
favorites: true,
|
||||
featuredStations: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
41
tests/forecast-utils.test.ts
Normal file
41
tests/forecast-utils.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getEffectiveHourlyWeatherCode, getForecastConditionKey, isForecastNight } from "@/lib/forecast-utils";
|
||||
|
||||
describe("forecast utilities", () => {
|
||||
it("promotes dry weather code to rain when precipitation probability is high", () => {
|
||||
expect(
|
||||
getEffectiveHourlyWeatherCode({
|
||||
weatherCode: 0,
|
||||
precipitation: 0,
|
||||
precipitationProbability: 100,
|
||||
}),
|
||||
).toBe(61);
|
||||
});
|
||||
|
||||
it("keeps clear weather code when there is no precipitation signal", () => {
|
||||
expect(
|
||||
getEffectiveHourlyWeatherCode({
|
||||
weatherCode: 0,
|
||||
precipitation: 0,
|
||||
precipitationProbability: 10,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("recognizes night hours from sunrise and sunset", () => {
|
||||
const day = {
|
||||
sunrise: "2026-06-15T05:00",
|
||||
sunset: "2026-06-15T21:00",
|
||||
};
|
||||
|
||||
expect(isForecastNight("2026-06-15T03:00", day)).toBe(true);
|
||||
expect(isForecastNight("2026-06-15T12:00", day)).toBe(false);
|
||||
expect(isForecastNight("2026-06-15T22:00", day)).toBe(true);
|
||||
});
|
||||
|
||||
it("maps storm and rain weather codes to condition keys", () => {
|
||||
expect(getForecastConditionKey(61)).toBe("forecast.condition.rain");
|
||||
expect(getForecastConditionKey(95)).toBe("forecast.condition.thunderstorm");
|
||||
});
|
||||
});
|
||||
67
tests/warning-regions.test.ts
Normal file
67
tests/warning-regions.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCountyTerytForLocation, warningMatchesCounty, warningMatchesLocalSelection } from "@/lib/warning-regions";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
|
||||
function warning(overrides: Partial<WeatherWarning> = {}): WeatherWarning {
|
||||
return {
|
||||
id: "meteo-1",
|
||||
kind: "meteo",
|
||||
level: 1,
|
||||
title: "Storm",
|
||||
description: null,
|
||||
comment: null,
|
||||
validFrom: null,
|
||||
validTo: null,
|
||||
publishedAt: null,
|
||||
probability: null,
|
||||
areas: [],
|
||||
terytCodes: [],
|
||||
provinces: ["mazowieckie"],
|
||||
office: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function location(overrides: Partial<SelectedLocation> = {}): SelectedLocation {
|
||||
return {
|
||||
name: "Warszawa",
|
||||
province: "mazowieckie",
|
||||
district: "Warszawa",
|
||||
country: "Poland",
|
||||
countryCode: "PL",
|
||||
admin1: "Mazowieckie",
|
||||
admin2: "Warszawa",
|
||||
timezone: "Europe/Warsaw",
|
||||
region: "PL",
|
||||
countyTeryt: "1465",
|
||||
latitude: 52.23,
|
||||
longitude: 21.01,
|
||||
stationId: null,
|
||||
stationName: null,
|
||||
distanceKm: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("warning region matching", () => {
|
||||
it("resolves Mazowieckie county codes from district names", () => {
|
||||
expect(getCountyTerytForLocation("mazowieckie", "Warszawa", null)).toBe("1465");
|
||||
expect(getCountyTerytForLocation("mazowieckie", "radomski", null)).toBe("1425");
|
||||
});
|
||||
|
||||
it("matches meteorological warnings by county TERYT prefix", () => {
|
||||
const warsawWarning = warning({ terytCodes: ["146501"] });
|
||||
|
||||
expect(warningMatchesCounty(warsawWarning, "1465")).toBe(true);
|
||||
expect(warningMatchesCounty(warsawWarning, "1425")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses county filtering before province fallback for selected locations", () => {
|
||||
const radomWarning = warning({ terytCodes: ["142501"] });
|
||||
|
||||
expect(warningMatchesLocalSelection(radomWarning, "mazowieckie", location())).toBe(false);
|
||||
expect(warningMatchesLocalSelection(radomWarning, "mazowieckie", location({ countyTeryt: null }))).toBe(true);
|
||||
});
|
||||
});
|
||||
133
tests/weather-brief.test.ts
Normal file
133
tests/weather-brief.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
|
||||
import type { WeatherForecast, HourlyForecast } from "@/types/forecast";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
import { getWeatherCapabilities } from "@/types/weather-region";
|
||||
|
||||
function hour(overrides: Partial<HourlyForecast> = {}): HourlyForecast {
|
||||
return {
|
||||
time: "2026-06-14T08:00",
|
||||
temperature: 10,
|
||||
feelsLike: 9,
|
||||
precipitationProbability: 20,
|
||||
precipitation: 0,
|
||||
weatherCode: 3,
|
||||
windSpeed: 4,
|
||||
source: "open-meteo",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function forecast(hours: HourlyForecast[]): WeatherForecast {
|
||||
return {
|
||||
latitude: 52.23,
|
||||
longitude: 21.01,
|
||||
region: "PL",
|
||||
timezone: "Europe/Warsaw",
|
||||
hourly: hours,
|
||||
daily: [],
|
||||
sources: ["open-meteo"],
|
||||
capabilities: getWeatherCapabilities("PL"),
|
||||
unavailableReasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
function warning(overrides: Partial<WeatherWarning> = {}): WeatherWarning {
|
||||
return {
|
||||
id: "meteo-1",
|
||||
kind: "meteo",
|
||||
level: 2,
|
||||
title: "Thunderstorms",
|
||||
description: null,
|
||||
comment: null,
|
||||
validFrom: "2026-06-15T06:00:00.000Z",
|
||||
validTo: "2026-06-15T18:00:00.000Z",
|
||||
publishedAt: null,
|
||||
probability: 80,
|
||||
areas: [],
|
||||
terytCodes: ["146501"],
|
||||
provinces: ["mazowieckie"],
|
||||
office: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("weather briefs", () => {
|
||||
it("uses selected temperature, wind and precipitation units in the morning brief", () => {
|
||||
const brief = buildWeatherBrief({
|
||||
forecast: forecast([
|
||||
hour({
|
||||
time: "2026-06-14T08:00",
|
||||
temperature: 10,
|
||||
windSpeed: 4,
|
||||
precipitationProbability: 80,
|
||||
precipitation: 25.4,
|
||||
}),
|
||||
hour({ time: "2026-06-14T09:00", temperature: 12, windSpeed: 5, precipitationProbability: 30 }),
|
||||
]),
|
||||
warnings: [],
|
||||
province: "mazowieckie",
|
||||
countyTeryt: "1465",
|
||||
locationName: "Warsaw",
|
||||
language: "en",
|
||||
temperatureUnit: "f",
|
||||
windSpeedUnit: "mph",
|
||||
precipitationUnit: "in",
|
||||
now: new Date("2026-06-14T06:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(brief).not.toBeNull();
|
||||
expect(brief?.pushBody).toContain("50°F-54°F");
|
||||
expect(brief?.pushBody).toContain("wind 11 mph");
|
||||
expect(brief?.pushBody).not.toContain("km/h");
|
||||
expect(brief?.body.join(" ")).toContain("1.00 in");
|
||||
});
|
||||
|
||||
it("warns about tomorrow storms from model weather codes without IMGW warning", () => {
|
||||
const brief = buildTomorrowWeatherBrief({
|
||||
forecast: forecast([
|
||||
hour({ time: "2026-06-15T08:00", temperature: 14, weatherCode: 3, precipitationProbability: 20 }),
|
||||
hour({
|
||||
time: "2026-06-15T15:00",
|
||||
temperature: 21,
|
||||
weatherCode: 95,
|
||||
precipitationProbability: 30,
|
||||
precipitation: 25.4,
|
||||
}),
|
||||
]),
|
||||
warnings: [],
|
||||
province: "mazowieckie",
|
||||
countyTeryt: "1465",
|
||||
locationName: "Warsaw",
|
||||
language: "en",
|
||||
precipitationUnit: "in",
|
||||
now: new Date("2026-06-14T16:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(brief?.severity).toBe("warning");
|
||||
expect(brief?.headline).toBe("Thunderstorms are possible tomorrow");
|
||||
expect(brief?.pushBody).toContain("thunderstorms");
|
||||
expect(brief?.pushBody).toContain("1.00 in");
|
||||
expect(brief?.pushBody).not.toContain("IMGW");
|
||||
});
|
||||
|
||||
it("prefers relevant IMGW county warning over province-wide context", () => {
|
||||
const brief = buildTomorrowWeatherBrief({
|
||||
forecast: forecast([hour({ time: "2026-06-15T12:00", weatherCode: 3 })]),
|
||||
warnings: [
|
||||
warning({ title: "Radom storm", terytCodes: ["142501"] }),
|
||||
warning({ title: "Warsaw storm", terytCodes: ["146501"] }),
|
||||
],
|
||||
province: "mazowieckie",
|
||||
countyTeryt: "1465",
|
||||
locationName: "Warsaw",
|
||||
language: "en",
|
||||
now: new Date("2026-06-14T16:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(brief?.headline).toBe("Tomorrow: IMGW Warsaw storm");
|
||||
expect(brief?.pushBody).toContain("IMGW: Warsaw storm");
|
||||
expect(brief?.pushBody).not.toContain("Radom storm");
|
||||
});
|
||||
});
|
||||
66
tests/weather-utils.test.ts
Normal file
66
tests/weather-utils.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_DISTANCE_UNIT,
|
||||
DEFAULT_PRECIPITATION_UNIT,
|
||||
DEFAULT_PRESSURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
formatDistance,
|
||||
formatPrecipitation,
|
||||
formatPressure,
|
||||
formatTemperature,
|
||||
formatWindSpeed,
|
||||
isDistanceUnit,
|
||||
isPrecipitationUnit,
|
||||
isPressureUnit,
|
||||
isTemperatureUnit,
|
||||
isWindSpeedUnit,
|
||||
} from "@/lib/weather-utils";
|
||||
|
||||
describe("weather utilities", () => {
|
||||
it("formats wind speed in km/h by default", () => {
|
||||
expect(DEFAULT_WIND_SPEED_UNIT).toBe("kmh");
|
||||
expect(formatWindSpeed(5, "en")).toBe("18 km/h");
|
||||
});
|
||||
|
||||
it("formats selected temperature and wind units", () => {
|
||||
expect(formatTemperature(10, "en", "f")).toBe("50°F");
|
||||
expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph");
|
||||
expect(formatWindSpeed(4, "en", "ms")).toBe("4.0 m/s");
|
||||
expect(formatWindSpeed(4, "en", "kt")).toBe("8 kt");
|
||||
expect(formatWindSpeed(4, "en", "bft")).toBe("3 Bft");
|
||||
});
|
||||
|
||||
it("formats selected precipitation, pressure and distance units", () => {
|
||||
expect(DEFAULT_PRECIPITATION_UNIT).toBe("mm");
|
||||
expect(DEFAULT_PRESSURE_UNIT).toBe("hpa");
|
||||
expect(DEFAULT_DISTANCE_UNIT).toBe("km");
|
||||
|
||||
expect(formatPrecipitation(25.4, "en", "mm")).toBe("25.4 mm");
|
||||
expect(formatPrecipitation(10, "en", "cm")).toBe("1 cm");
|
||||
expect(formatPrecipitation(25.4, "en", "in")).toBe("1.00 in");
|
||||
|
||||
expect(formatPressure(1013.25, "en", "hpa")).toBe("1,013.3 hPa");
|
||||
expect(formatPressure(1013.25, "en", "kpa")).toBe("101.3 kPa");
|
||||
expect(formatPressure(1013.25, "en", "inhg")).toBe("29.92 inHg");
|
||||
expect(formatPressure(1013.25, "en", "mmhg")).toBe("760 mm Hg");
|
||||
|
||||
expect(formatDistance(10, "en", "km")).toBe("10 km");
|
||||
expect(formatDistance(10, "en", "mi")).toBe("6.2 mi");
|
||||
});
|
||||
|
||||
it("validates supported presentation units", () => {
|
||||
expect(isTemperatureUnit("c")).toBe(true);
|
||||
expect(isTemperatureUnit("kelvin")).toBe(false);
|
||||
expect(isWindSpeedUnit("kmh")).toBe(true);
|
||||
expect(isWindSpeedUnit("kt")).toBe(true);
|
||||
expect(isWindSpeedUnit("bft")).toBe(true);
|
||||
expect(isWindSpeedUnit("knots")).toBe(false);
|
||||
expect(isPrecipitationUnit("in")).toBe(true);
|
||||
expect(isPrecipitationUnit("l")).toBe(false);
|
||||
expect(isPressureUnit("kpa")).toBe(true);
|
||||
expect(isPressureUnit("mbar")).toBe(false);
|
||||
expect(isDistanceUnit("mi")).toBe(true);
|
||||
expect(isDistanceUnit("m")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -23,19 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -65,8 +65,12 @@ export interface DailyForecast {
|
||||
export interface WeatherForecast {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
region: WeatherRegion;
|
||||
timezone: string;
|
||||
hourly: HourlyForecast[];
|
||||
daily: DailyForecast[];
|
||||
sources: ForecastSource[];
|
||||
capabilities: WeatherCapabilities;
|
||||
unavailableReasons: string[];
|
||||
}
|
||||
import type { WeatherCapabilities, WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
@@ -23,10 +23,11 @@ export interface RawImgwHybridWeatherResponse {
|
||||
}
|
||||
|
||||
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
|
||||
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only";
|
||||
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only" | "global-model";
|
||||
|
||||
export interface ImgwCurrentWeather {
|
||||
export interface ImgwCurrentWeather extends WeatherSourceMetadata {
|
||||
coverage: ImgwCurrentWeatherCoverage;
|
||||
region: WeatherRegion;
|
||||
measuredAt: string;
|
||||
temperature: number | null;
|
||||
feelsLike: number | null;
|
||||
@@ -41,3 +42,4 @@ export interface ImgwCurrentWeather {
|
||||
weatherCode: number | null;
|
||||
condition: CurrentWeatherCondition;
|
||||
}
|
||||
import type { WeatherRegion, WeatherSourceMetadata } from "@/types/weather-region";
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface RawWarning {
|
||||
id?: string | null;
|
||||
opublikowano?: string | null;
|
||||
stopien?: string | null;
|
||||
"stopień"?: string | null;
|
||||
stopień?: string | null;
|
||||
nazwa_zdarzenia?: string | null;
|
||||
data_od?: string | null;
|
||||
data_do?: string | null;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export interface LocationSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -5,22 +7,40 @@ export interface LocationSearchResult {
|
||||
longitude: number;
|
||||
province: string | null;
|
||||
district: string | null;
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
admin1: string | null;
|
||||
admin2: string | null;
|
||||
timezone: string | null;
|
||||
region: WeatherRegion;
|
||||
}
|
||||
|
||||
export interface ReverseLocationResult {
|
||||
name: string;
|
||||
province: string | null;
|
||||
district: string | null;
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
admin1: string | null;
|
||||
admin2: string | null;
|
||||
timezone: string | null;
|
||||
region: WeatherRegion;
|
||||
}
|
||||
|
||||
export interface SelectedLocation {
|
||||
name: string;
|
||||
province: string | null;
|
||||
district: string | null;
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
admin1: string | null;
|
||||
admin2: string | null;
|
||||
timezone: string | null;
|
||||
region: WeatherRegion;
|
||||
countyTeryt: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
distanceKm: number;
|
||||
stationId: string | null;
|
||||
stationName: string | null;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { Province } from "@/types/province";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export interface PushSubscriptionKeys {
|
||||
p256dh: string;
|
||||
@@ -16,7 +17,8 @@ export interface BrowserPushSubscription {
|
||||
export interface WarningPushSubscription {
|
||||
endpoint: string;
|
||||
subscription: BrowserPushSubscription;
|
||||
province: Province;
|
||||
province: Province | null;
|
||||
region: WeatherRegion;
|
||||
language: Language;
|
||||
enabled: boolean;
|
||||
morningBriefEnabled: boolean;
|
||||
@@ -24,9 +26,13 @@ export interface WarningPushSubscription {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
locationName: string | null;
|
||||
timezone: string | null;
|
||||
countyTeryt: string | null;
|
||||
temperatureUnit: TemperatureUnit;
|
||||
windSpeedUnit: WindSpeedUnit;
|
||||
precipitationUnit: PrecipitationUnit;
|
||||
pressureUnit: PressureUnit;
|
||||
distanceUnit: DistanceUnit;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export type TemperatureUnit = "c" | "f";
|
||||
export type WindSpeedUnit = "ms" | "kmh" | "mph";
|
||||
export type WindSpeedUnit = "ms" | "kmh" | "mph" | "kt" | "bft";
|
||||
export type PrecipitationUnit = "mm" | "cm" | "in";
|
||||
export type PressureUnit = "hpa" | "inhg" | "mmhg" | "kpa";
|
||||
export type DistanceUnit = "km" | "mi";
|
||||
|
||||
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"];
|
||||
}
|
||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user