Compare commits

..

46 Commits

Author SHA1 Message Date
zv
2c2515abba fix: show offline status in pwa
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 11m21s
2026-07-04 20:53:13 +02:00
zv
6bec7060e0 fix: cache pwa static assets offline
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
2026-07-04 20:41:19 +02:00
zv
c7da9e0d94 feat: allow dashboard section reordering 2026-07-04 20:31:56 +02:00
zv
91acdb39b8 feat: add weather unit preferences
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
2026-07-04 20:16:11 +02:00
zv
ab6b7b414f copy: simplify user-facing descriptions 2026-07-04 16:04:39 +02:00
zv
f15ebd28dc fix: preserve brief notification preferences
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 10m45s
2026-07-04 15:28:58 +02:00
zv
b1cbc771f8 fix: polish chart legends
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 9m58s
2026-06-14 21:03:13 +02:00
zv
169d5e5e59 fix: improve chart tooltips 2026-06-14 21:00:05 +02:00
zv
bdba3c709c test: add weather logic test suite
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 9m58s
2026-06-14 20:44:37 +02:00
zv
ee55521803 chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
2026-06-14 20:26:56 +02:00
zv
8bbd9397a1 feat: add app section visibility settings
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
2026-06-14 17:38:54 +02:00
zv
9087e1215c feat: add dashboard section visibility settings 2026-06-14 17:34:52 +02:00
zv
6be00da027 fix: simplify location search placeholder 2026-06-14 17:26:06 +02:00
zv
5049d81bd6 fix: remove duplicate weather condition badge 2026-06-14 17:23:18 +02:00
zv
2182297adc feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
2026-06-14 15:59:14 +02:00
zv
d572c9cc53 fix: show rain icon for likely hourly precipitation
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m55s
2026-06-14 12:52:42 +02:00
zv
bc8c346a7b fix: set mobile search input font size
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
2026-06-14 11:46:46 +02:00
zv
add8438307 fix: prevent mobile input focus zoom 2026-06-14 11:35:42 +02:00
zv
50d46be617 fix: use custom province dropdown
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
2026-06-14 11:28:24 +02:00
zv
bb03657c58 fix: align province selector chevron 2026-06-14 11:25:17 +02:00
zv
1cd0491e6e fix: align language dropdown button 2026-06-14 11:21:29 +02:00
zv
3820e01be6 fix: use custom language dropdown 2026-06-14 11:18:23 +02:00
zv
7dbcd8686b fix: refine language selector styling 2026-06-14 11:15:37 +02:00
zv
3515fca0da docs: document weather hero states
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m53s
2026-06-14 11:08:39 +02:00
zv
2d424655e5 fix: prefer hybrid cloud cover in hero 2026-06-14 11:05:04 +02:00
zv
a9b0c65527 feat: describe current sky in weather hero 2026-06-14 11:00:25 +02:00
zv
4b28e281c1 chore: update next type references
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m55s
2026-06-14 10:49:09 +02:00
zv
de06056596 feat: select first location suggestion on enter 2026-06-14 10:26:40 +02:00
zv
89d2066077 fix: position location suggestions under search 2026-06-14 10:25:19 +02:00
zv
074117869e docs: update agent verification rules
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m55s
2026-06-14 10:18:31 +02:00
zv
edcdc83a0e fix: round forecast modal on mobile 2026-06-14 10:09:33 +02:00
zv
f434126c96 fix: respect ios safe areas
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
2026-06-13 22:31:55 +02:00
zv
1ae1be68ec feat: persist push subscriptions in sqlite
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
2026-06-13 22:03:37 +02:00
zv
5a8e39dbe2 fix: show night icons for hourly forecast
All checks were successful
CI / Lint, typecheck and build (push) Successful in 10m2s
2026-06-13 21:27:41 +02:00
zv
83c0657296 fix: default wind unit to kmh 2026-06-13 21:21:52 +02:00
zv
cb113714f9 feat: add temperature unit preference 2026-06-13 21:05:01 +02:00
zv
66ead03f49 ci: add gitea quality workflow
All checks were successful
CI / Lint, typecheck and build (push) Successful in 11m26s
2026-06-13 20:05:53 +02:00
zv
72de545282 style: soften wind unit selector 2026-06-13 19:59:43 +02:00
zv
7ad95550eb feat: add configurable wind units 2026-06-13 13:36:36 +02:00
zv
a8d4d1e23c fix: improve notification settings switches 2026-06-13 13:14:26 +02:00
zv
c8c65df348 fix: keep thunderstorm effect visible in light theme 2026-06-13 12:59:59 +02:00
zv
db4db0832c fix: refine thunderstorm weather effect 2026-06-13 12:54:35 +02:00
zv
2245576bd4 docs: document dev weather effect preview 2026-06-13 12:26:39 +02:00
zv
7f8f52abd0 feat: add dev weather effect override 2026-06-13 12:24:08 +02:00
zv
169b3693b8 fix: limit precipitation effects to weather overlay 2026-06-13 12:19:44 +02:00
zv
3b58a31a37 fix: improve weather card precipitation effects 2026-06-13 12:12:08 +02:00
102 changed files with 7044 additions and 1252 deletions

View File

@@ -3,6 +3,8 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00

41
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,41 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
quality:
name: Lint, test, typecheck and build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- 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

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ out
!.env.example !.env.example
npm-debug.log* npm-debug.log*
.DS_Store .DS_Store
tsconfig.tsbuildinfo
data/*.sqlite
data/*.sqlite-*

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
out
coverage
data
*.sqlite
*.sqlite-*
tsconfig.tsbuildinfo
next-env.d.ts

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

View File

@@ -2,9 +2,9 @@
## Projekt ## Projekt
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. `wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową. Dla Polski pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Poza Polską pokazuje modelowe bieżące warunki i prognozę Open-Meteo bez oficjalnych ostrzeżeń IMGW. Open-Meteo Geocoding służy do globalnego wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React i `web-push`. PWA korzysta z manifestu oraz własnego service workera. Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React, `web-push` i SQLite przez `better-sqlite3`. PWA korzysta z manifestu oraz własnego service workera.
Najważniejsze katalogi: Najważniejsze katalogi:
@@ -23,12 +23,17 @@ Wymagany jest Node.js 20.9 lub nowszy.
npm install npm install
npm run dev npm run dev
npm run lint 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 build
npm run start npm run start
npm run notifications:worker npm run notifications:worker
``` ```
Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani formattera. `npm run build` uruchamia kontrolę TypeScript wykonywaną przez 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 ## Konwencje kodu
@@ -37,18 +42,21 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
- Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`. - Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`.
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji. - Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń tylko dla Polski. Poza Polską używaj Open-Meteo jako modelowego źródła bieżących warunków i prognozy. Nie generuj fikcyjnych danych, nie przedstawiaj prognozy jako pomiaru IMGW i nie przedstawiaj modelowych sygnałów jako oficjalnych alertów.
- Lokalizacja ma region `PL` albo `GLOBAL`. Preferuj capabilities i metadane źródła z `types/weather-region.ts` zamiast rozsiewania lokalnych warunków po kraju w UI.
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji. - Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`. - Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - Route handler prognozy pobiera pełne 7 dni Open-Meteo, a dla `PL` dodatkowo godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dla `GLOBAL` nie odpytuj ALARO i używaj Open-Meteo z lokalną strefą czasu. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. - Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
- HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera. - HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera.
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW. - Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i, dla `PL`, ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW.
- Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - 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. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
@@ -100,9 +108,15 @@ Przy zmianach UI:
- Utrzymuj mały zakres zmian. Nie refaktoruj niezwiązanych modułów i nie cofaj cudzych zmian. - Utrzymuj mały zakres zmian. Nie refaktoruj niezwiązanych modułów i nie cofaj cudzych zmian.
- Nie dodawaj mocków jako źródła danych pogodowych. Przy zmianie źródeł API zaktualizuj route handler, typy, normalizację, UI i README. - Nie dodawaj mocków jako źródła danych pogodowych. Przy zmianie źródeł API zaktualizuj route handler, typy, normalizację, UI i README.
- Przy zmianach PWA sprawdź spójność `public/manifest.json`, `public/sw.js` i rejestracji service workera. - Przy zmianach PWA sprawdź spójność `public/manifest.json`, `public/sw.js` i rejestracji service workera.
- Przed zakończeniem uruchom co najmniej `npm run lint` i `npm run build`. Jeśli dodano testy, uruchom także ich skrypt. - Dobieraj lokalną weryfikację proporcjonalnie do ryzyka zmiany. CI w Gitea Actions jest ostateczną bramką jakości po pushu.
- Sprawdź `git diff --check` oraz `git status`. Nie commituj wygenerowanego churnu w `next-env.d.ts`. - 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`.
- Po każdej zmianie rób commit i używaj formatu Conventional Commits, np. `fix: correct warning filtering`. - 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, 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.
- Nie pushuj automatycznie, chyba że użytkownik wyraźnie o to poprosi. Jeśli push wymaga autoryzacji, zatrzymaj się i poproś użytkownika o wykonanie `git push origin main`.
- Ważne decyzje o źródłach danych, ograniczeniach API, uruchamianiu i wdrożeniu dokumentuj krótko w `README.md`. - Ważne decyzje o źródłach danych, ograniczeniach API, uruchamianiu i wdrożeniu dokumentuj krótko w `README.md`.
## Utrzymywanie pliku ## Utrzymywanie pliku

View File

@@ -1,15 +1,15 @@
# wtr. # wtr.
`wtr.` to mobilna PWA pogodowa dla Polski oparta o publiczne dane IMGW oraz jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. `wtr.` to mobilna PWA pogodowa z pełnym trybem dla Polski i globalną prognozą modelową przez Open-Meteo.
Aplikacja pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, prognozę godzinową i 7-dniową, ostrzeżenia meteorologiczne i hydrologiczne, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI. Aplikacja pokazuje bieżącą analizę IMGW Hybrid dla Polski, modelowe warunki bieżące Open-Meteo poza Polską, prognozę godzinową i 7-dniową, ostrzeżenia IMGW dla Polski, dane hydro oraz deterministyczne briefy pogodowe bez użycia zewnętrznego modelu AI.
## Najważniejsze funkcje ## Najważniejsze funkcje
- Bieżące warunki z lokalnej analizy IMGW Hybrid, z opisanym fallbackiem do stacji synoptycznej. - Bieżące warunki: IMGW Hybrid w Polsce, Open-Meteo jako modelowe warunki bieżące poza Polską.
- Prognoza modelowa 7 dni: IMGW ALARO dla dostępnych godzin oraz Open-Meteo jako uzupełnienie i fallback. - Prognoza modelowa 7 dni: IMGW ALARO dla Polski oraz Open-Meteo jako globalny provider i fallback.
- Wyszukiwanie miejscowości w Polsce oraz opcjonalny wybór lokalizacji GPS. - Globalne wyszukiwanie miejscowości oraz opcjonalny wybór lokalizacji GPS.
- Ostrzeżenia IMGW z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza. - Ostrzeżenia IMGW dla Polski z filtrowaniem meteo po powiecie TERYT, gdy lokalizacja go dostarcza.
- Powiadomienia Web Push o nowych ostrzeżeniach, porannym briefie dnia i wieczornym briefie na jutro. - Powiadomienia Web Push o nowych ostrzeżeniach, porannym briefie dnia i wieczornym briefie na jutro.
- Widoki dashboardu, prognozy szczegółowej dnia, ostrzeżeń, hydro, stacji i ustawień. - Widoki dashboardu, prognozy szczegółowej dnia, ostrzeżeń, hydro, stacji i ustawień.
- PWA z manifestem, własnym service workerem i podstawowym fallbackiem offline. - PWA z manifestem, własnym service workerem i podstawowym fallbackiem offline.
@@ -25,6 +25,7 @@ Aplikacja pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, prognoz
- Recharts - Recharts
- Lucide React - Lucide React
- `web-push` - `web-push`
- SQLite przez `better-sqlite3` dla trwałego magazynu subskrypcji Web Push
- własny service worker i manifest PWA - własny service worker i manifest PWA
## Szybki Start ## Szybki Start
@@ -50,7 +51,9 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
``` ```
@@ -59,15 +62,36 @@ Przykład znajduje się w [.env.example](.env.example).
## Komendy ## Komendy
| Komenda | Opis | | Komenda | Opis |
| --- | --- | | ------------------------------ | --------------------------------------------------------------------------------------- |
| `npm run dev` | Uruchamia serwer deweloperski Next.js. | | `npm run dev` | Uruchamia serwer deweloperski Next.js. |
| `npm run lint` | Uruchamia ESLint. | | `npm run lint` | Uruchamia ESLint. |
| `npm run build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. | | `npm run format` | Formatuje obsługiwane pliki przez Prettier. |
| `npm run start` | Uruchamia zbudowaną aplikację. | | `npm run format:check` | Sprawdza formatowanie Prettier bez zapisywania zmian. |
| `npm run notifications:worker` | Uruchamia self-hostowany worker powiadomień. Wymaga działającej aplikacji Next.js. | | `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 osobnego skryptu testów, type-check 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
Repozytorium używa Gitea Actions. Workflow znajduje się w [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) i uruchamia się przy pushu do `main` oraz przy pull requestach.
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`.
CI jest ostateczną bramką jakości po pushu. Lokalnie dobieraj komendy proporcjonalnie do ryzyka zmiany: dokumentacja zwykle wymaga tylko przeglądu diffu, drobne zmiany UI nie wymagają rutynowego builda, a pełny zestaw `lint`, `typecheck` i `build` zostaw dla zmian wysokiego ryzyka.
## Struktura Projektu ## Struktura Projektu
@@ -93,7 +117,8 @@ docs/ szczegółowa dokumentacja techniczna
## Status i Ograniczenia ## Status i Ograniczenia
- `lib/push-store.ts` przechowuje subskrypcje Web Push w pamięci procesu. Produkcyjne wdrożenie wymaga trwałego magazynu, np. bazy danych albo KV. - `lib/push-store.ts` przechowuje subskrypcje Web Push i historię wysyłek w lokalnym SQLite. Na self-hostingu zadbaj o trwały katalog dla `WTR_DATABASE_PATH` i backup pliku bazy.
- Oficjalne ostrzeżenia pogodowe i dane hydrologiczne są obsługiwane tylko dla Polski przez IMGW. Poza Polską aplikacja pokazuje prognozę modelową Open-Meteo i briefy bez oficjalnych alertów.
- Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`. - Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`.
- Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń. - Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń.
- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary i prognozy są w UI rozdzielane. - Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary, modelowe warunki bieżące i prognozy są w UI rozdzielane.

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

View File

@@ -1,16 +1,22 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast"; import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
import type { WeatherRegion } from "@/types/weather-region";
function parseRegion(value: string | null): WeatherRegion {
return value === "GLOBAL" ? "GLOBAL" : "PL";
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90); const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180); const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
const region = parseRegion(searchParams.get("region"));
if (latitude === null || longitude === null) { if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
} }
try { try {
return NextResponse.json(await fetchServerForecast(latitude, longitude), { return NextResponse.json(await fetchServerForecast(latitude, longitude, region), {
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" }, headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
}); });
} catch { } catch {

View File

@@ -1,14 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response"; import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response";
const ALLOWED_PATHS = new Set([ const ALLOWED_PATHS = new Set(["synop", "hydro", "meteo", "warningsmeteo", "warningshydro", "product"]);
"synop",
"hydro",
"meteo",
"warningsmeteo",
"warningshydro",
"product",
]);
const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data"; const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data";
const USER_AGENT = "wtr./1.0 (weather data proxy)"; 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) { if (!response.ok) {
const body = await readImgwResponseBody(response); 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([], { return NextResponse.json([], {
headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" }, headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" },
}); });

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse"; const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)"; const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
@@ -13,6 +14,8 @@ interface RawReverseLocation {
municipality?: string; municipality?: string;
county?: string; county?: string;
state?: string; state?: string;
country?: string;
country_code?: string;
}; };
} }
@@ -46,21 +49,32 @@ export async function GET(request: Request) {
headers: { Accept: "application/json", "User-Agent": USER_AGENT }, headers: { Accept: "application/json", "User-Agent": USER_AGENT },
}); });
if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
const data = await response.json() as RawReverseLocation; const data = (await response.json()) as RawReverseLocation;
const name = data.address?.city const name =
?? data.address?.town data.address?.city ??
?? data.address?.village data.address?.town ??
?? data.address?.municipality data.address?.village ??
?? data.name data.address?.municipality ??
?? data.display_name?.split(",")[0]?.trim(); data.name ??
data.display_name?.split(",")[0]?.trim();
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 }); if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
return NextResponse.json({ const countryCode = data.address?.country_code?.toUpperCase() ?? null;
name, return NextResponse.json(
province: data.address?.state ?? null, {
district: data.address?.county ?? null, name,
}, { province: data.address?.state ?? null,
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, 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 { } catch {
return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
} }

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
@@ -7,8 +8,11 @@ interface RawLocation {
name?: string; name?: string;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
country?: string;
country_code?: string;
admin1?: string; admin1?: string;
admin2?: string; admin2?: string;
timezone?: string;
} }
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -19,27 +23,38 @@ export async function GET(request: Request) {
const params = new URLSearchParams({ const params = new URLSearchParams({
name: query, name: query,
count: "8", count: "10",
language, language,
format: "json", format: "json",
countryCode: "PL",
}); });
try { try {
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } }); const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 }); 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) => { const results = (data.results ?? []).flatMap((location) => {
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return []; if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude))
return [{ return [];
id: location.id, const countryCode = location.country_code?.toUpperCase() ?? null;
name: location.name, return [
latitude: location.latitude as number, {
longitude: location.longitude as number, id: location.id,
province: location.admin1 ?? null, name: location.name,
district: location.admin2 ?? null, 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 { } catch {
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 }); return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
} }

View File

@@ -1,6 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; 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 { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
import { warningMatchesCounty } from "@/lib/warning-regions"; import { warningMatchesCounty } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
@@ -40,8 +46,12 @@ export async function GET(request: Request) {
try { try {
const now = Date.now(); const now = Date.now();
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now)); const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) =>
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled); isRelevantWarning(warning, now),
);
const subscriptions = getPushSubscriptions().filter(
(subscription) => subscription.region === "PL" && subscription.enabled && subscription.province,
);
const activeWarningIds = new Set(warnings.map((warning) => warning.id)); const activeWarningIds = new Set(warnings.map((warning) => warning.id));
let sent = 0; let sent = 0;
let skipped = 0; let skipped = 0;
@@ -50,9 +60,11 @@ export async function GET(request: Request) {
pruneSentWarnings(activeWarningIds); pruneSentWarnings(activeWarningIds);
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const matchingWarnings = warnings.filter((warning) => ( const matchingWarnings = warnings.filter((warning) =>
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province) subscription.countyTeryt
)); ? warningMatchesCounty(warning, subscription.countyTeryt)
: subscription.province !== null && warning.provinces.includes(subscription.province),
);
for (const warning of matchingWarnings) { for (const warning of matchingWarnings) {
if (hasSentWarning(subscription.endpoint, warning.id)) { if (hasSentWarning(subscription.endpoint, warning.id)) {
skipped += 1; skipped += 1;
@@ -65,7 +77,8 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; 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); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }
@@ -80,9 +93,12 @@ export async function GET(request: Request) {
failed, failed,
}); });
} catch (error) { } catch (error) {
return NextResponse.json({ return NextResponse.json(
error: "Unable to check IMGW meteorological warnings.", {
details: getErrorMessage(error), error: "Unable to check IMGW meteorological warnings.",
}, { status: 502 }); details: getErrorMessage(error),
},
{ status: 502 },
);
} }
} }

View File

@@ -1,9 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast"; import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; 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 { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
import { buildWeatherBrief } from "@/lib/weather-brief"; import { buildWeatherBrief } from "@/lib/weather-brief";
import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -15,15 +21,27 @@ function isCronAuthorized(request: Request) {
return authHeader === `Bearer ${secret}` || secretHeader === secret; return authHeader === `Bearer ${secret}` || secretHeader === secret;
} }
function getWarsawDateKey(date = new Date()) { const DEFAULT_MORNING_BRIEF_TIME = "07:00";
function normalizeTime(value: string | undefined, fallback: string) {
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
}
function getLocalDateParts(timeZone: string | null, date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", { const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw", timeZone: timeZone || "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date); }).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
return `${part("year")}-${part("month")}-${part("day")}`; return {
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
time: `${part("hour")}:${part("minute")}`,
};
} }
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -35,41 +53,72 @@ export async function GET(request: Request) {
} }
const now = new Date(); const now = new Date();
const dateKey = getWarsawDateKey(now); const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => ( const allSubscriptions = getPushSubscriptions();
subscription.enabled const subscriptions = allSubscriptions.filter(
&& subscription.morningBriefEnabled (subscription) =>
&& Number.isFinite(subscription.latitude) subscription.morningBriefEnabled &&
&& Number.isFinite(subscription.longitude) 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; let warningsUnavailable = false;
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
warningsUnavailable = true; const warnings = hasPolishSubscriptions
return []; ? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
}); warningsUnavailable = true;
return [];
})
: [];
let sent = 0; let sent = 0;
let skipped = 0; let skipped = 0;
let failed = 0; let failed = 0;
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const localTime = getLocalDateParts(subscription.timezone, now);
if (localTime.time < briefTime) {
skipped += 1;
skippedReasons.beforeTime += 1;
continue;
}
const dateKey = localTime.dateKey;
if (hasSentMorningBrief(subscription.endpoint, dateKey)) { if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
skipped += 1; skipped += 1;
skippedReasons.alreadySent += 1;
continue; continue;
} }
try { try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); const forecast = await fetchServerForecast(
subscription.latitude as number,
subscription.longitude as number,
subscription.region,
);
const brief = buildWeatherBrief({ const brief = buildWeatherBrief({
forecast, forecast,
warnings, warnings: subscription.region === "PL" ? warnings : [],
province: subscription.province, province: subscription.province,
countyTeryt: subscription.countyTeryt, countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
locationName: subscription.locationName ?? "wtr.", locationName: subscription.locationName ?? "wtr.",
language: subscription.language, language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {
skipped += 1; skipped += 1;
skippedReasons.noBrief += 1;
continue; continue;
} }
await sendMorningBriefNotification(subscription, brief); await sendMorningBriefNotification(subscription, brief);
@@ -77,18 +126,21 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; 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); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
date: dateKey, time: briefTime,
subscriptions: subscriptions.length, subscriptions: allSubscriptions.length,
eligibleSubscriptions: subscriptions.length,
warningsUnavailable, warningsUnavailable,
sent, sent,
skipped, skipped,
skippedReasons,
failed, failed,
}); });
} }

View File

@@ -2,18 +2,33 @@ import { NextResponse } from "next/server";
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store"; import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
import { isWebPushConfigured } from "@/lib/push-service"; import { isWebPushConfigured } from "@/lib/push-service";
import { normalizeProvinceName } from "@/lib/provinces"; import { normalizeProvinceName } from "@/lib/provinces";
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 { Language } from "@/lib/i18n";
import type { BrowserPushSubscription } from "@/types/notifications"; import type { BrowserPushSubscription } from "@/types/notifications";
import type { WeatherRegion } from "@/types/weather-region";
export const runtime = "nodejs"; export const runtime = "nodejs";
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription { function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const subscription = value as Partial<BrowserPushSubscription>; const subscription = value as Partial<BrowserPushSubscription>;
return typeof subscription.endpoint === "string" return (
&& subscription.endpoint.startsWith("https://") typeof subscription.endpoint === "string" &&
&& typeof subscription.keys?.p256dh === "string" subscription.endpoint.startsWith("https://") &&
&& typeof subscription.keys.auth === "string"; typeof subscription.keys?.p256dh === "string" &&
typeof subscription.keys.auth === "string"
);
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -21,9 +36,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); 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; subscription?: unknown;
province?: unknown; province?: unknown;
region?: unknown;
language?: unknown; language?: unknown;
enabled?: unknown; enabled?: unknown;
morningBriefEnabled?: unknown; morningBriefEnabled?: unknown;
@@ -31,13 +47,26 @@ export async function POST(request: Request) {
latitude?: unknown; latitude?: unknown;
longitude?: unknown; longitude?: unknown;
locationName?: unknown; locationName?: unknown;
timezone?: unknown;
countyTeryt?: unknown; countyTeryt?: unknown;
temperatureUnit?: unknown;
windSpeedUnit?: unknown;
precipitationUnit?: unknown;
pressureUnit?: unknown;
distanceUnit?: unknown;
} | null; } | null;
const subscription = body?.subscription; const subscription = body?.subscription;
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
const language: Language = body?.language === "en" ? "en" : "pl"; const language: Language = body?.language === "en" ? "en" : "pl";
const rawTemperatureUnit = body?.temperatureUnit;
const 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 }); return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
} }
@@ -46,14 +75,22 @@ export async function POST(request: Request) {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
subscription, subscription,
province, province,
region,
language, language,
enabled: body?.enabled !== false, enabled,
morningBriefEnabled: body?.morningBriefEnabled === true, morningBriefEnabled: body?.morningBriefEnabled === true,
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true, tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null, latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null, longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null, locationName:
typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null,
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null, countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
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, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -62,7 +99,7 @@ export async function POST(request: Request) {
} }
export async function DELETE(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) { if (typeof body?.endpoint !== "string" || !body.endpoint) {
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
} }

View File

@@ -9,7 +9,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); 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) { if (typeof body?.endpoint !== "string" || !body.endpoint) {
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
} }
@@ -23,7 +23,8 @@ export async function POST(request: Request) {
await sendTestNotification(subscription); await sendTestNotification(subscription);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } 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); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 }); return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 });
} }

View File

@@ -1,9 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast"; import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; 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 { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief"; import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -15,25 +21,39 @@ function isCronAuthorized(request: Request) {
return authHeader === `Bearer ${secret}` || secretHeader === secret; return authHeader === `Bearer ${secret}` || secretHeader === secret;
} }
function getWarsawDateKey(date = new Date(), dayOffset = 0) { const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
function normalizeTime(value: string | undefined, fallback: string) {
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
}
function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset = 0) {
const parts = new Intl.DateTimeFormat("en-CA", { const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw", timeZone: timeZone || "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date); }).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`; const dateKey = `${part("year")}-${part("month")}-${part("day")}`;
const time = `${part("hour")}:${part("minute")}`;
if (dayOffset === 0) return { dateKey, time };
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12)); const shiftedDate = new Date(
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
);
const shiftedParts = new Intl.DateTimeFormat("en-CA", { const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw", timeZone: timeZone || "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}).formatToParts(shiftedDate); }).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? ""; const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`; shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
} }
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -45,41 +65,72 @@ export async function GET(request: Request) {
} }
const now = new Date(); const now = new Date();
const targetDateKey = getWarsawDateKey(now, 1); const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => ( const allSubscriptions = getPushSubscriptions();
subscription.enabled const subscriptions = allSubscriptions.filter(
&& subscription.tomorrowBriefEnabled (subscription) =>
&& Number.isFinite(subscription.latitude) subscription.tomorrowBriefEnabled &&
&& Number.isFinite(subscription.longitude) 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; let warningsUnavailable = false;
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => { const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
warningsUnavailable = true; const warnings = hasPolishSubscriptions
return []; ? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
}); warningsUnavailable = true;
return [];
})
: [];
let sent = 0; let sent = 0;
let skipped = 0; let skipped = 0;
let failed = 0; let failed = 0;
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const localToday = getLocalDateParts(subscription.timezone, now);
if (localToday.time < briefTime) {
skipped += 1;
skippedReasons.beforeTime += 1;
continue;
}
const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey;
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) { if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
skipped += 1; skipped += 1;
skippedReasons.alreadySent += 1;
continue; continue;
} }
try { try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number); const forecast = await fetchServerForecast(
subscription.latitude as number,
subscription.longitude as number,
subscription.region,
);
const brief = buildTomorrowWeatherBrief({ const brief = buildTomorrowWeatherBrief({
forecast, forecast,
warnings, warnings: subscription.region === "PL" ? warnings : [],
province: subscription.province, province: subscription.province,
countyTeryt: subscription.countyTeryt, countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
locationName: subscription.locationName ?? "wtr.", locationName: subscription.locationName ?? "wtr.",
language: subscription.language, language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {
skipped += 1; skipped += 1;
skippedReasons.noBrief += 1;
continue; continue;
} }
await sendTomorrowBriefNotification(subscription, brief); await sendTomorrowBriefNotification(subscription, brief);
@@ -87,18 +138,21 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; 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); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
date: targetDateKey, time: briefTime,
subscriptions: subscriptions.length, subscriptions: allSubscriptions.length,
eligibleSubscriptions: subscriptions.length,
warningsUnavailable, warningsUnavailable,
sent, sent,
skipped, skipped,
skippedReasons,
failed, failed,
}); });
} }

View File

@@ -46,7 +46,7 @@ html {
} }
body { body {
min-height: 100vh; min-height: 100dvh;
background: hsl(var(--background)); background: hsl(var(--background));
color: hsl(var(--foreground)); color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -64,6 +64,14 @@ select {
outline-offset: 3px; outline-offset: 3px;
} }
@media (max-width: 639px) {
input,
select,
textarea {
font-size: 16px;
}
}
@layer utilities { @layer utilities {
.glass { .glass {
@apply border border-border/70 bg-surface shadow-soft; @apply border border-border/70 bg-surface shadow-soft;
@@ -89,12 +97,50 @@ select {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.app-header-safe {
padding-top: env(safe-area-inset-top);
}
.app-main-safe {
padding-bottom: calc(7rem + env(safe-area-inset-bottom));
}
.mobile-nav-safe {
bottom: calc(0.75rem + env(safe-area-inset-bottom));
left: calc(0.75rem + env(safe-area-inset-left));
right: calc(0.75rem + env(safe-area-inset-right));
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: -1px 0 0; inset: -1px 0 0;
box-sizing: border-box;
height: calc(100dvh + 1px);
min-height: calc(100dvh + 1px); min-height: calc(100dvh + 1px);
background: hsl(var(--foreground) / 0.55); background: hsl(var(--foreground) / 0.55);
} }
.modal-safe-area {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
}
@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));
}
}
}
@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));
}
}
} }
@supports (-moz-appearance: none) { @supports (-moz-appearance: none) {

View File

@@ -27,7 +27,10 @@ export const metadata: Metadata = {
manifest: "/manifest.json", manifest: "/manifest.json",
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." }, appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." },
icons: { 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", apple: "/icons/icon-192.png",
}, },
}; };
@@ -46,8 +49,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
return ( return (
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth"> <html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
<body className={`${inter.variable} font-sans`}> <body className={`${inter.variable} font-sans`}>
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script> <Script id="wtr-theme" strategy="beforeInteractive">
<Script id="wtr-language" strategy="beforeInteractive">{languageScript}</Script> {themeScript}
</Script>
<Script id="wtr-language" strategy="beforeInteractive">
{languageScript}
</Script>
<Providers> <Providers>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</Providers> </Providers>

View File

@@ -8,10 +8,17 @@ export default function OfflinePage() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center"> <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> <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> <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> </section>
); );
} }

View 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>
);
}

View File

@@ -1,27 +1,84 @@
"use client"; "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 { Card } from "@/components/ui/card";
import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { CHART_COLORS } from "@/lib/chart-theme"; import { CHART_COLORS } from "@/lib/chart-theme";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; import { formatForecastTemperature } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
import {
convertPrecipitation,
convertTemperature,
formatTemperatureValue,
getPrecipitationUnitLabel,
getTemperatureUnitLabel,
} from "@/lib/weather-utils";
import type { HourlyForecast } from "@/types/forecast"; import type { HourlyForecast } from "@/types/forecast";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
interface ChartLegendItem {
color: string;
label: string;
marker: "bar" | "line" | "dashed-line";
}
function formatHour(value: string) { function formatHour(value: string) {
return value.slice(11, 16); 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[] }) { export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const rows = hours.map((hour) => ({ const rows = hours.map((hour) => ({
time: formatHour(hour.time), time: formatHour(hour.time),
temperature: hour.temperature, temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit),
feelsLike: hour.feelsLike, feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit),
precipitation: hour.precipitation, precipitation: hour.precipitation === null ? null : convertPrecipitation(hour.precipitation, precipitationUnit),
precipitationProbability: hour.precipitationProbability, 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 ( return (
<div className="grid gap-3 lg:grid-cols-2"> <div className="grid gap-3 lg:grid-cols-2">
@@ -29,48 +86,138 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
<div className="mt-4 h-56 min-w-0"> <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 }}> <ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" /> dataKey="time"
<Tooltip axisLine={false}
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }} tickLine={false}
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]} 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> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<ChartLegend items={temperatureLegendItems} />
</Card> </Card>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 min-w-0"> <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 }}> <ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" /> dataKey="time"
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" /> axisLine={false}
<Tooltip tickLine={false}
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }} interval={3}
formatter={(value, name) => [ tick={{ fill: "currentColor", fontSize: 11 }}
name === t("forecast.precipitation") />
? formatForecastRainfall(typeof value === "number" ? value : null, language) <YAxis
: `${typeof value === "number" ? value : "—"}%`, yAxisId="rainfall"
name, 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> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<ChartLegend items={rainfallLegendItems} />
</Card> </Card>
</div> </div>
); );

View File

@@ -2,23 +2,54 @@
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
import {
convertPrecipitation,
convertWindSpeed,
convertWindSpeedToBeaufort,
getPrecipitationUnitLabel,
getWindSpeedUnitLabel,
} from "@/lib/weather-utils";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = [ const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
"hsl(var(--chart-temperature))",
"hsl(var(--chart-feels-like))",
"hsl(var(--chart-rainfall))",
];
export function SnapshotChart({ station }: { station: SynopStation }) { export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n(); 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
: 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 = [ const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] }, { name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] }, {
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] }, name: t("weather.wind"),
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); 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 ( return (
<Card className="p-5"> <Card className="p-5">
@@ -26,13 +57,38 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2> <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> <p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<div className="mt-5 h-52 min-w-0"> <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 }}> <BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} /> <XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} /> <YAxis
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} /> 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}> <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> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -12,10 +12,18 @@ import { ErrorState } from "@/components/states/error-state";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations"; import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useCurrentWeather } from "@/hooks/use-current-weather"; import { useCurrentWeather } from "@/hooks/use-current-weather";
import { useForecast } from "@/hooks/use-forecast";
import { ForecastPanel } from "@/components/forecast/forecast-panel"; import { ForecastPanel } from "@/components/forecast/forecast-panel";
import { getUpcomingHourlyForecast } from "@/lib/forecast-utils";
import { locateSynopStations } from "@/lib/location-utils"; import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card"; import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
import type { SynopStation } from "@/types/imgw";
import {
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
type DashboardSectionId,
} from "@/lib/dashboard-sections";
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -23,31 +31,94 @@ export function DashboardPage() {
const { data: positions = [] } = useMeteoStationPositions(); const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const selectedStation = stations?.find((station) => station.id === selectedStationId) const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME) const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
?? stations?.[0]; const selectedStation =
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null; 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 const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null; : 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 forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude; const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name; const forecastLocationName = hasActiveLocationCoordinates
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude); ? (activeLocation?.name ?? selectedStation?.name)
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending; : 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 (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 ( return (
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> <WeatherHero
<DashboardWarnings /> station={heroStation}
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} /> currentWeather={currentWeather}
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} /> currentWeatherLoading={isCurrentWeatherLoading}
<FavoritesSection stations={stations} /> currentForecastWeatherCode={currentForecastWeatherCode}
<FeaturedStationsSection stations={stations} /> locationName={activeLocation?.name}
distanceKm={activeLocation?.distanceKm}
/>
{dashboardSectionOrder.map(renderDashboardSection)}
</div> </div>
); );
} }

View File

@@ -13,23 +13,83 @@ import { useI18n } from "@/lib/i18n";
import { getProvinceForSelection } from "@/lib/provinces"; import { getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief"; import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
import type { WeatherRegion } from "@/types/weather-region";
export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { export function WeatherBriefCard({
latitude,
longitude,
region = "PL",
locationName,
}: {
latitude?: number;
longitude?: number;
region?: WeatherRegion;
locationName: string;
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
const { data: warnings = [] } = useWarnings(); const { data: warnings = [] } = useWarnings();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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(() => { const brief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); return buildWeatherBrief({
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); 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(() => { const tomorrowBrief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); return buildTomorrowWeatherBrief({
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); 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) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />; return <LoadingSkeleton className="h-48" />;
@@ -56,11 +116,25 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
return ( return (
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<div className="flex items-start gap-3"> <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"}> <div
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />} 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>
<div className="min-w-0"> <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")} {t("brief.label")}
</p> </p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2> <h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
@@ -70,7 +144,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">
{brief.body.slice(0, 4).map((line) => ( {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} {line}
</li> </li>
))} ))}
@@ -82,7 +159,10 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3> <h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{tomorrowBrief.body.slice(0, 3).map((line) => ( {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} {line}
</li> </li>
))} ))}

View File

@@ -13,8 +13,11 @@ import {
formatForecastRainfall, formatForecastRainfall,
formatForecastTemperature, formatForecastTemperature,
formatForecastWind, formatForecastWind,
getEffectiveHourlyWeatherCode,
getForecastCondition, getForecastCondition,
isForecastNight,
} from "@/lib/forecast-utils"; } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
function formatHour(value: string | null) { function formatHour(value: string | null) {
@@ -53,6 +56,9 @@ export function DayForecastModal({
onClose: () => void; onClose: () => void;
}) { }) {
const { language, locale, t } = useI18n(); 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 closeButtonRef = useRef<HTMLButtonElement>(null);
const portalRoot = typeof document === "undefined" ? null : document.body; const portalRoot = typeof document === "undefined" ? null : document.body;
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]); const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
@@ -77,14 +83,16 @@ export function DayForecastModal({
}, [day, onClose]); }, [day, onClose]);
const formattedDate = day 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 = ( const modal = (
<AnimatePresence> <AnimatePresence>
{day ? ( {day ? (
<motion.div <motion.div
className="modal-overlay z-[90] flex items-center justify-center p-0 sm:p-4 lg:p-8" className="modal-overlay modal-safe-area z-[90] flex items-center justify-center"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -94,7 +102,7 @@ export function DayForecastModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="day-forecast-title" aria-labelledby="day-forecast-title"
className="h-full w-full overflow-hidden bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70" className="h-full max-h-full w-full overflow-hidden rounded-panel border border-border/70 bg-background shadow-card sm:max-w-6xl"
initial={{ opacity: 0, y: 28, scale: 0.985 }} initial={{ opacity: 0, y: 28, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.99 }} exit={{ opacity: 0, y: 20, scale: 0.99 }}
@@ -109,7 +117,9 @@ export function DayForecastModal({
<CloudSun className="size-4" /> <CloudSun className="size-4" />
{t("forecast.dayDetails")} {t("forecast.dayDetails")}
</p> </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> <p className="mt-1 capitalize text-muted">{formattedDate}</p>
</div> </div>
<button <button
@@ -129,13 +139,25 @@ export function DayForecastModal({
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p> <p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
<div className="mt-2 flex items-end gap-4"> <div className="mt-2 flex items-end gap-4">
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" /> <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)}</p> <p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p> {formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
</p>
<p className="mb-2 text-2xl text-muted">
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]"> <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
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} /> 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={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} /> <DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
</div> </div>
@@ -146,21 +168,30 @@ export function DayForecastModal({
<h3 className="text-sm font-semibold">{t("forecast.hourlyForDay")}</h3> <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"> <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"> <ul className="flex min-w-max gap-2">
{hours.map((hour) => ( {hours.map((hour) => {
<li const weatherCode = getEffectiveHourlyWeatherCode(hour);
key={hour.time} return (
className="w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center" <li
title={getForecastCondition(hour.weatherCode, language)} key={hour.time}
> className="w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center"
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p> title={getForecastCondition(weatherCode, language)}
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" /> >
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p> <p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"> <ForecastIcon
<Droplets className="size-3" /> code={weatherCode}
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} isNight={isForecastNight(hour.time, day)}
</p> className="mx-auto my-3 size-6 text-accent"
</li> />
))} <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> </ul>
</div> </div>
</Card> </Card>

View File

@@ -1,13 +1,44 @@
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, CloudSun, Sun } from "lucide-react"; import {
Cloud,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudMoon,
CloudRain,
CloudSnow,
CloudSun,
MoonStar,
Sun,
} from "lucide-react";
export function ForecastIcon({ code, className = "" }: { code: number | null; className?: string }) { export function ForecastIcon({
const Icon = code === 0 ? Sun code,
: code === 1 || code === 2 ? CloudSun className = "",
: code === 45 || code === 48 ? CloudFog isNight = false,
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle }: {
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain code: number | null;
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow className?: string;
: code !== null && code >= 95 ? CloudLightning isNight?: boolean;
: Cloud; }) {
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} />; return <Icon className={className} strokeWidth={1.45} />;
} }

View File

@@ -2,7 +2,18 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { motion } from "framer-motion"; 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 { DayForecastCharts } from "@/components/charts/day-forecast-charts";
import { DayForecastModal } from "@/components/forecast/day-forecast-modal"; import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
import { ForecastIcon } from "@/components/forecast/forecast-icon"; import { ForecastIcon } from "@/components/forecast/forecast-icon";
@@ -17,11 +28,16 @@ import {
formatForecastRainfall, formatForecastRainfall,
formatForecastTemperature, formatForecastTemperature,
formatForecastWind, formatForecastWind,
getDailyForecastForHour,
getEffectiveHourlyWeatherCode,
getForecastCondition, getForecastCondition,
getHourlyForecastForDay, getHourlyForecastForDay,
getUpcomingHourlyForecast, getUpcomingHourlyForecast,
isForecastNight,
} from "@/lib/forecast-utils"; } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
import type { DailyForecast, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, HourlyForecast } from "@/types/forecast";
import type { WeatherRegion } from "@/types/weather-region";
function formatHour(value: string) { function formatHour(value: string) {
return value.slice(11, 16); return value.slice(11, 16);
@@ -59,30 +75,58 @@ function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; l
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n(); 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 minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature)); const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed)); const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation)); const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const temperatureRange = minimumTemperature === null || maximumTemperature === null const temperatureRange =
? "—" minimumTemperature === null || maximumTemperature === null
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`; ? "—"
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
return ( return (
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block"> <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"> <div className="mt-3 grid grid-cols-4 gap-2">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} /> <HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} /> <HourlySummaryMetric
<HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} /> icon={Wind}
<HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} /> 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>
</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 { 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); const label = formatDay(day.date, locale, t("forecast.today"), index);
return ( return (
<motion.li <motion.li
@@ -101,19 +145,43 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
<p className="text-sm font-semibold capitalize">{label}</p> <p className="text-sm font-semibold capitalize">{label}</p>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" /> <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> </div>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span> <span className="hidden items-center gap-1 text-xs text-accent sm:flex">
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p> <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" /> <ChevronRight className="hidden size-4 text-muted sm:block" />
</motion.button> </motion.button>
</motion.li> </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 { language, t } = useI18n();
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null); const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
const closeDayDetails = useCallback(() => setSelectedDay(null), []); const closeDayDetails = useCallback(() => setSelectedDay(null), []);
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : []; const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
@@ -123,9 +191,14 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <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> <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> </div>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? ( {!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -136,7 +209,10 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
) : isError || !forecast ? ( ) : isError || !forecast ? (
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center"> <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> <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> </Card>
) : !forecast.hourly.length || !forecast.daily.length ? ( ) : !forecast.hourly.length || !forecast.daily.length ? (
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} /> <EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
@@ -144,45 +220,71 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<div className="space-y-3"> <div className="space-y-3">
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch"> <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"> <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"> <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"> <ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => ( {upcomingHours.map((hour, index) => {
<motion.li const day = getDailyForecastForHour(forecast.daily, hour.time);
key={hour.time} const weatherCode = getEffectiveHourlyWeatherCode(hour);
initial={{ opacity: 0, y: 8 }} return (
animate={{ opacity: 1, y: 0 }} <motion.li
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }} key={hour.time}
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" initial={{ opacity: 0, y: 8 }}
title={getForecastCondition(hour.weatherCode, language)} animate={{ opacity: 1, y: 0 }}
> transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p> 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"
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" /> title={getForecastCondition(weatherCode, language)}
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</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> <p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block"> <ForecastIcon
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}> code={weatherCode}
<ThermometerSun className="size-3 text-accent" /> isNight={isForecastNight(hour.time, day)}
{formatForecastTemperature(hour.feelsLike, language)} className="mx-auto my-3 size-6 text-accent"
/>
<p className="text-lg font-semibold tracking-tight">
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}> <p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
<Wind className="size-3 text-accent" /> <Droplets className="size-3" />
{formatForecastWind(hour.windSpeed, language)} {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}> <div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
<CloudRain className="size-3 text-accent" /> <p
{formatForecastRainfall(hour.precipitation, language)} className="flex items-center justify-center gap-1"
</p> title={t("forecast.apparentTemperature")}
</div> >
</motion.li> <ThermometerSun className="size-3 text-accent" />
))} {formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
</p>
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
<Wind className="size-3 text-accent" />
{formatForecastWind(hour.windSpeed, language, windSpeedUnit)}
</p>
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
<CloudRain className="size-3 text-accent" />
{formatForecastRainfall(hour.precipitation, language, precipitationUnit)}
</p>
</div>
</motion.li>
);
})}
</ul> </ul>
</div> </div>
<HourlyForecastSummary hours={upcomingHours} /> <HourlyForecastSummary hours={upcomingHours} />
</Card> </Card>
<Card className="p-4 sm:p-5"> <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> <h3 className="flex items-center gap-2 text-sm font-semibold">
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul> <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> </Card>
</div> </div>
<DayForecastCharts hours={todayHours} /> <DayForecastCharts hours={todayHours} />
@@ -191,7 +293,13 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
{forecast && <ForecastSources sources={forecast.sources} />} {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> </section>
); );
} }

View File

@@ -13,16 +13,26 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
{t("forecast.source")}{" "} {t("forecast.source")}{" "}
{hasImgw && ( {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" /> IMGW ALARO <ExternalLink className="size-3" />
</a> </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" /> Open-Meteo <ExternalLink className="size-3" />
</a> </a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} .
</p> </p>
); );
} }

View File

@@ -17,10 +17,14 @@ export function HydroPage() {
const { data: stations, isPending, isError, refetch } = useHydroStations(); const { data: stations, isPending, isError, refetch } = useHydroStations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const filteredStations = useMemo(() => (stations ?? []).filter((station) => { const filteredStations = useMemo(
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale); () =>
return haystack.includes(query.trim().toLocaleLowerCase(locale)); (stations ?? []).filter((station) => {
}), [locale, query, stations]); const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
return haystack.includes(query.trim().toLocaleLowerCase(locale));
}),
[locale, query, stations],
);
if (isPending) return <PageLoadingSkeleton />; if (isPending) return <PageLoadingSkeleton />;
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />; 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"> <label className="glass relative block rounded-panel p-3">
<span className="sr-only">{t("hydro.searchLabel")}</span> <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" /> <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> </label>
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p> <p className="text-xs text-muted">
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : ( {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> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>} {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> </div>

View File

@@ -6,24 +6,44 @@ import type { HydroStation } from "@/types/imgw";
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils"; import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) { export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
return ( 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"> <Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<div> <div>
<div> <div>
<h2 className="font-semibold tracking-tight">{station.name}</h2> <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> </div>
<div className="mt-5 grid grid-cols-3 gap-2"> <div className="mt-5 grid grid-cols-3 gap-2">
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} /> <HydroMetric
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} /> 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)} /> <HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
</div> </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> </Card>
</motion.article> </motion.article>
); );
@@ -32,8 +52,13 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) { function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return ( return (
<div className="rounded-card bg-surface-muted/60 p-2.5"> <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="flex items-center gap-1 text-[0.65rem] text-muted">
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p> <Icon className="size-3" />
{label}
</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
{value}
</p>
</div> </div>
); );
} }

View File

@@ -2,29 +2,63 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; 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 { NAV_ITEMS } from "@/lib/constants";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { InstallPWAButton } from "@/components/ui/install-pwa-button"; import { InstallPWAButton } from "@/components/ui/install-pwa-button";
import { useI18n } from "@/lib/i18n"; 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 }) { export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useI18n(); 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 ( return (
<div className="min-h-screen overflow-x-hidden bg-background"> <div className="min-h-screen overflow-x-hidden bg-background">
<header className="sticky top-0 z-40 border-b border-border/70 bg-surface"> <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"> <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> wtr<span className="text-accent">.</span>
</Link> </Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex"> <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); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( 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)} {t(item.labelKey)}
</Link> </Link>
); );
@@ -35,13 +69,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main> {isOffline && (
<nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"> <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">
{NAV_ITEMS.map((item, index) => { <div className="mx-auto flex max-w-7xl items-center gap-2">
const Icon = icons[index]; <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"
>
{visibleNavItems.map((item) => {
const Icon = iconsByHref[item.href] ?? CloudSun;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( 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" /> <Icon className="size-4" />
<span className="max-w-full truncate">{t(item.labelKey)}</span> <span className="max-w-full truncate">{t(item.labelKey)}</span>
</Link> </Link>

View File

@@ -1,22 +1,67 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone } 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 { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { LanguageToggle } from "@/components/ui/language-toggle"; import { LanguageToggle } from "@/components/ui/language-toggle";
import { ThemeToggle } from "@/components/ui/theme-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations"; import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useWeatherStations } from "@/hooks/use-weather-stations"; import { useWeatherStations } from "@/hooks/use-weather-stations";
import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
import { DEFAULT_STATION_ID } from "@/lib/constants"; import { DEFAULT_STATION_ID } from "@/lib/constants";
import {
DASHBOARD_SECTION_SETTINGS,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
} from "@/lib/dashboard-sections";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { locateSynopStations } from "@/lib/location-utils"; 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 { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import type { Province } from "@/types/province"; 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() { function isStandaloneApp() {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -34,12 +79,23 @@ function isAppleMobilePlatform() {
function getNotificationSupportStatus(): NotificationSupportStatus { function getNotificationSupportStatus(): NotificationSupportStatus {
if (typeof window === "undefined") return "checking"; 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"; if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
return Notification.permission; 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 ( 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 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"> <div className="flex items-start gap-3">
@@ -56,6 +112,108 @@ function SettingsRow({ icon: Icon, title, description, children }: { icon: typeo
); );
} }
function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: boolean }) {
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"
} ${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>
);
}
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"
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="sr-only"
/>
<span className="min-w-0">
<span className="flex items-center gap-2 font-medium">
<Icon className="size-3.5 text-accent" aria-hidden="true" />
{title}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{description}</span>
</span>
<SwitchIndicator checked={checked} disabled={disabled} />
</label>
);
}
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"> = {
c: "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() { export function SettingsPage() {
const { language, t } = useI18n(); const { language, t } = useI18n();
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking"); const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
@@ -63,6 +221,7 @@ export function SettingsPage() {
const [isSavingSubscription, setIsSavingSubscription] = useState(false); const [isSavingSubscription, setIsSavingSubscription] = useState(false);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string | null>(null); const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
const [provinceDropdownOpen, setProvinceDropdownOpen] = useState(false);
const { data: stations } = useWeatherStations(); const { data: stations } = useWeatherStations();
const { data: positions = [] } = useMeteoStationPositions(); const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
@@ -72,26 +231,63 @@ export function SettingsPage() {
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled); const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
const 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 setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled); const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
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) const selectedStation =
?? stations?.find((station) => station.id === DEFAULT_STATION_ID) stations?.find((station) => station.id === selectedStationId) ??
?? stations?.[0]; stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null; stations?.[0];
const activeLocation = selectedLocation;
const selectedRegion = activeLocation?.region ?? "PL";
const stationPosition = selectedStation const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null; : null;
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null; const notificationLatitude = Number.isFinite(activeLocation?.latitude)
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null; ? activeLocation?.latitude
: (stationPosition?.latitude ?? null);
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
? activeLocation?.longitude
: (stationPosition?.longitude ?? null);
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null; const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
const notificationCountyTeryt = activeLocation?.countyTeryt ?? null; const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; const selectedProvince =
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); 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(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -111,7 +307,7 @@ export function SettingsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return; if (!notificationsEnabled || !vapidPublicKey || notificationStatus !== "granted") return;
const province = effectiveProvince; const province = effectiveProvince;
let cancelled = false; let cancelled = false;
@@ -120,12 +316,20 @@ export function SettingsPage() {
const subscription = await registration?.pushManager.getSubscription(); const subscription = await registration?.pushManager.getSubscription();
if (!subscription || cancelled) return; if (!subscription || cancelled) return;
await savePushSubscription(subscription, province, language, { await savePushSubscription(subscription, province, language, {
region: selectedRegion,
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
morningBriefEnabled, morningBriefEnabled,
tomorrowBriefEnabled, tomorrowBriefEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
timezone: notificationTimezone,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit,
windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -133,7 +337,38 @@ export function SettingsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey]); }, [
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(() => { const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) { switch (notificationStatus) {
@@ -154,15 +389,22 @@ export function SettingsPage() {
} }
}, [notificationStatus, t]); }, [notificationStatus, t]);
const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured"); const canUsePush = Boolean(
vapidPublicKey &&
(selectedRegion === "GLOBAL" || effectiveProvince) &&
notificationStatus !== "unsupported" &&
notificationStatus !== "needs-install" &&
notificationStatus !== "denied" &&
notificationStatus !== "unconfigured",
);
async function getServiceWorkerRegistration() { async function getServiceWorkerRegistration() {
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available."); if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
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() { async function enableWarningNotifications() {
if (!vapidPublicKey || !effectiveProvince) { if (!vapidPublicKey || (selectedRegion === "PL" && !effectiveProvince)) {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
return; return;
} }
@@ -181,17 +423,27 @@ export function SettingsPage() {
} }
const registration = await getServiceWorkerRegistration(); const registration = await getServiceWorkerRegistration();
const existingSubscription = await registration.pushManager.getSubscription(); const existingSubscription = await registration.pushManager.getSubscription();
const subscription = existingSubscription ?? await registration.pushManager.subscribe({ const subscription =
userVisibleOnly: true, existingSubscription ??
applicationServerKey: decodeBase64UrlKey(vapidPublicKey), (await registration.pushManager.subscribe({
}); userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
}));
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
region: selectedRegion,
officialWarningsEnabled: canUseOfficialWarnings,
morningBriefEnabled, morningBriefEnabled,
tomorrowBriefEnabled, tomorrowBriefEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
timezone: notificationTimezone,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit,
windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
setNotificationsEnabled(true); setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess")); setNotificationMessage(t("settings.notifications.saveSuccess"));
@@ -242,18 +494,27 @@ export function SettingsPage() {
async function updateMorningBriefPreference(enabled: boolean) { async function updateMorningBriefPreference(enabled: boolean) {
setMorningBriefEnabled(enabled); setMorningBriefEnabled(enabled);
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return; if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
try { try {
const preferences = useWeatherStore.getState();
const registration = await navigator.serviceWorker?.getRegistration(); const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription(); const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return; if (!subscription) return;
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled: enabled, region: selectedRegion,
tomorrowBriefEnabled, officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
timezone: notificationTimezone,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit,
windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -262,18 +523,27 @@ export function SettingsPage() {
async function updateTomorrowBriefPreference(enabled: boolean) { async function updateTomorrowBriefPreference(enabled: boolean) {
setTomorrowBriefEnabled(enabled); setTomorrowBriefEnabled(enabled);
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return; if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
try { try {
const preferences = useWeatherStore.getState();
const registration = await navigator.serviceWorker?.getRegistration(); const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription(); const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return; if (!subscription) return;
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled, region: selectedRegion,
tomorrowBriefEnabled: enabled, officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
latitude: notificationLatitude, latitude: notificationLatitude,
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
timezone: notificationTimezone,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit,
windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -283,18 +553,256 @@ export function SettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section> <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> <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> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
</section> </section>
<Card className="p-4 sm:p-5"> <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 /> <LanguageToggle />
</SettingsRow> </SettingsRow>
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}> <SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
<ThemeToggle /> <ThemeToggle />
</SettingsRow> </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")}
>
{TEMPERATURE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={temperatureUnit === 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 ${
temperatureUnit === unit
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
}`}
onClick={() => setTemperatureUnit(unit)}
>
{t(temperatureUnitKeys[unit])}
</button>
))}
</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")}
>
{WIND_SPEED_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={windSpeedUnit === 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 ${
windSpeedUnit === unit
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
}`}
onClick={() => setWindSpeedUnit(unit)}
>
{t(windSpeedUnitKeys[unit])}
</button>
))}
</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>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
@@ -315,7 +823,9 @@ export function SettingsPage() {
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" /> <MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
<div> <div>
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3> <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>
</div> </div>
@@ -325,12 +835,17 @@ export function SettingsPage() {
type="radio" type="radio"
name="warning-notification-province-mode" name="warning-notification-province-mode"
checked={provinceMode === "selected"} checked={provinceMode === "selected"}
disabled={selectedRegion === "GLOBAL"}
onChange={() => setProvinceMode("selected")} onChange={() => setProvinceMode("selected")}
className="mt-1 accent-accent" className="mt-1 accent-accent"
/> />
<span> <span>
<span className="block font-medium">{t("settings.notifications.regionSelected")}</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> </span>
</label> </label>
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm"> <label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
@@ -338,6 +853,7 @@ export function SettingsPage() {
type="radio" type="radio"
name="warning-notification-province-mode" name="warning-notification-province-mode"
checked={provinceMode === "manual"} checked={provinceMode === "manual"}
disabled={selectedRegion === "GLOBAL"}
onChange={() => { onChange={() => {
setProvinceMode("manual"); setProvinceMode("manual");
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie"); if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
@@ -346,19 +862,61 @@ export function SettingsPage() {
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium">{t("settings.notifications.regionManual")}</span> <span className="block font-medium">{t("settings.notifications.regionManual")}</span>
<select <span className="relative mt-2 block">
aria-label={t("settings.notifications.regionManual")} <button
value={manualProvince ?? selectedProvince ?? "mazowieckie"} type="button"
disabled={provinceMode !== "manual"} aria-label={t("settings.notifications.regionManual")}
onChange={(event) => setManualProvince(event.target.value as Province)} aria-haspopup="listbox"
className="surface-control mt-2 w-full rounded-control px-3 py-2 text-sm disabled:opacity-60" aria-expanded={provinceDropdownOpen}
> disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
{PROVINCES.map((province) => ( onBlur={(event) => {
<option key={province} value={province}>{formatProvinceName(province, language)}</option> if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
))} setProvinceDropdownOpen(false);
</select> }}
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) => (
<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>
))}
</div>
)}
</span>
</span> </span>
</label> </label>
{selectedRegion === "GLOBAL" && (
<p className="rounded-card border border-border/60 bg-surface px-3 py-3 text-xs leading-5 text-muted">
{t("settings.notifications.globalAlertsUnavailable")}
</p>
)}
</div> </div>
</div> </div>
@@ -371,62 +929,80 @@ export function SettingsPage() {
</div> </div>
</div> </div>
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm"> <button
<p className="font-medium">{t("settings.notifications.enable")}</p> type="button"
<p className="mt-0.5 text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</p> role="switch"
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>} aria-checked={notificationsEnabled}
</div> disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}
<label className="mt-3 flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm"> className={`mt-4 flex w-full items-start justify-between gap-4 rounded-card border px-3 py-3 text-left text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-65 ${
<input notificationsEnabled
type="checkbox" ? "border-accent/45 bg-accent/10"
checked={morningBriefEnabled} : "border-border/60 bg-surface hover:border-accent/45"
disabled={!notificationsEnabled} }`}
onChange={(event) => updateMorningBriefPreference(event.target.checked)} >
className="mt-1 accent-accent" <span className="min-w-0">
/>
<span>
<span className="flex items-center gap-2 font-medium"> <span className="flex items-center gap-2 font-medium">
<Clock3 className="size-3.5 text-accent" aria-hidden="true" /> <Bell className="size-3.5 text-accent" aria-hidden="true" />
{t("settings.notifications.morningBrief")} {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-2 block text-xs font-semibold text-accent">
{isSavingSubscription
? t("settings.notifications.saving")
: notificationsEnabled
? t("settings.notifications.enabledStatus")
: t("settings.notifications.disabledStatus")}
</span> </span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.morningBriefDescription")}</span>
</span> </span>
</label> <SwitchIndicator
checked={notificationsEnabled}
<label className="mt-3 flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm"> disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
<input
type="checkbox"
checked={tomorrowBriefEnabled}
disabled={!notificationsEnabled}
onChange={(event) => updateTomorrowBriefPreference(event.target.checked)}
className="mt-1 accent-accent"
/> />
<span> </button>
<span className="flex items-center gap-2 font-medium"> {notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
<Clock3 className="size-3.5 text-accent" aria-hidden="true" />
{t("settings.notifications.tomorrowBrief")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.tomorrowBriefDescription")}</span>
</span>
</label>
<div className="mt-4 flex flex-col gap-2 sm:flex-row"> <NotificationSwitchLabel
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}> icon={Clock3}
<Bell className="size-4" /> title={t("settings.notifications.morningBrief")}
{isSavingSubscription description={t("settings.notifications.morningBriefDescription")}
? t("settings.notifications.saving") checked={morningBriefEnabled}
: notificationsEnabled disabled={!notificationsEnabled || isSavingSubscription}
? t("settings.notifications.disable") onChange={updateMorningBriefPreference}
: t("settings.notifications.enableDevice")} />
</Button>
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}> <NotificationSwitchLabel
icon={Clock3}
title={t("settings.notifications.tomorrowBrief")}
description={t("settings.notifications.tomorrowBriefDescription")}
checked={tomorrowBriefEnabled}
disabled={!notificationsEnabled || isSavingSubscription}
onChange={updateTomorrowBriefPreference}
/>
<div className="mt-4">
<Button
type="button"
variant="glass"
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
onClick={sendTestNotification}
>
<BellRing className="size-4" /> <BellRing className="size-4" />
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")} {isSendingTestNotification
? t("settings.notifications.sendingTest")
: t("settings.notifications.sendTest")}
</Button> </Button>
</div> </div>
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p> {t("settings.notifications.implementationNote") && (
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
)}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
import { CircleCheckBig } from "lucide-react"; import { CircleCheckBig } from "lucide-react";
import { Card } from "@/components/ui/card"; 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 ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <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> <h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p> <p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
</Card> </Card>

View File

@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; 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(); const { t } = useI18n();
return ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <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> <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> <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> </Card>
); );
} }

View File

@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) { export function LoadingSkeleton({ className = "" }: { className?: string }) {
const { t } = useI18n(); 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() { export function PageLoadingSkeleton() {
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
<div className="space-y-5" aria-busy="true"> <div className="space-y-5" aria-busy="true">
<LoadingSkeleton className="h-[25rem]" /> <LoadingSkeleton className="h-[25rem]" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <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>
</div> </div>
); );

View File

@@ -1,23 +1,83 @@
"use client"; "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"; import { useI18n, type Language } from "@/lib/i18n";
const languageOptions: Language[] = ["pl", "en"];
export function LanguageToggle() { export function LanguageToggle() {
const { language, setLanguage, t } = useI18n(); 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 ( return (
<label className="relative flex items-center"> <div ref={rootRef} className="relative inline-flex">
<span className="sr-only">{t("language.label")}</span> <button
<Languages className="pointer-events-none absolute left-3 size-4 text-muted" /> type="button"
<select
aria-label={t("language.label")} aria-label={t("language.label")}
value={language} aria-expanded={open}
onChange={(event) => setLanguage(event.target.value as Language)} aria-haspopup="listbox"
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" 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> <span className="flex min-w-0 items-center gap-2.5">
<option value="en">{t("language.english")}</option> <Languages className="size-4 shrink-0 text-accent" aria-hidden="true" />
</select> <span className="truncate">{labels[language]}</span>
</label> </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>
); );
} }

View File

@@ -42,6 +42,7 @@ export function DashboardWarnings() {
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const [now, setNow] = useState<number | null>(null); const [now, setNow] = useState<number | null>(null);
const isGlobalLocation = selectedLocation?.region === "GLOBAL";
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => setNow(Date.now()), 0); const timeoutId = window.setTimeout(() => setNow(Date.now()), 0);
@@ -52,20 +53,28 @@ export function DashboardWarnings() {
}; };
}, []); }, []);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = isGlobalLocation
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null; ? null
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const regionLabel = selectedLocation?.countyTeryt
? (selectedLocation.district ?? selectedLocation.name)
: province
? formatProvinceName(province, language)
: null;
const relevantWarnings = useMemo(() => { const relevantWarnings = useMemo(() => {
if (!warnings || !province || now === null) return []; if (isGlobalLocation || !warnings || !province || now === null) return [];
return warnings return warnings
.filter((warning) => { .filter((warning) => {
const validTo = getTimestamp(warning.validTo); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return (
&& warningMatchesLocalSelection(warning, province, selectedLocation) warning.kind === "meteo" &&
&& (validTo === null || validTo > now); warningMatchesLocalSelection(warning, province, selectedLocation) &&
(validTo === null || validTo > now)
);
}) })
.sort((a, b) => compareDashboardWarnings(a, b, now)); .sort((a, b) => compareDashboardWarnings(a, b, now));
}, [now, province, selectedLocation, warnings]); }, [isGlobalLocation, now, province, selectedLocation, warnings]);
if (!province || !regionLabel || !relevantWarnings.length || now === null) return null; if (!province || !regionLabel || !relevantWarnings.length || now === null) return null;
@@ -86,12 +95,8 @@ export function DashboardWarnings() {
<AlertTriangle className="size-4" aria-hidden="true" /> <AlertTriangle className="size-4" aria-hidden="true" />
</div> </div>
<div> <div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
IMGW · {regionLabel} <h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
</p>
<h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")}
</h2>
</div> </div>
</div> </div>
<Link <Link
@@ -108,13 +113,11 @@ export function DashboardWarnings() {
const active = isWarningActive(warning, now); const active = isWarningActive(warning, now);
const validityLabel = active const validityLabel = active
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) }) ? 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 ( return (
<article <article key={warning.id} className="rounded-card border border-warning/20 bg-surface px-3.5 py-3">
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"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")} {t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`} {warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}

View File

@@ -12,25 +12,57 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
const { language, t } = useI18n(); const { language, t } = useI18n();
const Icon = warning.kind === "hydro" ? Waves : CloudLightning; const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
const level = warning.level; const level = warning.level;
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level }); const levelLabel =
const areasLabel = warning.areas.length > 8 level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}` const areasLabel =
: warning.areas.join("; "); warning.areas.length > 8
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
: warning.areas.join("; ");
return ( 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"> <Card className="h-full overflow-hidden p-5">
<div className="flex items-start justify-between gap-3"> <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> <div className="rounded-card bg-warning/10 p-2.5 text-warning">
<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> <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> </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> <p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2> {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>} {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"> <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">
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p> <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> </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> </Card>
</motion.article> </motion.article>
); );

View File

@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) { function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <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> </div>
); );
} }
@@ -26,35 +28,61 @@ export function WarningsPanel() {
const { data: warnings, isPending, isError, refetch } = useWarnings(); const { data: warnings, isPending, isError, refetch } = useWarnings();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
if (selectedLocation?.region === "GLOBAL") {
return (
<EmptyState
title={t("warnings.globalUnavailableTitle")}
description={t("warnings.globalUnavailableDescription")}
/>
);
}
if (isPending) return <PageLoadingSkeleton />; if (isPending) return <PageLoadingSkeleton />;
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />; if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />; if (!warnings?.length)
return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
if (!province) return <WarningGrid warnings={warnings} />; if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language); 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 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 ( return (
<div className="space-y-9"> <div className="space-y-9">
<section className="space-y-4"> <section className="space-y-4">
<div> <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> <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> </div>
{localWarnings.length {localWarnings.length ? (
? <WarningGrid warnings={localWarnings} /> <WarningGrid warnings={localWarnings} />
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />} ) : (
<EmptyState
title={t("warnings.myProvinceEmptyTitle")}
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
/>
)}
</section> </section>
{otherWarnings.length > 0 && ( {otherWarnings.length > 0 && (
<section className="space-y-4"> <section className="space-y-4">
<div> <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> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
</div> </div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} /> <WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />

View File

@@ -1,26 +1,79 @@
"use client"; "use client";
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react"; 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 type { SynopStation } from "@/types/imgw";
import { MetricCard } from "@/components/weather/metric-card"; import { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
export function CurrentConditionsCard({ station }: { station: SynopStation }) { export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const { language, t } = useI18n(); 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 feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const metrics = [ const metrics = [
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") }, {
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") }, icon: Thermometer,
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") }, label: t("weather.feelsLike"),
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language), detail: t("weather.windSpeedDetail") }, value: formatTemperature(feelsLike, language, temperatureUnit),
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") }, detail: t("weather.feelsLikeDetail"),
{ 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), detail: t("weather.temperatureDetail") }, {
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 ( return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <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> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { fetchReverseLocation } from "@/lib/location-api"; import { fetchReverseLocation } from "@/lib/location-api";
import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils"; import { createSelectedLocation, type LocatedSynopStation } from "@/lib/location-utils";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen"; const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
@@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
setMessage(t("location.gpsUnavailable")); setMessage(t("location.gpsUnavailable"));
return; return;
} }
if (!stations.length) {
setMessage(t("location.gpsStationsPending"));
return;
}
setIsLocating(true); setIsLocating(true);
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
async ({ coords }) => { async ({ coords }) => {
@@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
name: t("location.gpsFallbackName"), name: t("location.gpsFallbackName"),
province: null, province: null,
district: null, district: null,
country: null,
countryCode: null,
admin1: null,
admin2: null,
timezone: null,
region: "GLOBAL" as const,
})); }));
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations); const selected = createSelectedLocation({ ...place, latitude, longitude }, stations);
if (!nearest) { selectLocation(selected);
setMessage(t("location.gpsStationsPending")); setMessage(t("location.gpsSelected", { location: selected.name }));
return;
}
selectLocation(nearest);
setMessage(t("location.gpsSelected", { location: nearest.name }));
} catch { } catch {
setMessage(t("location.gpsPositionUnavailable")); setMessage(t("location.gpsPositionUnavailable"));
} finally { } finally {
@@ -70,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
}, },
(error) => { (error) => {
setIsLocating(false); setIsLocating(false);
setMessage(error.code === error.PERMISSION_DENIED setMessage(
? t("location.gpsDenied") error.code === error.PERMISSION_DENIED
: error.code === error.TIMEOUT ? t("location.gpsDenied")
? t("location.gpsTimeout") : error.code === error.TIMEOUT
: t("location.gpsPositionUnavailable")); ? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"),
);
}, },
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 }, { enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
); );
@@ -89,30 +88,43 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return; if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
navigator.permissions.query({ name: "geolocation" }).then((permission) => { navigator.permissions
if (permission.state !== "granted" || autoLocated.current) return; .query({ name: "geolocation" })
autoLocated.current = true; .then((permission) => {
locate(); if (permission.state !== "granted" || autoLocated.current) return;
}).catch(() => undefined); autoLocated.current = true;
}, [locate, stations.length]); locate();
})
.catch(() => undefined);
}, [locate]);
return ( return (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{showPrompt && ( {showPrompt && (
<div className="glass-subtle rounded-card p-3.5"> <div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3"> <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"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p> <p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>} {!isSecureContext && (
<p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" />
{t("location.gpsSecureContext")}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}> <Button type="button" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")} {isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button> </Button>
<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> </div>
</div> </div>
@@ -120,15 +132,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
)} )}
{!showPrompt && ( {!showPrompt && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}> <Button type="button" variant="glass" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")} {isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button> </Button>
{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> </div>
)} )}
<p className="text-[0.68rem] text-muted"> <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> </p>
</div> </div>
); );

View File

@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
return ( return (
<section className="space-y-3"> <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="flex items-center gap-2 text-sm font-semibold">
<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> <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> </section>
); );
} }

View File

@@ -14,11 +14,15 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
const { language, t } = useI18n(); const { language, t } = useI18n();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectStation = useWeatherStore((state) => state.selectStation); const selectStation = useWeatherStore((state) => state.selectStation);
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []); const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []);
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <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> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
@@ -29,11 +33,16 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button" type="button"
key={station.id} key={station.id}
onClick={() => selectStation(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="min-w-0">
<span className="block truncate text-xs font-medium text-muted">{station.name}</span> <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)}</span> <span className="mt-1 block text-xl font-semibold tracking-tight">
{formatTemperature(station.temperature, language, temperatureUnit)}
</span>
</span> </span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" /> <WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button> </button>

View File

@@ -1,28 +1,55 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState, type KeyboardEvent } from "react";
import { LoaderCircle, MapPin, Search, X } from "lucide-react"; import { LoaderCircle, MapPin, Search, X } from "lucide-react";
import { useLocationSearch } from "@/hooks/use-location-search";
import { useWeatherStore } from "@/lib/store";
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
import { useI18n } from "@/lib/i18n";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import { CurrentLocationControl } from "@/components/weather/current-location-control"; import { CurrentLocationControl } from "@/components/weather/current-location-control";
import { useLocationSearch } from "@/hooks/use-location-search";
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";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) { export function LocationSearch({
stations,
positions,
}: {
stations: SynopStation[];
positions: MeteoStationPosition[];
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const selectLocation = useWeatherStore((state) => state.selectLocation); const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language); const { data: locations, isFetching, isError } = useLocationSearch(query, language);
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]); const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
const suggestions = useMemo(() => (locations ?? []).map((location) => ({ const suggestions = useMemo(
location, () =>
nearest: findNearestSynopStation(location, locatedStations), (locations ?? []).map((location) => ({
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]); location,
selected: createSelectedLocation(location, locatedStations),
})),
[locatedStations, locations],
);
const showSuggestions = isFocused && query.trim().length >= 2; const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0; const isPreparingStations =
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
function chooseLocation(location: SelectedLocation) {
selectLocation(location);
setQuery("");
setIsFocused(false);
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const firstSuggestion = suggestions[0]?.selected;
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
event.preventDefault();
chooseLocation(firstSuggestion);
}
return ( return (
<section className="relative z-30"> <section className="relative z-30">
@@ -31,55 +58,102 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<MapPin className="size-4 text-accent" /> <MapPin className="size-4 text-accent" />
<p className="section-kicker">{t("location.label")}</p> <p className="section-kicker">{t("location.label")}</p>
</div> </div>
<label className="relative block"> <div className="relative z-20">
<span className="sr-only">{t("location.searchLabel")}</span> <label className="relative block">
{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" />} <span className="sr-only">{t("location.searchLabel")}</span>
<input {isFetching || isPreparingStations ? (
value={query} <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" />
onChange={(event) => setQuery(event.target.value)} ) : (
onFocus={() => setIsFocused(true)} <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)} )}
placeholder={t("location.placeholder")} <input
autoComplete="off" value={query}
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" onChange={(event) => setQuery(event.target.value)}
/> onKeyDown={handleSearchKeyDown}
{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>} onFocus={() => setIsFocused(true)}
</label> 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-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>
)}
</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, selected }) => (
<button
type="button"
key={location.id}
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.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}
</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} /> <CurrentLocationControl stations={locatedStations} />
{selectedLocation && ( {selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted"> <p className="mt-3 px-1 text-xs text-muted">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} {selectedLocation.stationName && selectedLocation.distanceKm !== null
? t("location.currentSource", {
location: selectedLocation.name,
station: selectedLocation.stationName,
distance: formatDistance(selectedLocation.distanceKm, language, distanceUnit),
})
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
</p> </p>
)} )}
<p className="mt-3 px-1 text-[0.68rem] text-muted"> <p className="mt-3 px-1 text-[0.68rem] text-muted">
{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> </p>
</div> </div>
{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 && (
<button
type="button"
key={location.id}
onClick={() => {
selectLocation(nearest);
setQuery("");
setIsFocused(false);
}}
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>
<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>
</button>
))}
</div>
)}
</section> </section>
); );
} }

View File

@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card"; 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 ( 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"> <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"> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" /> <Icon className="size-4 text-accent" />

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react"; import { Droplets, Gauge, Heart, Wind } from "lucide-react";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { formatHumidity, formatPressure, formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils"; import {
formatHumidity,
formatPressure,
formatTemperature,
formatWind,
getWeatherMoodFromData,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -17,28 +23,63 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const favorites = useWeatherStore((state) => state.favorites); const favorites = useWeatherStore((state) => state.favorites);
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite); const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
const selectStation = useWeatherStore((state) => state.selectStation); 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 favorite = favorites.includes(station.id);
const mood = getWeatherMoodFromData(station); const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`; const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
return ( 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"> <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"> <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="truncate text-sm font-semibold">{station.name}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p> <p className="mt-3 text-4xl font-light tracking-[-0.08em]">
{formatTemperature(station.temperature, language, temperatureUnit)}
</p>
</Link> </Link>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<WeatherIcon mood={mood} className="size-9 text-accent" /> <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")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button> </Button>
</div> </div>
</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"> <Link
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span> href={`/station/${station.id}`}
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span> onClick={() => selectStation(station.id)}
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span> 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> </Link>
</Card> </Card>
</motion.div> </motion.div>

View File

@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <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)}> <Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")} {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]"> <div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} /> <SnapshotChart station={station} />
<Card className="p-5"> <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> <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> <p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm"> <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>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></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> </dl>
</Card> </Card>
</div> </div>

View File

@@ -2,34 +2,139 @@
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
const rainDrops = Array.from({ length: 12 }, (_, index) => ({ const rainDrops = Array.from({ length: 32 }, (_, index) => ({
left: `${(index * 43 + 7) % 101}%`, left: `${(index * 29 + 7) % 101}%`,
delay: (index % 9) * 0.18, delay: (index % 16) * 0.08,
duration: 1.1 + (index % 4) * 0.18, duration: 0.82 + (index % 5) * 0.1,
lengthClass: index % 3 === 0 ? "h-16" : index % 3 === 1 ? "h-12" : "h-9",
opacity: index % 3 === 0 ? 0.58 : index % 3 === 1 ? 0.48 : 0.36,
})); }));
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) { const mistDrops = Array.from({ length: 18 }, (_, index) => ({
left: `${(index * 37 + 19) % 101}%`,
delay: (index % 9) * 0.16,
duration: 1.35 + (index % 4) * 0.12,
}));
const lightningBolts = [
{
className: "left-[18%] top-[4%] h-44 w-24",
path: "M62 0 L42 39 L56 36 L35 88 L48 84 L28 142",
delay: 0,
opacity: 0.78,
},
{
className: "left-[42%] top-[-4%] h-52 w-28",
path: "M38 0 L61 47 L45 43 L72 101 L57 96 L82 164",
delay: 0.34,
opacity: 0.86,
},
{
className: "right-[12%] top-[22%] h-48 w-24",
path: "M70 0 L48 52 L63 49 L36 108 L52 103 L24 170",
delay: 0.72,
opacity: 0.68,
},
{
className: "right-[34%] top-[44%] h-32 w-20",
path: "M28 0 L44 33 L33 31 L52 75 L41 72 L60 116",
delay: 1.08,
opacity: 0.5,
},
];
export function WeatherEffects({
precipitation10m,
thunderstorm = false,
}: {
precipitation10m?: number | null;
thunderstorm?: boolean;
}) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const isRaining = (precipitation10m ?? 0) > 0; const isRaining = (precipitation10m ?? 0) > 0;
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden">
{isRaining && rainDrops.map((drop, index) => ( {isRaining && (
<motion.span <>
key={`rain-${index}`} {rainDrops.map((drop, index) => (
initial={{ y: "-12vh", opacity: 0 }} <motion.span
animate={reduceMotion ? { opacity: 0.18 } : { y: ["-12vh", "115vh"], opacity: [0, 0.22, 0] }} key={`rain-${index}`}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }} initial={{ y: "-18vh", opacity: 0 }}
className="absolute -top-8 h-10 w-px rotate-[8deg] rounded-full bg-foreground/35" animate={
style={{ left: drop.left }} 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 }}
/>
))}
{mistDrops.map((drop, index) => (
<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] }
}
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 }}
/>
))}
</>
)}
{thunderstorm && ( {thunderstorm && (
<motion.div <>
animate={reduceMotion ? { opacity: 0.08 } : { opacity: [0, 0, 0.16, 0, 0.08, 0] }} {lightningBolts.map((bolt, index) => (
transition={{ duration: 6, repeat: Infinity, repeatDelay: 2.5 }} <motion.svg
className="absolute inset-0 bg-foreground" 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] }
}
transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }}
className={`absolute text-foreground dark:text-white ${bolt.className}`}
>
<path
d={bolt.path}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
vectorEffect="non-scaling-stroke"
opacity="0.06"
/>
<path
d={bolt.path}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
vectorEffect="non-scaling-stroke"
opacity="0.22"
/>
<path
d={bolt.path}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.95"
vectorEffect="non-scaling-stroke"
opacity="0.86"
/>
</motion.svg>
))}
</>
)} )}
</div> </div>
); );

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } from "lucide-react"; import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } from "lucide-react";
import { import {
calculateFeelsLike, calculateFeelsLike,
formatDateTime, formatDateTime,
formatDistance,
formatHumidity, formatHumidity,
formatPressure, formatPressure,
formatRainfall, formatRainfall,
@@ -13,48 +15,123 @@ import {
getWeatherDescription, getWeatherDescription,
getWeatherMoodFromData, getWeatherMoodFromData,
} from "@/lib/weather-utils"; } 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 type { ImgwCurrentWeather } from "@/types/imgw-current";
import { WeatherIcon } from "@/components/weather/weather-icon"; import { WeatherIcon } from "@/components/weather/weather-icon";
import { WeatherEffects } from "@/components/weather/weather-effects"; import { WeatherEffects } from "@/components/weather/weather-effects";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
function moodAccentClass(mood: WeatherMood) { type DevWeatherEffectOverride = "rain" | "thunderstorm" | "none";
return {
warm: "border-accent/25 bg-accent/10 text-accent", function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
cloudy: "border-border/70 bg-surface-muted text-muted", if (process.env.NODE_ENV !== "development" || typeof window === "undefined") return null;
wind: "border-border/70 bg-surface-muted text-muted", const effect = new URLSearchParams(window.location.search).get("effect");
cold: "border-border/70 bg-surface-muted text-muted", if (effect === "rain" || effect === "thunderstorm" || effect === "none") return effect;
night: "border-border/70 bg-surface-muted text-muted", if (effect === "storm") return "thunderstorm";
mild: "border-accent/25 bg-accent/10 text-accent", return null;
}[mood];
} }
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) { export function WeatherHero({
station,
currentWeather,
currentWeatherLoading = false,
currentForecastWeatherCode,
locationName,
distanceKm,
}: {
station: SynopStation;
currentWeather?: ImgwCurrentWeather | null;
currentWeatherLoading?: boolean;
currentForecastWeatherCode?: number | null;
locationName?: string;
distanceKm?: number | null;
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const 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 displayedLocationName = locationName ?? station.name;
const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; const hasDistantFallback =
const displayedStation = currentWeather ? { !hasGlobalModel &&
...station, !hasFullHybridAnalysis &&
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt, !currentWeatherLoading &&
temperature: currentWeather.temperature ?? station.temperature, distanceKm !== undefined &&
windSpeed: currentWeather.windSpeed ?? station.windSpeed, distanceKm !== null &&
windDirection: currentWeather.windDirection ?? station.windDirection, distanceKm >= 30;
humidity: currentWeather.humidity ?? station.humidity, const formattedDistance = formatDistance(distanceKm ?? null, language, distanceUnit);
pressure: currentWeather.pressure ?? station.pressure, const displayedStation = currentWeather
rainfall: currentWeather.precipitation10m ?? station.rainfall, ? {
} : station; ...station,
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;
const mood = getWeatherMoodFromData(displayedStation); const mood = getWeatherMoodFromData(displayedStation);
const moodAccent = moodAccentClass(mood); const feelsLike =
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); currentWeather?.feelsLike ??
calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [ const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) }, {
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, icon: Wind,
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, 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"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm =
devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
: currentWeather?.condition === "thunderstorm";
const weatherDescription = getWeatherDescription(
displayedStation,
language,
currentWeather?.condition,
currentWeather?.weatherCode,
currentWeather?.cloudCover,
currentForecastWeatherCode,
);
useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
const updateOverride = () => setDevEffectOverride(readDevWeatherEffectOverride());
updateOverride();
window.addEventListener("popstate", updateOverride);
return () => window.removeEventListener("popstate", updateOverride);
}, []);
return ( return (
<motion.section <motion.section
@@ -63,43 +140,68 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
transition={{ duration: 0.55, ease: "easeOut" }} transition={{ duration: 0.55, ease: "easeOut" }}
className="relative isolate overflow-hidden rounded-panel border border-border/70 bg-surface-raised px-5 py-6 shadow-card sm:px-8 sm:py-8 lg:px-10" className="relative isolate overflow-hidden rounded-panel border border-border/70 bg-surface-raised px-5 py-6 shadow-card sm:px-8 sm:py-8 lg:px-10"
> >
<WeatherEffects precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} /> <WeatherEffects precipitation10m={effectPrecipitation10m} thunderstorm={effectThunderstorm} />
<div className="relative z-10"> <div className="relative z-10">
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <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="flex items-center gap-1.5 text-sm font-medium text-muted">
<span className={`rounded-control border px-2.5 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${moodAccent}`}> <MapPin className="size-4" />
{getWeatherDescription(displayedStation, language, currentWeather?.condition)} {displayedLocationName}
</span> </span>
</div> </div>
<div className="mt-2 space-y-1 text-xs text-muted"> <div className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading <p>
? t("location.heroHybridLoading", { station: station.name }) {currentWeatherLoading
: hasFullHybridAnalysis ? t("location.heroHybridLoading", { station: station.name })
? t("location.heroHybridSource", { location: displayedLocationName }) : hasGlobalModel
: hasPartialHybridAnalysis ? t("location.heroGlobalModelSource", { location: displayedLocationName })
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 }) : hasFullHybridAnalysis
: locationName ? t("location.heroHybridSource", { location: displayedLocationName })
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 }) : hasPartialHybridAnalysis
: t("location.heroStationFallback", { station: station.name })}</p> ? t("location.heroHybridPartial", { station: station.name, distance: formattedDistance })
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>} : locationName
{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> </div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10"> <div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<div> <div>
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]"> <div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
{formatTemperature(displayedStation.temperature, language)} {formatTemperature(displayedStation.temperature, language, temperatureUnit)}
</div> </div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p> <p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p> <p className="mt-1 text-sm text-muted">
{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} ·{" "}
{hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")}{" "}
{formatDateTime(displayedStation.measuredAt, language)}
</p>
</div> </div>
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" /> <WeatherIcon
mood={mood}
condition={currentWeather?.condition}
className="mb-4 size-20 text-accent sm:size-28"
/>
</div> </div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4"> <div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => ( {metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5"> <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> <p className="mt-2 text-base font-semibold">{value}</p>
</div> </div>
))} ))}

View File

@@ -2,14 +2,29 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
import type { WeatherMood } from "@/types/imgw"; import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current"; import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) { export function WeatherIcon({
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : { mood,
warm: ThermometerSun, condition,
cloudy: Cloud, className = "",
wind: Wind, }: {
cold: Snowflake, mood: WeatherMood;
night: MoonStar, condition?: CurrentWeatherCondition;
mild: CloudSun, className?: string;
}[mood]; }) {
const Icon =
condition === "thunderstorm"
? CloudLightning
: condition === "rain"
? CloudRain
: condition === "snow"
? Snowflake
: {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,
cold: Snowflake,
night: MoonStar,
mild: CloudSun,
}[mood];
return <Icon className={className} strokeWidth={1.35} />; return <Icon className={className} strokeWidth={1.35} />;
} }

View File

@@ -4,27 +4,28 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
## Dane Pogodowe i Lokalizacje ## Dane Pogodowe i Lokalizacje
| Metoda | Endpoint | Przeznaczenie | | Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- | | ------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/forecast?latitude={lat}&longitude={lon}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Łączy IMGW ALARO z Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | | `GET` | `/api/current-weather?latitude={lat}&longitude={lon}&region={PL\|GLOBAL}` | Zwraca znormalizowane bieżące warunki. Dla `PL` używa IMGW Hybrid, dla `GLOBAL` modelowych warunków Open-Meteo. |
| `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/forecast?latitude={lat}&longitude={lon}&region={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/{path}` | Proxy allowlistowanych endpointów IMGW `danepubliczne.imgw.pl`. Obsługuje kolekcje `synop`, `hydro`, `meteo`, `warningsmeteo`, `warningshydro`, `product` oraz szczegół `synop/id/{id}`. Nieobsługiwana ścieżka zwraca `404`. | | `GET` | `/api/imgw-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/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/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/reverse?latitude={lat}&longitude={lon}&language={pl\|en}` | Ustala nazwę miejsca dla pozycji GPS przez Nominatim / OpenStreetMap. Współrzędne są zaokrąglane do trzech miejsc po przecinku. | | `GET` | `/api/locations/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 ## Powiadomienia
Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i pamięciowego magazynu subskrypcji. Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i lokalnego magazynu SQLite dla subskrypcji.
| Metoda | Endpoint | Przeznaczenie | | Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- | | -------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. | | `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. |
| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo i opcjonalny powiat TERYT. | | `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje region `PL`/`GLOBAL`, preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo dla `PL` i opcjonalny powiat TERYT. |
| `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. | | `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. |
| `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. | | `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. |
| `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. | | `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. |
| `GET` | `/api/notifications/daily-brief` | Endpoint harmonogramu wysyłający raz dziennie poranny brief dla subskrypcji z włączoną opcją i zapisaną lokalizacją. | | `GET` | `/api/notifications/daily-brief` | Endpoint harmonogramu wysyłający raz dziennie poranny brief dla subskrypcji z włączoną opcją i zapisaną lokalizacją. |
| `GET` | `/api/notifications/tomorrow-brief` | Endpoint harmonogramu wysyłający wieczorny brief z prognozą na kolejny dzień. | | `GET` | `/api/notifications/tomorrow-brief` | Endpoint harmonogramu wysyłający wieczorny brief z prognozą na kolejny dzień. |
## Autoryzacja Harmonogramu ## Autoryzacja Harmonogramu
@@ -45,12 +46,13 @@ Jeśli `NOTIFICATIONS_CRON_SECRET` nie jest ustawiony, endpointy harmonogramu s
## Cache i Odpowiedzi ## Cache i Odpowiedzi
| Endpoint | Cache | | Endpoint | Cache |
| --- | --- | | ------------------------ | ------------------------------------------------- |
| `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` | | `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` |
| `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` | | `/api/current-weather` | `s-maxage=120`, `stale-while-revalidate=300` |
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` | | `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` |
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` | | `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
| `/api/locations/reverse` | `s-maxage=86400`, `stale-while-revalidate=604800` | | `/api/locations/reverse` | `s-maxage=86400`, `stale-while-revalidate=604800` |
Route handlery zwracają kontrolowane odpowiedzi JSON. Błędy zewnętrznych usług są mapowane na czytelne statusy HTTP, zwykle `400`, `404`, `502` albo `503`. Route handlery zwracają kontrolowane odpowiedzi JSON. Błędy zewnętrznych usług są mapowane na czytelne statusy HTTP, zwykle `400`, `404`, `502` albo `503`.
@@ -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ą: Publiczne endpointy ostrzeżeń IMGW potrafią zwrócić HTTP `404` z treścią:
```json ```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. Dla `warningsmeteo` i `warningshydro` aplikacja traktuje ten wariant jako poprawną pustą listę ostrzeżeń, a nie awarię źródła danych.

View File

@@ -22,7 +22,7 @@ Najważniejsze widoki:
- `/` - dashboard pogody, wyszukiwarka lokalizacji, hero, prognoza, briefy i ostrzeżenia regionalne, - `/` - dashboard pogody, wyszukiwarka lokalizacji, hero, prognoza, briefy i ostrzeżenia regionalne,
- `/warnings` - pełny widok ostrzeżeń meteo i hydro, - `/warnings` - pełny widok ostrzeżeń meteo i hydro,
- `/hydro` - stacje hydrologiczne, - `/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, - `/station/[id]` - szczegóły stacji,
- `/offline` - fallback offline. - `/offline` - fallback offline.
@@ -42,7 +42,9 @@ Zustand w `lib/store.ts` przechowuje trwałe preferencje użytkownika w `localSt
- ulubione stacje, - ulubione stacje,
- wybraną stację albo lokalizację, - wybraną stację albo lokalizację,
- ustawienia powiadomień, - 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`. 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: Service worker:
- cache'uje powłokę aplikacji, - cache'uje powłokę aplikacji,
- obsługuje fallback `/offline` dla nawigacji, - obsługuje fallback `/offline` dla nawigacji i zapisuje udane nawigacje do cache,
- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/forecast`, - 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. - obsługuje zdarzenia `push` i kliknięcia w powiadomienia.
Rejestracja service workera działa w buildzie produkcyjnym. Rejestracja service workera działa w buildzie produkcyjnym.
@@ -66,6 +69,7 @@ Interfejs jest dostępny po polsku i angielsku. Teksty są w `lib/i18n.tsx`. Naz
## Zasady Integracji ## Zasady Integracji
- Dane zewnętrzne przechodzą przez route handlery Next.js. - Dane zewnętrzne przechodzą przez route handlery Next.js.
- IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń. - IMGW pozostaje źródłem bieżących pomiarów, hydro i ostrzeżeń dla Polski.
- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW. - Poza Polską Open-Meteo jest źródłem modelowych bieżących warunków i prognozy.
- Prognoza modelowa jest opisana oddzielnie i nie jest przedstawiana jako pomiar IMGW ani oficjalny alert.
- Braki danych są pokazywane jawnie, bez generowania fikcyjnych wartości. - Braki danych są pokazywane jawnie, bez generowania fikcyjnych wartości.

View File

@@ -4,27 +4,41 @@
## IMGW ## IMGW
| Dane | Źródło | | Dane | Źródło |
| --- | --- | | ----------------------------- | ------------------------------------------------------ |
| Bieżąca analiza IMGW Hybrid | `https://meteo.imgw.pl/api/v1/forecast/fcapi` | | 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` | | Prognoza godzinowa IMGW ALARO | `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro` |
| Dane synoptyczne | `https://danepubliczne.imgw.pl/api/data/synop` | | Dane synoptyczne | `https://danepubliczne.imgw.pl/api/data/synop` |
| Pojedyncza stacja synoptyczna | `https://danepubliczne.imgw.pl/api/data/synop/id/{id}` | | Pojedyncza stacja synoptyczna | `https://danepubliczne.imgw.pl/api/data/synop/id/{id}` |
| Dane hydrologiczne | `https://danepubliczne.imgw.pl/api/data/hydro/` | | Dane hydrologiczne | `https://danepubliczne.imgw.pl/api/data/hydro/` |
| Ostrzeżenia meteorologiczne | `https://danepubliczne.imgw.pl/api/data/warningsmeteo` | | Ostrzeżenia meteorologiczne | `https://danepubliczne.imgw.pl/api/data/warningsmeteo` |
| Ostrzeżenia hydrologiczne | `https://danepubliczne.imgw.pl/api/data/warningshydro` | | Ostrzeżenia hydrologiczne | `https://danepubliczne.imgw.pl/api/data/warningshydro` |
| Dane meteorologiczne | `https://danepubliczne.imgw.pl/api/data/meteo/` | | Dane meteorologiczne | `https://danepubliczne.imgw.pl/api/data/meteo/` |
| Lista produktów | `https://danepubliczne.imgw.pl/api/data/product` | | Lista produktów | `https://danepubliczne.imgw.pl/api/data/product` |
IMGW jest traktowane jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognoza modelowa jest w interfejsie rozdzielona od pomiarów. IMGW jest traktowane jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognoza modelowa jest w interfejsie rozdzielona od pomiarów.
IMGW jest używane tylko dla lokalizacji w Polsce. Dla lokalizacji globalnych oficjalne ostrzeżenia IMGW, dane hydrologiczne IMGW, TERYT i powiatowe filtrowanie ostrzeżeń są niedostępne.
## Open-Meteo ## Open-Meteo
Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada. Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza pełny 7-dniowy horyzont prognozy, prawdopodobieństwo opadu i fallback, jeśli IMGW ALARO nie odpowiada. Poza Polską dostarcza także modelowe warunki bieżące dla dashboardu.
Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do wyszukiwania miejscowości w Polsce. Open-Meteo Geocoding API (`https://geocoding-api.open-meteo.com/v1/search`) służy do globalnego wyszukiwania miejscowości. Wynik zawiera m.in. `countryCode`, `admin1`, `admin2`, `timezone` i współrzędne, które aplikacja zapisuje w modelu lokalizacji.
Przed większym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania z Open-Meteo albo zastąpić usługę własnym dostawcą. Przed większym, publicznym lub komercyjnym wdrożeniem należy sprawdzić aktualne warunki korzystania, limity i licencję Open-Meteo albo zastąpić usługę własnym dostawcą.
## Capabilities per Region
| Capability | Polska | Global |
| --------------------- | ------------------------------ | ---------------------------- |
| Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model |
| Prognoza godzinowa | IMGW ALARO + Open-Meteo | Open-Meteo |
| Prognoza dzienna | IMGW ALARO/Open-Meteo | Open-Meteo |
| Oficjalne ostrzeżenia | IMGW | niedostępne |
| Filtrowanie powiatowe | TERYT | niedostępne |
| Briefy | tak | tak, bez oficjalnych alertów |
| Push alerty oficjalne | IMGW | niedostępne |
## Nominatim / OpenStreetMap ## Nominatim / OpenStreetMap
@@ -40,15 +54,16 @@ Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użyc
## Cache ## Cache
| Dane | Cache | | Dane | Cache |
| --- | --- | | ----------------------------------- | ------------------------------------------ |
| IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` | | IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` |
| Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` | | Modelowe warunki bieżące Open-Meteo | 120 sekund, `stale-while-revalidate=300` |
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` | | Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` |
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` | | Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
| Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` | | Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
| Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` |
Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią. Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią.
## Ograniczenia Źródeł ## Ograniczenia Źródeł

View File

@@ -60,6 +60,7 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
@@ -67,16 +68,18 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
Do zwykłego uruchomienia aplikacji pogodowej zmienne Web Push nie są wymagane. Są potrzebne dopiero dla powiadomień. Do zwykłego uruchomienia aplikacji pogodowej zmienne Web Push nie są wymagane. Są potrzebne dopiero dla powiadomień.
`WTR_DATABASE_PATH` wskazuje plik SQLite z subskrypcjami Web Push i historią wysyłek. Na self-hostingu ustaw go na ścieżkę w trwałym katalogu, np. `/home/admin/wtr-data/wtr.sqlite`, jeśli katalog projektu jest podmieniany podczas deploya.
## Jakość ## Jakość
Przed zakończeniem zmian uruchamiaj: Repozytorium używa Gitea Actions jako końcowej bramki jakości po pushu. Lokalna weryfikacja powinna być proporcjonalna do ryzyka zmiany:
```bash - dokumentacja, teksty i komentarze: przegląd diffu oraz opcjonalnie `git diff --check`,
npm run lint - drobne zmiany wizualne: bez rutynowego builda; `npm run lint` tylko przy ryzyku błędu składni lub reguł lintingu,
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 osobnego skryptu testów, osobnego skryptu type-check 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 ## Bezpieczeństwo Zależności

View File

@@ -2,7 +2,7 @@
Powiadomienia obejmują: Powiadomienia obejmują:
- nowe ostrzeżenia meteorologiczne IMGW, - nowe ostrzeżenia meteorologiczne IMGW dla Polski,
- poranny brief dnia, - poranny brief dnia,
- wieczorny brief z prognozą na jutro, - wieczorny brief z prognozą na jutro,
- testowe powiadomienie wysyłane z `/settings`. - testowe powiadomienie wysyłane z `/settings`.
@@ -31,21 +31,33 @@ Endpointy harmonogramu wymagają też sekretu crona w produkcji:
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
``` ```
Subskrypcje Web Push i historia wysyłek są zapisywane w SQLite:
```bash
WTR_DATABASE_PATH=./data/wtr.sqlite
```
Jeśli zmienna nie jest ustawiona, aplikacja używa `./data/wtr.sqlite` względem katalogu projektu.
## Preferencje Subskrypcji ## Preferencje Subskrypcji
Subskrypcja Web Push zapisuje: Subskrypcja Web Push zapisuje:
- endpoint subskrypcji przeglądarki, - endpoint subskrypcji przeglądarki,
- województwo, - region pogodowy `PL` albo `GLOBAL`,
- województwo dla oficjalnych ostrzeżeń IMGW w Polsce,
- język interfejsu, - język interfejsu,
- czy ostrzeżenia są aktywne, - czy ostrzeżenia są aktywne,
- czy aktywny jest brief poranny, - czy aktywny jest brief poranny,
- czy aktywny jest brief na jutro, - czy aktywny jest brief na jutro,
- współrzędne i nazwę lokalizacji, - współrzędne i nazwę lokalizacji,
- opcjonalny powiat TERYT. - opcjonalny powiat TERYT,
- 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. Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.
Dla subskrypcji `GLOBAL` oficjalne ostrzeżenia IMGW są wyłączone, `province` może być puste, a powiadomienia mogą obejmować brief poranny i brief na jutro oparte o prognozę modelową Open-Meteo.
## Worker Self-Hosted ## Worker Self-Hosted
Worker powiadomień uruchamia się osobno od aplikacji: Worker powiadomień uruchamia się osobno od aplikacji:
@@ -74,6 +86,7 @@ Domyślna konfiguracja:
```bash ```bash
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_BRIEF_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
``` ```
@@ -81,12 +94,22 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
Worker: Worker:
- sprawdza nowe ostrzeżenia co 5 minut, - sprawdza nowe ostrzeżenia co 5 minut,
- po 07:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/daily-brief`, - co 5 minut odpytuje endpointy briefów,
- po 18:00 czasu `Europe/Warsaw` wywołuje `/api/notifications/tomorrow-brief`, - endpoint `/api/notifications/daily-brief` wysyła po 07:00 lokalnego czasu zapisanego w subskrypcji,
- endpoint `/api/notifications/tomorrow-brief` wysyła po 18:00 lokalnego czasu zapisanego w subskrypcji,
- nie blokuje jednego harmonogramu błędem drugiego. - nie blokuje jednego harmonogramu błędem drugiego.
Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona.
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 ## Zewnętrzny Cron
Jeśli nie używasz `npm run notifications:worker`, zewnętrzny cron powinien wywoływać: Jeśli nie używasz `npm run notifications:worker`, zewnętrzny cron powinien wywoływać:
@@ -112,6 +135,6 @@ x-cron-secret: <NOTIFICATIONS_CRON_SECRET>
## Ograniczenia ## Ograniczenia
Obecny `lib/push-store.ts` przechowuje subskrypcje i historię wysyłek w pamięci procesu. Po restarcie procesu te dane znikają. Produkcja wymaga trwałego magazynu, np. bazy danych albo KV. `lib/push-store.ts` przechowuje subskrypcje i historię wysyłek w lokalnym SQLite przez `better-sqlite3`. Dane przetrwają restart procesu, jeśli plik wskazany przez `WTR_DATABASE_PATH` znajduje się w trwałym katalogu. Na self-hostingu warto backupować plik bazy razem z plikami `-wal` i `-shm`, jeśli istnieją.
Powiadomienie testowe jest wysyłane na konkretny endpoint subskrypcji przekazany z `/settings`, a nie broadcastowane do wszystkich urządzeń. Powiadomienie testowe jest wysyłane na konkretny endpoint subskrypcji przekazany z `/settings`, a nie broadcastowane do wszystkich urządzeń.

View File

@@ -2,9 +2,29 @@
`wtr.` rozdziela bieżące pomiary, lokalną analizę IMGW Hybrid, prognozę modelową i ostrzeżenia. Prognoza nie jest przedstawiana jako pomiar IMGW. `wtr.` rozdziela bieżące pomiary, lokalną analizę IMGW Hybrid, prognozę modelową i ostrzeżenia. Prognoza nie jest przedstawiana jako pomiar IMGW.
## Region i Kontrakt Danych
Aplikacja rozpoznaje dwa regiony pogodowe:
- `PL` - lokalizacje w Polsce,
- `GLOBAL` - wszystkie pozostałe lokalizacje.
Region jest częścią modelu lokalizacji i odpowiedzi pogodowych. UI powinien bazować na capabilities i metadanych źródła, a nie rozsiewać własne warunki po kraju.
Każda znormalizowana odpowiedź bieżących warunków zawiera metadane:
- `source`, np. `IMGW` albo `OPEN_METEO`,
- `sourceLabel` dla UI,
- `sourceType`, np. `hybrid` albo `model`,
- `isOfficial`,
- `region`,
- `fetchedAt` i `measuredAt`/czas aktualizacji.
## Bieżące Warunki ## Bieżące Warunki
Dashboard hero korzysta z publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl` przez `/api/imgw-current`. Dashboard hero korzysta z `/api/current-weather`.
Dla `PL` endpoint używa publicznego endpointu IMGW Hybrid oficjalnego portalu `meteo.imgw.pl`. Dla `GLOBAL` używa modelowych bieżących warunków Open-Meteo i nie opisuje ich jako pomiaru.
Normalizacja w `lib/imgw-current-api.ts`: Normalizacja w `lib/imgw-current-api.ts`:
@@ -19,13 +39,15 @@ Jeśli Hybrid nie dostarcza pełnych danych, UI jawnie korzysta z fallbacku `syn
## Prognoza Modelowa ## Prognoza Modelowa
Route handler `/api/forecast` pobiera równolegle: Route handler `/api/forecast` dla `PL` pobiera równolegle:
- pełne 7 dni Open-Meteo, - pełne 7 dni Open-Meteo,
- godzinowe IMGW ALARO. - godzinowe IMGW ALARO.
W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo. Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont do 7 dni. Awaria ALARO pozostawia działający fallback Open-Meteo. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo. Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont do 7 dni. Awaria ALARO pozostawia działający fallback Open-Meteo.
Dla `GLOBAL` route handler nie odpytuje IMGW ALARO i używa Open-Meteo z `timezone=auto`.
Dashboard pokazuje: Dashboard pokazuje:
- regułowy brief dnia, - regułowy brief dnia,
@@ -38,6 +60,8 @@ Widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
Ostrzeżenia meteorologiczne IMGW zawierają kody powiatów TERYT. Ostrzeżenia hydrologiczne zawierają jawne województwa. Normalizacja odbywa się przez `lib/provinces.ts` i `lib/warning-regions.ts`. Ostrzeżenia meteorologiczne IMGW zawierają kody powiatów TERYT. Ostrzeżenia hydrologiczne zawierają jawne województwa. Normalizacja odbywa się przez `lib/provinces.ts` i `lib/warning-regions.ts`.
Ostrzeżenia są capability tylko dla `PL`. Dla `GLOBAL` widok `/warnings` pokazuje stan niedostępności oficjalnych ostrzeżeń i nie traktuje prognozy modelowej jako oficjalnego alertu.
Zasady UI: Zasady UI:
- lokalny obszar jest priorytetyzowany, - lokalny obszar jest priorytetyzowany,
@@ -55,6 +79,8 @@ Brief dnia analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i na
Brief na jutro analizuje pełny jutrzejszy dzień prognozy. Może informować o burzach na podstawie kodów pogodowych modelu nawet wtedy, gdy IMGW nie opublikowało oficjalnego ostrzeżenia. Brief na jutro analizuje pełny jutrzejszy dzień prognozy. Może informować o burzach na podstawie kodów pogodowych modelu nawet wtedy, gdy IMGW nie opublikowało oficjalnego ostrzeżenia.
Dla `GLOBAL` briefy korzystają z Open-Meteo i pomijają oficjalne ostrzeżenia IMGW.
## Opad i Pomiary Synoptyczne ## Opad i Pomiary Synoptyczne
Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu. Pole `synop.suma_opadu` jest akumulowaną sumą opadu. Nie oznacza, że pada w tej chwili, i nie steruje animacją deszczu.
@@ -65,31 +91,61 @@ IMGW Hybrid dostarcza m.in. opad 10-minutowy. Ten parametr jest prezentowany odd
Prognoza godzinowa i dzienna rozpoznaje: Prognoza godzinowa i dzienna rozpoznaje:
| Stan | Opis w interfejsie | | Stan | Opis w interfejsie |
| --- | --- | | -------------- | ---------------------- |
| `clear` | Bezchmurnie | | `clear` | Bezchmurnie |
| `partlyCloudy` | Częściowe zachmurzenie | | `partlyCloudy` | Częściowe zachmurzenie |
| `cloudy` | Pochmurno | | `cloudy` | Pochmurno |
| `fog` | Mgła | | `fog` | Mgła |
| `drizzle` | Mżawka | | `drizzle` | Mżawka |
| `rain` | Opady deszczu | | `rain` | Opady deszczu |
| `snow` | Opady śniegu | | `snow` | Opady śniegu |
| `thunderstorm` | Burza | | `thunderstorm` | Burza |
| `unknown` | Brak opisu | | `unknown` | Brak opisu |
Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`. Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Dashboard hero używa opisu warunków w następującym priorytecie:
1. bieżące zjawisko z IMGW Hybrid: `Burza`, `Opady śniegu`, `Opady deszczu`,
2. silny wiatr od `8 m/s`: `Silny wiatr`,
3. bieżący kod pogody i zachmurzenie z IMGW Hybrid,
4. najbliższa godzina prognozy modelowej, jeśli Hybrid nie daje opisu nieba,
5. wysoka wilgotność od `90%`: `Wilgotno`,
6. fallback: `Pogodnie`.
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, 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 ## Mood i Efekty Wizualne
Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu. Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu.
| Mood | Reguła | | Mood | Reguła |
| --- | --- | | -------- | ------------------------------------ |
| `night` | godzina przed `06:00` lub od `21:00` | | `night` | godzina przed `06:00` lub od `21:00` |
| `wind` | wiatr od `8 m/s` | | `wind` | wiatr od `8 m/s` |
| `cold` | temperatura do `3°C` | | `cold` | temperatura do `3°C` |
| `cloudy` | wilgotność od `80%` | | `cloudy` | wilgotność od `80%` |
| `warm` | temperatura od `20°C` | | `warm` | temperatura od `20°C` |
| `mild` | pozostałe przypadki | | `mild` | pozostałe przypadki |
Warstwa efektów wizualnych jest ograniczona do subtelnych efektów informacyjnych: kropli przy lokalnym opadzie oraz błysku przy burzy. Mood hero jest heurystyką, a nie pełną klasyfikacją sterowaną kodem warunków IMGW Hybrid. Warstwa efektów wizualnych jest ograniczona do subtelnych efektów informacyjnych: kropli przy lokalnym opadzie oraz błysku przy burzy. Mood hero jest heurystyką, a nie pełną klasyfikacją sterowaną kodem warunków IMGW Hybrid.
## Podgląd Efektów w Development
W trybie deweloperskim można wymusić samą nakładkę efektu na karcie pogody przez parametr URL. Nie zmienia to danych pogodowych, temperatury, opisu ani ikon.
```text
/?effect=rain
/?effect=thunderstorm
/?effect=storm
/?effect=none
```
Ten mechanizm działa tylko przy `NODE_ENV=development`, np. podczas `npm run dev`. Produkcyjny build ignoruje te parametry i używa wyłącznie rzeczywistych danych pogodowych.

View File

@@ -1,15 +1,16 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchImgwCurrentWeather } from "@/lib/imgw-current-api"; import { fetchCurrentWeather } from "@/lib/imgw-current-api";
import { QUERY_GC_TIME } from "@/lib/constants"; import { QUERY_GC_TIME } from "@/lib/constants";
import type { WeatherRegion } from "@/types/weather-region";
const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000; const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000;
export function useCurrentWeather(latitude?: number, longitude?: number) { export function useCurrentWeather(latitude?: number, longitude?: number, region: WeatherRegion = "PL") {
return useQuery({ return useQuery({
queryKey: ["imgw-current-weather", latitude, longitude], queryKey: ["current-weather", region, latitude, longitude],
queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal), queryFn: ({ signal }) => fetchCurrentWeather(latitude as number, longitude as number, region, signal),
staleTime: CURRENT_WEATHER_STALE_TIME, staleTime: CURRENT_WEATHER_STALE_TIME,
gcTime: QUERY_GC_TIME, gcTime: QUERY_GC_TIME,
retry: 1, retry: 1,

View File

@@ -3,11 +3,12 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchForecast } from "@/lib/forecast-api"; import { fetchForecast } from "@/lib/forecast-api";
import { QUERY_GC_TIME } from "@/lib/constants"; import { QUERY_GC_TIME } from "@/lib/constants";
import type { WeatherRegion } from "@/types/weather-region";
export function useForecast(latitude?: number, longitude?: number) { export function useForecast(latitude?: number, longitude?: number, region: WeatherRegion = "PL") {
return useQuery({ return useQuery({
queryKey: ["forecast", latitude, longitude], queryKey: ["forecast", region, latitude, longitude],
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal), queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, region, signal),
enabled: Number.isFinite(latitude) && Number.isFinite(longitude), enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
staleTime: 15 * 60 * 1000, staleTime: 15 * 60 * 1000,
gcTime: QUERY_GC_TIME, gcTime: QUERY_GC_TIME,

52
lib/app-sections.ts Normal file
View 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
View 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;
}

View File

@@ -1,4 +1,6 @@
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast"; import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherRegion } from "@/types/weather-region";
import { getWeatherCapabilities } from "@/types/weather-region";
function readNumber(value: unknown) { function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null; return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -17,61 +19,76 @@ function normalizeSources(value: unknown): ForecastSource[] {
} }
function normalizeHourlyForecast(value: unknown): HourlyForecast[] { function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
return Array.isArray(value) ? value.flatMap((candidate) => { return Array.isArray(value)
if (!candidate || typeof candidate !== "object") return []; ? value.flatMap((candidate) => {
const row = candidate as Partial<HourlyForecast>; if (!candidate || typeof candidate !== "object") return [];
const time = readString(row.time); const row = candidate as Partial<HourlyForecast>;
if (!time) return []; const time = readString(row.time);
return [{ if (!time) return [];
time, return [
temperature: readNumber(row.temperature), {
feelsLike: readNumber(row.feelsLike), time,
precipitationProbability: readNumber(row.precipitationProbability), temperature: readNumber(row.temperature),
precipitation: readNumber(row.precipitation), feelsLike: readNumber(row.feelsLike),
weatherCode: readNumber(row.weatherCode), precipitationProbability: readNumber(row.precipitationProbability),
windSpeed: readNumber(row.windSpeed), precipitation: readNumber(row.precipitation),
source: isForecastSource(row.source) ? row.source : "open-meteo", weatherCode: readNumber(row.weatherCode),
}]; windSpeed: readNumber(row.windSpeed),
}) : []; source: isForecastSource(row.source) ? row.source : "open-meteo",
},
];
})
: [];
} }
function normalizeDailyForecast(value: unknown): DailyForecast[] { function normalizeDailyForecast(value: unknown): DailyForecast[] {
return Array.isArray(value) ? value.flatMap((candidate) => { return Array.isArray(value)
if (!candidate || typeof candidate !== "object") return []; ? value.flatMap((candidate) => {
const row = candidate as Partial<DailyForecast>; if (!candidate || typeof candidate !== "object") return [];
const date = readString(row.date); const row = candidate as Partial<DailyForecast>;
if (!date) return []; const date = readString(row.date);
return [{ if (!date) return [];
date, return [
temperatureMax: readNumber(row.temperatureMax), {
temperatureMin: readNumber(row.temperatureMin), date,
precipitationProbability: readNumber(row.precipitationProbability), temperatureMax: readNumber(row.temperatureMax),
precipitation: readNumber(row.precipitation), temperatureMin: readNumber(row.temperatureMin),
weatherCode: readNumber(row.weatherCode), precipitationProbability: readNumber(row.precipitationProbability),
sunrise: readString(row.sunrise), precipitation: readNumber(row.precipitation),
sunset: readString(row.sunset), weatherCode: readNumber(row.weatherCode),
sources: normalizeSources(row.sources), sunrise: readString(row.sunrise),
}]; sunset: readString(row.sunset),
}) : []; sources: normalizeSources(row.sources),
},
];
})
: [];
} }
function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast { function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
const latitude = Number(raw.latitude); const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude); const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error("Forecast service returned invalid coordinates.");
const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL";
return { return {
latitude, latitude,
longitude, longitude,
region,
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw", timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
hourly: normalizeHourlyForecast(raw.hourly), hourly: normalizeHourlyForecast(raw.hourly),
daily: normalizeDailyForecast(raw.daily), daily: normalizeDailyForecast(raw.daily),
sources: normalizeSources(raw.sources), sources: normalizeSources(raw.sources),
capabilities: raw.capabilities ?? getWeatherCapabilities(region),
unavailableReasons: Array.isArray(raw.unavailableReasons)
? raw.unavailableReasons.filter((value): value is string => typeof value === "string")
: [],
}; };
} }
export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) { export async function fetchForecast(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
const response = await fetch(`/api/forecast?${params}`, { signal }); const response = await fetch(`/api/forecast?${params}`, { signal });
if (!response.ok) throw new Error("Unable to load forecast."); if (!response.ok) throw new Error("Unable to load forecast.");
return normalizeForecast(await response.json() as Partial<WeatherForecast>); return normalizeForecast((await response.json()) as Partial<WeatherForecast>);
} }

View File

@@ -8,6 +8,7 @@ import type {
RawWeatherForecast, RawWeatherForecast,
WeatherForecast, WeatherForecast,
} from "@/types/forecast"; } from "@/types/forecast";
import { getWeatherCapabilities, type WeatherRegion } from "@/types/weather-region";
const WARSAW_TIME_ZONE = "Europe/Warsaw"; const WARSAW_TIME_ZONE = "Europe/Warsaw";
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", { const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
@@ -65,16 +66,18 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
return asArray(series.time).flatMap((_, index) => { return asArray(series.time).flatMap((_, index) => {
const time = readString(series, "time", index); const time = readString(series, "time", index);
if (!time) return []; if (!time) return [];
return [{ return [
time, {
temperature: readNumber(series, "temperature_2m", index), time,
feelsLike: readNumber(series, "apparent_temperature", index), temperature: readNumber(series, "temperature_2m", index),
precipitationProbability: readNumber(series, "precipitation_probability", index), feelsLike: readNumber(series, "apparent_temperature", index),
precipitation: readNumber(series, "precipitation", index), precipitationProbability: readNumber(series, "precipitation_probability", index),
weatherCode: readNumber(series, "weather_code", index), precipitation: readNumber(series, "precipitation", index),
windSpeed: readNumber(series, "wind_speed_10m", index), weatherCode: readNumber(series, "weather_code", index),
source: "open-meteo" as const, windSpeed: readNumber(series, "wind_speed_10m", index),
}]; source: "open-meteo" as const,
},
];
}); });
} }
@@ -82,31 +85,37 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
return asArray(series.time).flatMap((_, index) => { return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index); const date = readString(series, "time", index);
if (!date) return []; if (!date) return [];
return [{ return [
date, {
temperatureMax: readNumber(series, "temperature_2m_max", index), date,
temperatureMin: readNumber(series, "temperature_2m_min", index), temperatureMax: readNumber(series, "temperature_2m_max", index),
precipitationProbability: readNumber(series, "precipitation_probability_max", index), temperatureMin: readNumber(series, "temperature_2m_min", index),
precipitation: readNumber(series, "precipitation_sum", index), precipitationProbability: readNumber(series, "precipitation_probability_max", index),
weatherCode: readNumber(series, "weather_code", index), precipitation: readNumber(series, "precipitation_sum", index),
sunrise: readString(series, "sunrise", index), weatherCode: readNumber(series, "weather_code", index),
sunset: readString(series, "sunset", index), sunrise: readString(series, "sunrise", index),
sources: ["open-meteo" as const], 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 latitude = Number(raw.latitude);
const longitude = Number(raw.longitude); const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error("Forecast service returned invalid coordinates.");
return { return {
latitude, latitude,
longitude, longitude,
region,
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE, timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
hourly: normalizeOpenMeteoHourly(raw.hourly), hourly: normalizeOpenMeteoHourly(raw.hourly),
daily: normalizeOpenMeteoDaily(raw.daily), daily: normalizeOpenMeteoDaily(raw.daily),
sources: ["open-meteo"], sources: ["open-meteo"],
capabilities: getWeatherCapabilities(region),
unavailableReasons: region === "GLOBAL" ? ["official-warnings"] : [],
}; };
} }
@@ -165,9 +174,11 @@ function getWeatherCodePriority(code: number | null) {
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) { function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
if (!hours.length) return fallback; if (!hours.length) return fallback;
return hours.reduce((selected, hour) => ( return hours.reduce(
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected (selected, hour) =>
), null as number | null); getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected,
null as number | null,
);
} }
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) { 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`)); const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
return { return {
...day, ...day,
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax), temperatureMax: getMaximum(
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin), dayHours.map((hour) => hour.temperature),
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability), day.temperatureMax,
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation), ),
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), weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
sources: getSources(dayHours, day.sources), sources: getSources(dayHours, day.sources),
}; };
} }
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast { export function mergeForecastSources(
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload); openMeteoPayload: RawWeatherForecast,
imgwPayload?: RawImgwForecastResponse | null,
region: WeatherRegion = "PL",
): WeatherForecast {
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
const imgwHours = normalizeImgwHourly(imgwPayload); const imgwHours = normalizeImgwHourly(imgwPayload);
let hasImgwHours = false; let hasImgwHours = false;
const hourly = openMeteoForecast.hourly.map((hour) => { const hourly = openMeteoForecast.hourly.map((hour) => {

View File

@@ -1,6 +1,15 @@
import type { Language, TranslationKey } from "@/lib/i18n"; import type { Language, TranslationKey } from "@/lib/i18n";
import { translate } from "@/lib/i18n"; import { translate } from "@/lib/i18n";
import type { HourlyForecast } from "@/types/forecast"; 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 { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
export function getForecastConditionKey(code: number | null): TranslationKey { export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear"; if (code === 0) return "forecast.condition.clear";
@@ -18,22 +27,44 @@ export function getForecastCondition(code: number | null, language: Language) {
return translate(language, getForecastConditionKey(code)); return translate(language, getForecastConditionKey(code));
} }
export function formatForecastTemperature(value: number | null, language: Language) { function isDryWeatherCode(code: number | null) {
if (value === null) return "—"; return code === null || (code >= 0 && code <= 3) || code === 45 || code === 48;
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`;
} }
export function formatForecastRainfall(value: number | null, language: Language) { export function getEffectiveHourlyWeatherCode(
if (value === null) return "—"; hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">,
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; ) {
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 formatForecastWind(value: number | null, language: Language) { export function formatForecastTemperature(
value: number | null,
language: Language,
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
) {
if (value === null) return "—"; if (value === null) return "—";
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { return formatTemperature(value, language, unit);
minimumFractionDigits: 1, }
maximumFractionDigits: 1,
}).format(value)} m/s`; export function formatForecastRainfall(
value: number | null,
language: Language,
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
) {
if (value === null) return "—";
return formatPrecipitation(value, language, unit);
}
export function formatForecastWind(
value: number | null,
language: Language,
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
) {
if (value === null) return "—";
return formatWindSpeed(value, language, unit);
} }
function getWarsawForecastHour(date = new Date()) { function getWarsawForecastHour(date = new Date()) {
@@ -66,22 +97,24 @@ function getForecastHourOfDay(time: string) {
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) { export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
const currentHour = getWarsawForecastHour(); const currentHour = getWarsawForecastHour();
const currentTimestamp = parseForecastHour(currentHour); const currentTimestamp = parseForecastHour(currentHour);
const upcomingHours = currentTimestamp === null const upcomingHours =
? hours.filter((hour) => hour.time >= currentHour) currentTimestamp === null
: hours.filter((hour) => { ? hours.filter((hour) => hour.time >= currentHour)
const hourTimestamp = parseForecastHour(hour.time); : hours.filter((hour) => {
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp; const hourTimestamp = parseForecastHour(hour.time);
}); return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
});
if (upcomingHours.length) return upcomingHours.slice(0, limit); if (upcomingHours.length) return upcomingHours.slice(0, limit);
const currentHourOfDay = getForecastHourOfDay(currentHour); const currentHourOfDay = getForecastHourOfDay(currentHour);
const fallbackHours = currentHourOfDay === null const fallbackHours =
? hours currentHourOfDay === null
: hours.filter((hour) => { ? hours
const forecastHourOfDay = getForecastHourOfDay(hour.time); : hours.filter((hour) => {
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay; const forecastHourOfDay = getForecastHourOfDay(hour.time);
}); return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
});
return (fallbackHours.length ? fallbackHours : hours).slice(0, limit); return (fallbackHours.length ? fallbackHours : hours).slice(0, limit);
} }
@@ -90,6 +123,23 @@ export function getHourlyForecastForDay(hours: HourlyForecast[], date: string) {
return hours.filter((hour) => hour.time.startsWith(`${date}T`)); return hours.filter((hour) => hour.time.startsWith(`${date}T`));
} }
export function getDailyForecastForHour(days: DailyForecast[], time: string) {
return days.find((day) => time.startsWith(`${day.date}T`)) ?? null;
}
export function isForecastNight(time: string, day: Pick<DailyForecast, "sunrise" | "sunset"> | null | undefined) {
const hourTimestamp = parseForecastHour(time);
const sunriseTimestamp = day?.sunrise ? parseForecastHour(day.sunrise) : null;
const sunsetTimestamp = day?.sunset ? parseForecastHour(day.sunset) : null;
if (hourTimestamp !== null && sunriseTimestamp !== null && sunsetTimestamp !== null) {
return hourTimestamp < sunriseTimestamp || hourTimestamp >= sunsetTimestamp;
}
const hour = getForecastHourOfDay(time);
return hour !== null && (hour < 6 || hour >= 21);
}
export function isForecastHourPast(time: string) { export function isForecastHourPast(time: string) {
const currentHour = getWarsawForecastHour(); const currentHour = getWarsawForecastHour();
const hourTimestamp = parseForecastHour(time); const hourTimestamp = parseForecastHour(time);

View File

@@ -21,27 +21,93 @@ const translations = {
"theme.dark": "Włącz ciemny motyw", "theme.dark": "Włącz ciemny motyw",
"settings.section": "Preferencje", "settings.section": "Preferencje",
"settings.title": "Ustawienia", "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.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.title": "Motyw",
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.", "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.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.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.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.regionTitle": "Obszar alertów",
"settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.", "settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji", "settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
"settings.notifications.regionManual": "Wybierz województwo ręcznie", "settings.notifications.regionManual": "Wybierz województwo ręcznie",
"settings.notifications.noProvince": "brak wybranego województwa", "settings.notifications.noProvince": "brak wybranego województwa",
"settings.notifications.globalRegion": "lokalizacja poza Polską",
"settings.notifications.globalAlertsUnavailable":
"Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.",
"settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.deviceTitle": "To urządzenie",
"settings.notifications.enable": "Alerty IMGW", "settings.notifications.enable": "Alerty IMGW",
"settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", "settings.notifications.enableDescription": "Otrzymuj alerty dla wybranego obszaru.",
"settings.notifications.enableGlobalDescription": "Otrzymuj briefy dla wybranej lokalizacji.",
"settings.notifications.morningBrief": "Brief poranny o 7:00", "settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.", "settings.notifications.morningBriefDescription": "Krótkie podsumowanie pogody na dziś.",
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00", "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.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu", "settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.enabledStatus": "Włączone",
"settings.notifications.disabledStatus": "Wyłączone",
"settings.notifications.saving": "Zapisuję…", "settings.notifications.saving": "Zapisuję…",
"settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.", "settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.",
"settings.notifications.disableSuccess": "Powiadomienia są wyłączone dla tego urządzenia.", "settings.notifications.disableSuccess": "Powiadomienia są wyłączone dla tego urządzenia.",
@@ -52,13 +118,15 @@ const translations = {
"settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.", "settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.",
"settings.notifications.requestPermission": "Sprawdź zgodę", "settings.notifications.requestPermission": "Sprawdź zgodę",
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.", "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.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.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
"settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.", "settings.notifications.statusReady":
"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.", "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", "pwa.install": "Zainstaluj",
"common.noData": "Brak danych", "common.noData": "Brak danych",
"common.loading": "Ładowanie danych", "common.loading": "Ładowanie danych",
@@ -67,40 +135,51 @@ const translations = {
"error.description": "Sprawdź połączenie i spróbuj ponownie.", "error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.", "dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"brief.label": "Brief dnia", "brief.label": "Brief dnia",
"brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.", "brief.description": "Podsumowanie dla lokalizacji: {location}.",
"brief.tomorrowLabel": "Jutro", "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.error": "Nie udało się przygotować briefu dnia.",
"brief.emptyTitle": "Brak briefu", "brief.emptyTitle": "Brak briefu",
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.", "brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
"location.label": "Twoja lokalizacja", "location.label": "Twoja lokalizacja",
"location.searchLabel": "Szukaj miejscowości w Polsce", "location.searchLabel": "Szukaj miejscowości",
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…", "location.placeholder": "Wpisz miejscowość",
"location.clear": "Wyczyść wyszukiwanie", "location.clear": "Wyczyść wyszukiwanie",
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.", "location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…", "location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.", "location.empty": "Nie znaleziono pasującej miejscowości.",
"location.nearest": "Najbliższa stacja IMGW", "location.nearest": "Najbliższa stacja IMGW",
"location.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.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km", "location.heroHybridPartial":
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km", "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.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.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.",
"location.attribution": "Wyszukiwanie miejscowości:", "location.attribution": "Wyszukiwanie miejscowości:",
"location.gpsUse": "Użyj mojej lokalizacji", "location.gpsUse": "Użyj mojej lokalizacji",
"location.gpsLocating": "Ustalam lokalizację…", "location.gpsLocating": "Ustalam lokalizację…",
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?", "location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.", "location.gpsPromptDescription":
"Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.",
"location.gpsAllow": "Użyj GPS", "location.gpsAllow": "Użyj GPS",
"location.gpsNotNow": "Nie teraz", "location.gpsNotNow": "Nie teraz",
"location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.", "location.gpsSecureContext":
"GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.",
"location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.", "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.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.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.",
"location.gpsFallbackName": "Bieżąca lokalizacja", "location.gpsFallbackName": "Bieżąca lokalizacja",
"location.gpsSelected": "Wybrano lokalizację: {location}.", "location.gpsSelected": "Wybrano lokalizację: {location}.",
@@ -116,11 +195,13 @@ const translations = {
"weather.wind": "Wiatr", "weather.wind": "Wiatr",
"weather.rainfall": "Suma opadu", "weather.rainfall": "Suma opadu",
"weather.rainfall10m": "Opad 10 min", "weather.rainfall10m": "Opad 10 min",
"weather.currentPrecipitation": "Opad",
"weather.pressure": "Ciśnienie", "weather.pressure": "Ciśnienie",
"weather.feelsLike": "Odczuwalna", "weather.feelsLike": "Odczuwalna",
"weather.measurement": "pomiar", "weather.measurement": "pomiar",
"weather.modelUpdate": "aktualizacja modelu",
"weather.windDirection": "Kierunek wiatru", "weather.windDirection": "Kierunek wiatru",
"weather.calm": "Spokojne warunki", "weather.calm": "Pogodnie",
"weather.humid": "Wilgotno", "weather.humid": "Wilgotno",
"weather.strongWind": "Silny wiatr", "weather.strongWind": "Silny wiatr",
"weather.currentRain": "Opady deszczu", "weather.currentRain": "Opady deszczu",
@@ -138,7 +219,7 @@ const translations = {
"weather.temperatureDetail": "Temperatura powietrza", "weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa", "forecast.label": "Prognoza modelowa",
"forecast.title": "Najbliższe godziny i dni", "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.hourly": "Najbliższe 24 godziny",
"forecast.daily": "Prognoza 7-dniowa", "forecast.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj", "forecast.today": "Dzisiaj",
@@ -163,8 +244,8 @@ const translations = {
"forecast.maxProbability": "Maks. szansa opadu", "forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina", "forecast.pastHour": "Miniona godzina",
"forecast.source": "Źródło prognozy:", "forecast.source": "Źródło prognozy:",
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.", "forecast.sourceCombinedDescription": "IMGW ALARO i Open-Meteo.",
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.", "forecast.sourceFallbackDescription": "Open-Meteo.",
"forecast.error": "Nie udało się pobrać prognozy modelowej.", "forecast.error": "Nie udało się pobrać prognozy modelowej.",
"forecast.emptyTitle": "Brak prognozy", "forecast.emptyTitle": "Brak prognozy",
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.", "forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
@@ -181,7 +262,8 @@ const translations = {
"station.all": "Wszystkie stacje", "station.all": "Wszystkie stacje",
"station.label": "Stacja {name}", "station.label": "Stacja {name}",
"station.parameters": "Aktualne parametry", "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.error": "Nie udało się pobrać danych wybranej stacji IMGW.",
"station.quality": "Jakość danych", "station.quality": "Jakość danych",
"station.lastMeasurementImgw": "Ostatni pomiar IMGW", "station.lastMeasurementImgw": "Ostatni pomiar IMGW",
@@ -191,19 +273,24 @@ const translations = {
"station.publicApi": "Publiczne API IMGW", "station.publicApi": "Publiczne API IMGW",
"snapshot.label": "Snapshot pomiarowy", "snapshot.label": "Snapshot pomiarowy",
"snapshot.title": "Aktualne proporcje parametrów", "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.section": "Komunikaty IMGW",
"warnings.title": "Ostrzeżenia", "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.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.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.otherRegions": "Pozostałe regiony",
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.", "warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń", "warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski",
"warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia są obecnie dostępne tylko dla Polski.",
"warnings.drought": "Susza hydrologiczna", "warnings.drought": "Susza hydrologiczna",
"warnings.levelUnknown": "Poziom nieokreślony", "warnings.levelUnknown": "Poziom nieokreślony",
"warnings.level": "Stopień {level}", "warnings.level": "Stopień {level}",
@@ -224,7 +311,7 @@ const translations = {
"warnings.dashboard.viewAll": "Zobacz wszystkie", "warnings.dashboard.viewAll": "Zobacz wszystkie",
"hydro.section": "Monitoring wód IMGW", "hydro.section": "Monitoring wód IMGW",
"hydro.title": "Hydro", "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.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
"hydro.searchLabel": "Szukaj stacji hydrologicznej", "hydro.searchLabel": "Szukaj stacji hydrologicznej",
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…", "hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
@@ -237,8 +324,10 @@ const translations = {
"hydro.flow": "Przepływ", "hydro.flow": "Przepływ",
"hydro.levelMeasurement": "Pomiar poziomu: {date}", "hydro.levelMeasurement": "Pomiar poziomu: {date}",
"offline.title": "Brak połączenia", "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.back": "Wróć do aplikacji",
"offline.banner": "Brak połączenia · dane mogą być nieaktualne",
}, },
en: { en: {
"nav.weather": "Weather", "nav.weather": "Weather",
@@ -255,27 +344,91 @@ const translations = {
"theme.dark": "Enable dark theme", "theme.dark": "Enable dark theme",
"settings.section": "Preferences", "settings.section": "Preferences",
"settings.title": "Settings", "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.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.title": "Theme",
"settings.theme.description": "Switch the light or dark appearance on this device.", "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.label": "Choose temperature unit",
"settings.units.temperature.c": "°C",
"settings.units.temperature.f": "°F",
"settings.units.wind.title": "Wind unit",
"settings.units.wind.description": "Used in forecasts, briefs, notifications and current readings on this device.",
"settings.units.wind.label": "Choose wind unit",
"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.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.regionTitle": "Alert area",
"settings.notifications.regionDescription": "Current area for future notifications: {province}.", "settings.notifications.regionDescription": "Current area for future notifications: {province}.",
"settings.notifications.regionSelected": "Use the location selected in the app", "settings.notifications.regionSelected": "Use the location selected in the app",
"settings.notifications.regionManual": "Choose province manually", "settings.notifications.regionManual": "Choose province manually",
"settings.notifications.noProvince": "no province selected", "settings.notifications.noProvince": "no province selected",
"settings.notifications.globalRegion": "location outside Poland",
"settings.notifications.globalAlertsUnavailable":
"Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.",
"settings.notifications.deviceTitle": "This device", "settings.notifications.deviceTitle": "This device",
"settings.notifications.enable": "IMGW alerts", "settings.notifications.enable": "IMGW alerts",
"settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.", "settings.notifications.enableDescription": "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.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.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.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device", "settings.notifications.disable": "Disable on this device",
"settings.notifications.enabledStatus": "Enabled",
"settings.notifications.disabledStatus": "Disabled",
"settings.notifications.saving": "Saving…", "settings.notifications.saving": "Saving…",
"settings.notifications.saveSuccess": "Notifications are enabled for this device.", "settings.notifications.saveSuccess": "Notifications are enabled for this device.",
"settings.notifications.disableSuccess": "Notifications are disabled for this device.", "settings.notifications.disableSuccess": "Notifications are disabled for this device.",
@@ -286,13 +439,15 @@ const translations = {
"settings.notifications.testError": "Unable to send the test notification.", "settings.notifications.testError": "Unable to send the test notification.",
"settings.notifications.requestPermission": "Check permission", "settings.notifications.requestPermission": "Check permission",
"settings.notifications.statusChecking": "Checking notification support in this browser.", "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.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.statusDenied": "Notifications are blocked in system or browser settings.",
"settings.notifications.statusGranted": "This device has notification permission.", "settings.notifications.statusGranted": "This device has notification permission.",
"settings.notifications.statusReady": "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.", "settings.notifications.statusReady":
"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.", "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", "pwa.install": "Install",
"common.noData": "No data", "common.noData": "No data",
"common.loading": "Loading data", "common.loading": "Loading data",
@@ -301,40 +456,51 @@ const translations = {
"error.description": "Check your connection and try again.", "error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.", "dashboard.error": "Unable to load the IMGW synoptic station list.",
"brief.label": "Daily brief", "brief.label": "Daily brief",
"brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.", "brief.description": "Summary for: {location}.",
"brief.tomorrowLabel": "Tomorrow", "brief.tomorrowLabel": "Tomorrow",
"brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.", "brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.",
"brief.error": "Unable to prepare the daily brief.", "brief.error": "Unable to prepare the daily brief.",
"brief.emptyTitle": "Brief unavailable", "brief.emptyTitle": "Brief unavailable",
"brief.emptyDescription": "The forecast does not contain enough data for the next day.", "brief.emptyDescription": "The forecast does not contain enough data for the next day.",
"location.label": "Your location", "location.label": "Your location",
"location.searchLabel": "Search places in Poland", "location.searchLabel": "Search places",
"location.placeholder": "Enter a place, e.g. Piaseczno…", "location.placeholder": "Enter a place",
"location.clear": "Clear search", "location.clear": "Clear search",
"location.error": "Unable to search for places. Try again.", "location.error": "Unable to search for places. Try again.",
"location.preparing": "Preparing the nearest IMGW stations…", "location.preparing": "Preparing the nearest IMGW stations…",
"location.empty": "No matching place was found in Poland.", "location.empty": "No matching place was found.",
"location.nearest": "Nearest IMGW station", "location.nearest": "Nearest IMGW station",
"location.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.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "location.currentSourceGlobal":
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away", "{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away", "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.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.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
"location.attribution": "Place search:", "location.attribution": "Place search:",
"location.gpsUse": "Use my location", "location.gpsUse": "Use my location",
"location.gpsLocating": "Finding your location…", "location.gpsLocating": "Finding your location…",
"location.gpsPromptTitle": "Show weather for your location?", "location.gpsPromptTitle": "Show weather for your location?",
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.", "location.gpsPromptDescription":
"With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.",
"location.gpsAllow": "Use GPS", "location.gpsAllow": "Use GPS",
"location.gpsNotNow": "Not now", "location.gpsNotNow": "Not now",
"location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.", "location.gpsSecureContext":
"GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.",
"location.gpsUnavailable": "This browser does not provide GPS location access.", "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.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.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.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.",
"location.gpsFallbackName": "Current location", "location.gpsFallbackName": "Current location",
"location.gpsSelected": "Selected location: {location}.", "location.gpsSelected": "Selected location: {location}.",
@@ -350,11 +516,13 @@ const translations = {
"weather.wind": "Wind", "weather.wind": "Wind",
"weather.rainfall": "Rainfall total", "weather.rainfall": "Rainfall total",
"weather.rainfall10m": "Rainfall 10 min", "weather.rainfall10m": "Rainfall 10 min",
"weather.currentPrecipitation": "Precipitation",
"weather.pressure": "Pressure", "weather.pressure": "Pressure",
"weather.feelsLike": "Feels like", "weather.feelsLike": "Feels like",
"weather.measurement": "measurement", "weather.measurement": "measurement",
"weather.modelUpdate": "model update",
"weather.windDirection": "Wind direction", "weather.windDirection": "Wind direction",
"weather.calm": "Calm conditions", "weather.calm": "Fair",
"weather.humid": "Humid", "weather.humid": "Humid",
"weather.strongWind": "Strong wind", "weather.strongWind": "Strong wind",
"weather.currentRain": "Rain", "weather.currentRain": "Rain",
@@ -368,11 +536,12 @@ const translations = {
"weather.pressureDetail": "Atmospheric pressure", "weather.pressureDetail": "Atmospheric pressure",
"weather.windSpeedDetail": "Current IMGW reading", "weather.windSpeedDetail": "Current IMGW reading",
"weather.windDirectionDetail": "Direction the wind is coming from", "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", "weather.temperatureDetail": "Air temperature",
"forecast.label": "Model forecast", "forecast.label": "Model forecast",
"forecast.title": "Upcoming hours and days", "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.hourly": "Next 24 hours",
"forecast.daily": "7-day forecast", "forecast.daily": "7-day forecast",
"forecast.today": "Today", "forecast.today": "Today",
@@ -397,8 +566,8 @@ const translations = {
"forecast.maxProbability": "Max. rain chance", "forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour", "forecast.pastHour": "Past hour",
"forecast.source": "Forecast source:", "forecast.source": "Forecast source:",
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.", "forecast.sourceCombinedDescription": "IMGW ALARO and Open-Meteo.",
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.", "forecast.sourceFallbackDescription": "Open-Meteo.",
"forecast.error": "Unable to load the model forecast.", "forecast.error": "Unable to load the model forecast.",
"forecast.emptyTitle": "Forecast unavailable", "forecast.emptyTitle": "Forecast unavailable",
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.", "forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
@@ -415,7 +584,8 @@ const translations = {
"station.all": "All stations", "station.all": "All stations",
"station.label": "Station {name}", "station.label": "Station {name}",
"station.parameters": "Current parameters", "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.error": "Unable to load data for the selected IMGW station.",
"station.quality": "Data details", "station.quality": "Data details",
"station.lastMeasurementImgw": "Latest IMGW measurement", "station.lastMeasurementImgw": "Latest IMGW measurement",
@@ -425,12 +595,14 @@ const translations = {
"station.publicApi": "Public IMGW API", "station.publicApi": "Public IMGW API",
"snapshot.label": "Measurement snapshot", "snapshot.label": "Measurement snapshot",
"snapshot.title": "Current parameter proportions", "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.section": "IMGW notices",
"warnings.title": "Warnings", "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.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.myProvinceEmptyTitle": "No warnings for your area",
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.", "warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
"warnings.otherRegions": "Other regions", "warnings.otherRegions": "Other regions",
@@ -438,6 +610,8 @@ const translations = {
"warnings.error": "Unable to load meteorological or hydrological warnings.", "warnings.error": "Unable to load meteorological or hydrological warnings.",
"warnings.emptyTitle": "No active warnings", "warnings.emptyTitle": "No active warnings",
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.", "warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
"warnings.globalUnavailableTitle": "Official warnings only for Poland",
"warnings.globalUnavailableDescription": "Official warnings are currently available only for Poland.",
"warnings.drought": "Hydrological drought", "warnings.drought": "Hydrological drought",
"warnings.levelUnknown": "Level not specified", "warnings.levelUnknown": "Level not specified",
"warnings.level": "Level {level}", "warnings.level": "Level {level}",
@@ -458,7 +632,7 @@ const translations = {
"warnings.dashboard.viewAll": "View all", "warnings.dashboard.viewAll": "View all",
"hydro.section": "IMGW water monitoring", "hydro.section": "IMGW water monitoring",
"hydro.title": "Hydro", "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.error": "Unable to load IMGW hydrological stations.",
"hydro.searchLabel": "Search hydrological stations", "hydro.searchLabel": "Search hydrological stations",
"hydro.searchPlaceholder": "Search by station, river or province…", "hydro.searchPlaceholder": "Search by station, river or province…",
@@ -471,8 +645,10 @@ const translations = {
"hydro.flow": "Flow", "hydro.flow": "Flow",
"hydro.levelMeasurement": "Level measurement: {date}", "hydro.levelMeasurement": "Level measurement: {date}",
"offline.title": "No connection", "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.back": "Back to the app",
"offline.banner": "Offline · data may be out of date",
}, },
} as const; } as const;
@@ -513,12 +689,15 @@ export function I18nProvider({ children }: PropsWithChildren) {
setLanguageState(nextLanguage); setLanguageState(nextLanguage);
}, []); }, []);
const value = useMemo<I18nContextValue>(() => ({ const value = useMemo<I18nContextValue>(
language, () => ({
locale: language === "pl" ? "pl-PL" : "en-GB", language,
setLanguage, locale: language === "pl" ? "pl-PL" : "en-GB",
t: (key, params) => translate(language, key, params), setLanguage,
}), [language, setLanguage]); t: (key, params) => translate(language, key, params),
}),
[language, setLanguage],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
} }

View File

@@ -1,8 +1,4 @@
import { import { normalizeHydroStation, normalizeSynopStation, normalizeWarning } from "@/lib/weather-utils";
normalizeHydroStation,
normalizeSynopStation,
normalizeWarning,
} from "@/lib/weather-utils";
import type { import type {
HydroStation, HydroStation,
MeteoStationPosition, MeteoStationPosition,
@@ -66,7 +62,7 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
fetchWarningsByKind("meteo", signal), fetchWarningsByKind("meteo", signal),
fetchWarningsByKind("hydro", 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")) { if (results.every((result) => result.status === "rejected")) {
throw new Error("Nie udało się pobrać ostrzeżeń IMGW."); throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
} }

View File

@@ -1,5 +1,6 @@
import { toNumber } from "@/lib/weather-utils"; import { toNumber } from "@/lib/weather-utils";
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current"; import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
import type { WeatherRegion } from "@/types/weather-region";
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000; const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
@@ -52,19 +53,27 @@ function hasNumericValue(value: unknown) {
} }
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) { function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Temperature) return (
&& hasNumericValue(candidate.Chill) hasNumericValue(candidate.Temperature) &&
&& hasNumericValue(candidate.Humidity) hasNumericValue(candidate.Chill) &&
&& hasNumericValue(candidate.Wind_Speed) hasNumericValue(candidate.Humidity) &&
&& hasNumericValue(candidate.PressureMSL); hasNumericValue(candidate.Wind_Speed) &&
hasNumericValue(candidate.PressureMSL)
);
} }
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) { 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[]) { 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)); const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
if (!tenMinuteRow) return hourlyRow ?? null; if (!tenMinuteRow) return hourlyRow ?? null;
if (!hourlyRow) return tenMinuteRow; if (!hourlyRow) return tenMinuteRow;
@@ -73,25 +82,26 @@ function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) { function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow; 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 (!precipitationRows.length) return null;
if (!fullRow) return precipitationRows[0]; if (!fullRow) return precipitationRows[0];
return precipitationRows.reduce((best, candidate) => ( return precipitationRows.reduce((best, candidate) =>
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best,
)); );
} }
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const rows = payload.data.Data const rows = payload.data.Data.flatMap((candidate): NormalizedHybridRow[] => {
.flatMap((candidate): NormalizedHybridRow[] => { if (!candidate || typeof candidate !== "object") return [];
if (!candidate || typeof candidate !== "object") return []; const row = candidate as RawImgwHybridWeatherRow;
const row = candidate as RawImgwHybridWeatherRow; if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return []; const normalizedRow = normalizeHybridRow(row);
const normalizedRow = normalizeHybridRow(row); return normalizedRow ? [normalizedRow] : [];
return normalizedRow ? [normalizedRow] : []; });
});
const fullRow = pickFullWeatherRow(rows); const fullRow = pickFullWeatherRow(rows);
const precipitationRow = pickPrecipitationRow(rows, fullRow); const precipitationRow = pickPrecipitationRow(rows, fullRow);
const row = fullRow ?? precipitationRow; const row = fullRow ?? precipitationRow;
@@ -104,6 +114,12 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
return { return {
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only", coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
region: "PL",
source: "IMGW",
sourceLabel: "IMGW Hybrid",
sourceType: fullRow ? "hybrid" : "model",
isOfficial: true,
fetchedAt: new Date().toISOString(),
measuredAt: row.measuredAt, measuredAt: row.measuredAt,
temperature: toCelsius(row.row.Temperature), temperature: toCelsius(row.row.Temperature),
feelsLike: toCelsius(row.row.Chill), feelsLike: toCelsius(row.row.Chill),
@@ -124,5 +140,17 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
const response = await fetch(`/api/imgw-current?${params}`, { signal }); 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."); 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;
} }

View File

@@ -1,9 +1,11 @@
export function isImgwNoProductsResponse(value: unknown) { export function isImgwNoProductsResponse(value: unknown) {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const response = value as { status?: unknown; message?: unknown }; const response = value as { status?: unknown; message?: unknown };
return response.status === false return (
&& typeof response.message === "string" response.status === false &&
&& response.message.toLocaleLowerCase("en-US").includes("no products were found"); typeof response.message === "string" &&
response.message.toLocaleLowerCase("en-US").includes("no products were found")
);
} }
export async function readImgwResponseBody(response: Response) { export async function readImgwResponseBody(response: Response) {

View File

@@ -1,14 +1,22 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { LocationSearchResult, ReverseLocationResult } from "@/types/location"; 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 params = new URLSearchParams({ query, language });
const response = await fetch(`/api/locations/search?${params}`, { signal }); const response = await fetch(`/api/locations/search?${params}`, { signal });
if (!response.ok) throw new Error("Location search is temporarily unavailable."); if (!response.ok) throw new Error("Location search is temporarily unavailable.");
return response.json() as Promise<LocationSearchResult[]>; 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 params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language });
const response = await fetch(`/api/locations/reverse?${params}`); const response = await fetch(`/api/locations/reverse?${params}`);
if (!response.ok) throw new Error("Reverse location search is temporarily unavailable."); if (!response.ok) throw new Error("Reverse location search is temporarily unavailable.");

View File

@@ -1,6 +1,7 @@
import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { LocationSearchResult, SelectedLocation } from "@/types/location"; import type { LocationSearchResult, SelectedLocation } from "@/types/location";
import { getCountyTerytForLocation } from "@/lib/warning-regions"; import { getCountyTerytForLocation } from "@/lib/warning-regions";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const stationAliases: Record<string, string> = { const stationAliases: Record<string, string> = {
gdansk: "gdanskrebiechowo", gdansk: "gdanskrebiechowo",
@@ -29,10 +30,11 @@ function normalizeName(value: string) {
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) { function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
const earthRadiusKm = 6371; 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 latitudeDistance = toRadians(latitudeB - latitudeA);
const longitudeDistance = toRadians(longitudeB - longitudeA); 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; 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)); return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
@@ -64,6 +66,12 @@ export function findNearestSynopStation(
name: location.name, name: location.name,
province: location.province, province: location.province,
district: location.district, district: location.district,
country: "Polska",
countryCode: "PL",
admin1: location.province,
admin2: location.district,
timezone: "Europe/Warsaw",
region: "PL",
countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name), countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name),
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
@@ -72,3 +80,50 @@ export function findNearestSynopStation(
distanceKm: Math.round(nearest.distanceKm), distanceKm: Math.round(nearest.distanceKm),
}; };
} }
export function createSelectedLocation(
location: Pick<
LocationSearchResult,
| "name"
| "province"
| "district"
| "country"
| "countryCode"
| "admin1"
| "admin2"
| "timezone"
| "region"
| "latitude"
| "longitude"
>,
stations: LocatedSynopStation[],
): SelectedLocation {
const countryCode = location.countryCode?.toUpperCase() ?? null;
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
const nearest =
region === "PL"
? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
}, null)
: null;
return {
name: location.name,
province: location.province,
district: location.district,
country: location.country,
countryCode,
admin1: location.admin1,
admin2: location.admin2,
timezone: location.timezone,
region,
countyTeryt:
region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null,
latitude: location.latitude,
longitude: location.longitude,
stationId: nearest?.station.id ?? null,
stationName: nearest?.station.name ?? null,
distanceKm: nearest ? Math.round(nearest.distanceKm) : null,
};
}

View File

@@ -1,5 +1,7 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import type { WeatherRegion } from "@/types/weather-region";
interface VapidKeyResponse { interface VapidKeyResponse {
configured: boolean; configured: boolean;
@@ -13,15 +15,28 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
} }
interface SavePushSubscriptionOptions { interface SavePushSubscriptionOptions {
region?: WeatherRegion;
officialWarningsEnabled?: boolean;
morningBriefEnabled?: boolean; morningBriefEnabled?: boolean;
tomorrowBriefEnabled?: boolean; tomorrowBriefEnabled?: boolean;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
locationName?: string | null; locationName?: string | null;
timezone?: string | null;
countyTeryt?: string | null; countyTeryt?: string | null;
temperatureUnit?: TemperatureUnit;
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", { const response = await fetch("/api/notifications/subscriptions", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -29,13 +44,20 @@ export async function savePushSubscription(subscription: PushSubscription, provi
subscription: subscription.toJSON(), subscription: subscription.toJSON(),
province, province,
language, language,
enabled: true, region: options.region ?? "PL",
enabled: options.officialWarningsEnabled ?? true,
morningBriefEnabled: options.morningBriefEnabled === true, morningBriefEnabled: options.morningBriefEnabled === true,
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true, tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
latitude: options.latitude ?? null, latitude: options.latitude ?? null,
longitude: options.longitude ?? null, longitude: options.longitude ?? null,
locationName: options.locationName ?? null, locationName: options.locationName ?? null,
timezone: options.timezone ?? null,
countyTeryt: options.countyTeryt ?? null, countyTeryt: options.countyTeryt ?? null,
temperatureUnit: options.temperatureUnit,
windSpeedUnit: options.windSpeedUnit,
precipitationUnit: options.precipitationUnit,
pressureUnit: options.pressureUnit,
distanceUnit: options.distanceUnit,
}), }),
}); });
if (!response.ok) throw new Error("Unable to save push subscription."); if (!response.ok) throw new Error("Unable to save push subscription.");

View File

@@ -85,22 +85,22 @@ const provinceByStationId: Record<string, Province> = {
}; };
const provinceLabels: Record<Province, Record<Language, string>> = { 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" }, "kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" },
"lubelskie": { pl: "lubelskie", en: "Lublin" }, lubelskie: { pl: "lubelskie", en: "Lublin" },
"lubuskie": { pl: "lubuskie", en: "Lubusz" }, lubuskie: { pl: "lubuskie", en: "Lubusz" },
"łódzkie": { pl: "łódzkie", en: "Łódź" }, łódzkie: { pl: "łódzkie", en: "Łódź" },
"małopolskie": { pl: "małopolskie", en: "Lesser Poland" }, małopolskie: { pl: "małopolskie", en: "Lesser Poland" },
"mazowieckie": { pl: "mazowieckie", en: "Masovian" }, mazowieckie: { pl: "mazowieckie", en: "Masovian" },
"opolskie": { pl: "opolskie", en: "Opole" }, opolskie: { pl: "opolskie", en: "Opole" },
"podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" }, podkarpackie: { pl: "podkarpackie", en: "Subcarpathian" },
"podlaskie": { pl: "podlaskie", en: "Podlaskie" }, podlaskie: { pl: "podlaskie", en: "Podlaskie" },
"pomorskie": { pl: "pomorskie", en: "Pomeranian" }, pomorskie: { pl: "pomorskie", en: "Pomeranian" },
ląskie": { pl: "śląskie", en: "Silesian" }, śląskie: { pl: "śląskie", en: "Silesian" },
więtokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" }, świętokrzyskie: { pl: "świętokrzyskie", en: "Świętokrzyskie" },
"warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" }, "warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" },
"wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" }, wielkopolskie: { pl: "wielkopolskie", en: "Greater Poland" },
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" }, zachodniopomorskie: { pl: "zachodniopomorskie", en: "West Pomeranian" },
}; };
export const PROVINCES = Object.keys(provinceLabels) as Province[]; export const PROVINCES = Object.keys(provinceLabels) as Province[];
@@ -123,11 +123,11 @@ export function getProvinceFromTeryt(code: string) {
} }
export function getProvinceForStation(stationId: string | null) { 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) { 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) { export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) {

View File

@@ -1,9 +1,9 @@
import webPush, { type PushSubscription } from "web-push"; import webPush, { type PushSubscription } from "web-push";
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import { formatProvinceName } from "@/lib/provinces"; import { formatProvinceName } from "@/lib/provinces";
import type { WeatherBrief } from "@/lib/weather-brief";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
import type { WarningPushSubscription } from "@/types/notifications"; import type { WarningPushSubscription } from "@/types/notifications";
import type { WeatherBrief } from "@/lib/weather-brief";
const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY; const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY; const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY;
@@ -49,13 +49,20 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) {
} }
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) { function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
const province = formatProvinceName(preference.province, preference.language); const region = preference.province
? formatProvinceName(preference.province, preference.language)
: (preference.locationName ?? "wtr.");
const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning"); const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
const validity = formatWarningValidity(warning, preference.language); const validity = formatWarningValidity(warning, preference.language);
const level = getWarningLevelLabel(warning, preference.language); const level = getWarningLevelLabel(warning, preference.language);
const bodyParts = preference.language === "pl" const bodyParts =
? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null] preference.language === "pl"
: [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null]; ? [
`${region}: ${level}`,
validity,
warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null,
]
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
return { return {
title: `IMGW: ${title}`, title: `IMGW: ${title}`,
@@ -73,12 +80,15 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
export async function sendTestNotification(preference: WarningPushSubscription) { export async function sendTestNotification(preference: WarningPushSubscription) {
ensureWebPushConfigured(); ensureWebPushConfigured();
const province = formatProvinceName(preference.province, preference.language); const location = preference.province
? formatProvinceName(preference.province, preference.language)
: (preference.locationName ?? "Open-Meteo");
const payload = JSON.stringify({ const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: test powiadomień IMGW" : "wtr.: IMGW notification test", title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
body: preference.language === "pl" body:
? `Powiadomienia są aktywne dla województwa: ${province}.` preference.language === "pl"
: `Notifications are active for: ${province}.`, ? `Powiadomienia są aktywne dla: ${location}.`
: `Notifications are active for: ${location}.`,
url: "/settings", url: "/settings",
}); });
await webPush.sendNotification(preference.subscription as PushSubscription, payload); await webPush.sendNotification(preference.subscription as PushSubscription, payload);

View File

@@ -1,83 +1,347 @@
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
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 { WarningPushSubscription } from "@/types/notifications";
import type { Province } from "@/types/province";
import type { WeatherRegion } from "@/types/weather-region";
interface PushStore { interface PushDatabase {
subscriptions: Map<string, WarningPushSubscription>; database: Database.Database;
sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>;
sentTomorrowBriefDates: Map<string, Set<string>>;
} }
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore }; interface PushSubscriptionRow {
endpoint: string;
subscription_json: string;
province: string;
region?: string;
language: string;
enabled: number;
morning_brief_enabled: number;
tomorrow_brief_enabled: number;
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;
}
function getStore() { const globalStore = globalThis as typeof globalThis & { __wtrPushDatabase?: PushDatabase };
if (!globalStore.__wtrPushStore) {
globalStore.__wtrPushStore = { function getDatabasePath() {
subscriptions: new Map(), const configuredPath = process.env.WTR_DATABASE_PATH;
sentWarningIds: new Map(), if (configuredPath) {
sentMorningBriefDates: new Map(), return path.isAbsolute(configuredPath)
sentTomorrowBriefDates: new Map(), ? configuredPath
}; : path.join(/* turbopackIgnore: true */ process.cwd(), configuredPath);
}
return path.join(/* turbopackIgnore: true */ process.cwd(), "data", "wtr.sqlite");
}
function ensureDatabaseDirectory(databasePath: string) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
}
function initializeDatabase(database: Database.Database) {
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
database.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
subscription_json 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,
tomorrow_brief_enabled INTEGER NOT NULL DEFAULT 0,
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
);
CREATE TABLE IF NOT EXISTS sent_warnings (
endpoint TEXT NOT NULL,
warning_id TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, warning_id),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_morning_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_tomorrow_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
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() {
if (!globalStore.__wtrPushDatabase) {
const databasePath = getDatabasePath();
ensureDatabaseDirectory(databasePath);
const database = new Database(databasePath);
initializeDatabase(database);
globalStore.__wtrPushDatabase = { database };
}
return globalStore.__wtrPushDatabase.database;
}
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 === "global" ? null : (row.province as Province),
region,
language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1,
morningBriefEnabled: row.morning_brief_enabled === 1,
tomorrowBriefEnabled: row.tomorrow_brief_enabled === 1,
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,
};
} catch {
return null;
} }
return globalStore.__wtrPushStore;
} }
export function upsertPushSubscription(subscription: WarningPushSubscription) { export function upsertPushSubscription(subscription: WarningPushSubscription) {
getStore().subscriptions.set(subscription.endpoint, subscription); getDatabase()
.prepare(
`
INSERT INTO push_subscriptions (
endpoint,
subscription_json,
province,
region,
language,
enabled,
morning_brief_enabled,
tomorrow_brief_enabled,
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,
@tomorrowBriefEnabled,
@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,
tomorrow_brief_enabled = excluded.tomorrow_brief_enabled,
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({
endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription),
province: subscription.province ?? "global",
region: subscription.region,
language: subscription.language,
enabled: subscription.enabled ? 1 : 0,
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
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,
});
} }
export function removePushSubscription(endpoint: string) { export function removePushSubscription(endpoint: string) {
const store = getStore(); getDatabase().prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint);
store.sentTomorrowBriefDates.delete(endpoint);
} }
export function getPushSubscriptions() { export function getPushSubscriptions() {
return Array.from(getStore().subscriptions.values()); return getDatabase()
.prepare("SELECT * FROM push_subscriptions ORDER BY created_at ASC")
.all()
.map((row) => parseSubscription(row as PushSubscriptionRow))
.filter((subscription): subscription is WarningPushSubscription => subscription !== null);
} }
export function getPushSubscription(endpoint: string) { export function getPushSubscription(endpoint: string) {
return getStore().subscriptions.get(endpoint) ?? null; const row = getDatabase().prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?").get(endpoint);
return row ? parseSubscription(row as PushSubscriptionRow) : null;
} }
export function hasSentWarning(endpoint: string, warningId: string) { export function hasSentWarning(endpoint: string, warningId: string) {
return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_warnings WHERE endpoint = ? AND warning_id = ?")
.get(endpoint, warningId);
return Boolean(row);
} }
export function markWarningSent(endpoint: string, warningId: string) { export function markWarningSent(endpoint: string, warningId: string) {
const store = getStore(); getDatabase()
const sentIds = store.sentWarningIds.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_warnings (endpoint, warning_id, sent_at) VALUES (?, ?, ?)")
sentIds.add(warningId); .run(endpoint, warningId, new Date().toISOString());
store.sentWarningIds.set(endpoint, sentIds);
} }
export function pruneSentWarnings(activeWarningIds: Set<string>) { export function pruneSentWarnings(activeWarningIds: Set<string>) {
getStore().sentWarningIds.forEach((sentIds) => { const database = getDatabase();
sentIds.forEach((warningId) => { if (!activeWarningIds.size) {
if (!activeWarningIds.has(warningId)) sentIds.delete(warningId); database.prepare("DELETE FROM sent_warnings").run();
}); return;
}); }
database.transaction((warningIds: string[]) => {
const placeholders = warningIds.map(() => "?").join(", ");
database.prepare(`DELETE FROM sent_warnings WHERE warning_id NOT IN (${placeholders})`).run(...warningIds);
})(Array.from(activeWarningIds));
} }
export function hasSentMorningBrief(endpoint: string, dateKey: string) { export function hasSentMorningBrief(endpoint: string, dateKey: string) {
return getStore().sentMorningBriefDates.get(endpoint)?.has(dateKey) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_morning_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
} }
export function markMorningBriefSent(endpoint: string, dateKey: string) { export function markMorningBriefSent(endpoint: string, dateKey: string) {
const store = getStore(); getDatabase()
const sentDates = store.sentMorningBriefDates.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_morning_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
sentDates.add(dateKey); .run(endpoint, dateKey, new Date().toISOString());
store.sentMorningBriefDates.set(endpoint, sentDates);
} }
export function hasSentTomorrowBrief(endpoint: string, dateKey: string) { export function hasSentTomorrowBrief(endpoint: string, dateKey: string) {
return getStore().sentTomorrowBriefDates.get(endpoint)?.has(dateKey) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_tomorrow_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
} }
export function markTomorrowBriefSent(endpoint: string, dateKey: string) { export function markTomorrowBriefSent(endpoint: string, dateKey: string) {
const store = getStore(); getDatabase()
const sentDates = store.sentTomorrowBriefDates.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_tomorrow_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
sentDates.add(dateKey); .run(endpoint, dateKey, new Date().toISOString());
store.sentTomorrowBriefDates.set(endpoint, sentDates);
} }

View 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);
}

View File

@@ -1,5 +1,6 @@
import { mergeForecastSources } from "@/lib/forecast-merge"; import { mergeForecastSources } from "@/lib/forecast-merge";
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast"; import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
import type { WeatherRegion } from "@/types/weather-region";
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"; const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi"; const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
@@ -17,19 +18,20 @@ export function parseForecastCoordinate(value: string | null, min: number, max:
async function readImgwPayload(response: Response | null) { async function readImgwPayload(response: Response | null) {
if (!response?.ok) return null; if (!response?.ok) return null;
try { try {
return await response.json() as RawImgwForecastResponse; return (await response.json()) as RawImgwForecastResponse;
} catch { } catch {
return null; 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({ const openMeteoParams = new URLSearchParams({
latitude: String(latitude), latitude: String(latitude),
longitude: String(longitude), longitude: String(longitude),
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m", hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset", daily:
timezone: "Europe/Warsaw", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
timezone: region === "PL" ? "Europe/Warsaw" : "auto",
forecast_days: "7", forecast_days: "7",
wind_speed_unit: "ms", wind_speed_unit: "ms",
}); });
@@ -41,12 +43,20 @@ export async function fetchServerForecast(latitude: number, longitude: number) {
}); });
const [openMeteoResponse, imgwResponse] = await Promise.all([ const [openMeteoResponse, imgwResponse] = await Promise.all([
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }), fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, {
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null), 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."); if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable.");
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast; const openMeteoPayload = (await openMeteoResponse.json()) as RawWeatherForecast;
const imgwPayload = await readImgwPayload(imgwResponse); const imgwPayload = await readImgwPayload(imgwResponse);
return mergeForecastSources(openMeteoPayload, imgwPayload); return mergeForecastSources(openMeteoPayload, imgwPayload, region);
} }

View File

@@ -14,8 +14,10 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherW
if (!response.ok) { if (!response.ok) {
const details = await readImgwResponseBody(response); const details = await readImgwResponseBody(response);
if (response.status === 404 && isImgwNoProductsResponse(details.json)) return []; 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)) : []; return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];
} }

View File

@@ -4,6 +4,36 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { SelectedLocation } from "@/types/location"; import type { SelectedLocation } from "@/types/location";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
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"; type NotificationProvinceMode = "selected" | "manual";
@@ -16,6 +46,14 @@ interface WeatherStore {
tomorrowBriefNotificationsEnabled: boolean; tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null; warningNotificationProvince: Province | null;
temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit;
precipitationUnit: PrecipitationUnit;
pressureUnit: PressureUnit;
distanceUnit: DistanceUnit;
dashboardSections: DashboardSectionVisibility;
dashboardSectionOrder: DashboardSectionOrder;
appSections: AppSectionVisibility;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
selectStation: (id: string) => void; selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void; selectLocation: (location: SelectedLocation) => void;
@@ -24,6 +62,14 @@ interface WeatherStore {
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void; setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void; setWarningNotificationProvince: (province: Province | null) => void;
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>()( export const useWeatherStore = create<WeatherStore>()(
@@ -37,6 +83,14 @@ export const useWeatherStore = create<WeatherStore>()(
tomorrowBriefNotificationsEnabled: false, tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected", warningNotificationProvinceMode: "selected",
warningNotificationProvince: null, 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) => toggleFavorite: (id) =>
set((state) => ({ set((state) => ({
favorites: state.favorites.includes(id) favorites: state.favorites.includes(id)
@@ -50,7 +104,74 @@ export const useWeatherStore = create<WeatherStore>()(
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }), setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
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 };
}),
setAppSectionVisible: (section, visible) =>
set((state) => ({
appSections: { ...state.appSections, [section]: visible },
})),
}), }),
{ name: "wtr:preferences" }, {
name: "wtr:preferences",
migrate: (persisted) => {
const state = persisted as Partial<WeatherStore> | undefined;
const location = state?.selectedLocation as
| (Partial<SelectedLocation> & { region?: WeatherRegion })
| null
| undefined;
if (!state) 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,
},
};
},
},
), ),
); );

View File

@@ -50,19 +50,21 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
}; };
function normalizeRegionName(value: string | null | undefined) { function normalizeRegionName(value: string | null | undefined) {
return value return (
?.replace(/[Łł]/g, "l") value
.normalize("NFD") ?.replace(/[Łł]/g, "l")
.replace(/[\u0300-\u036f]/g, "") .normalize("NFD")
.toLocaleLowerCase("pl-PL") .replace(/[\u0300-\u036f]/g, "")
.replace(/^powiat\s+/, "") .toLocaleLowerCase("pl-PL")
.replace(/^m\.\s*/, "") .replace(/^powiat\s+/, "")
.replace(/^miasto\s+/, "") .replace(/^m\.\s*/, "")
.replace(/\s+powiat$/, "") .replace(/^miasto\s+/, "")
.replace(/\s+/g, "_") .replace(/\s+powiat$/, "")
.replace(/[^a-z0-9_]/g, "") .replace(/\s+/g, "_")
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni") .replace(/[^a-z0-9_]/g, "")
.trim() ?? ""; .replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
.trim() ?? ""
);
} }
function normalizeTerytCountyCode(value: string) { function normalizeTerytCountyCode(value: string) {
@@ -70,7 +72,11 @@ function normalizeTerytCountyCode(value: string) {
return /^\d{4}/.test(code) ? code.slice(0, 4) : null; 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); const normalizedProvince = getProvinceForSelection(province, null);
if (normalizedProvince !== "mazowieckie") return null; if (normalizedProvince !== "mazowieckie") return null;
@@ -79,7 +85,7 @@ export function getCountyTerytForLocation(province: string | null | undefined, d
if (districtMatch) return districtMatch; if (districtMatch) return districtMatch;
const normalizedLocation = normalizeRegionName(locationName); 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) { 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); 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) { if (warning.kind === "meteo" && selectedLocation?.countyTeryt) {
return warningMatchesCounty(warning, selectedLocation.countyTeryt); return warningMatchesCounty(warning, selectedLocation.countyTeryt);
} }

View File

@@ -5,6 +5,15 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import { warningMatchesCounty } from "@/lib/warning-regions"; import { warningMatchesCounty } from "@/lib/warning-regions";
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"; export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -24,31 +33,12 @@ interface BuildWeatherBriefInput {
countyTeryt?: string | null; countyTeryt?: string | null;
locationName: string; locationName: string;
language: Language; language: Language;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
precipitationUnit?: PrecipitationUnit;
now?: Date; 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 formatTemperature(value: number, language: Language) {
return `${formatNumber(value, language)}°C`;
}
function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
}
function formatWindKmh(value: number, language: Language) {
return `${formatNumber(value * 3.6, language)} km/h`;
}
function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`;
}
function formatHour(value: string) { function formatHour(value: string) {
return value.slice(11, 16); return value.slice(11, 16);
} }
@@ -82,14 +72,17 @@ function getWarsawDateKey(date: Date, dayOffset = 0) {
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`; 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", { const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw", timeZone: "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}).formatToParts(shiftedDate); }).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? ""; const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`; return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
} }
@@ -106,7 +99,9 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
const currentTimestamp = parseForecastHour(currentHour); const currentTimestamp = parseForecastHour(currentHour);
const upcoming = hours.filter((hour) => { const upcoming = hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time); 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); return upcoming.slice(0, limit);
} }
@@ -143,11 +138,18 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
}, null); }, 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); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return (
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) warning.kind === "meteo" &&
&& (validTo === null || validTo > now); (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
(validTo === null || validTo > now)
);
} }
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) { function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
@@ -156,10 +158,17 @@ function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey); return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
} }
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) { function isRelevantMeteoWarningForDate(
return warning.kind === "meteo" warning: WeatherWarning,
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) province: Province | null,
&& warningOverlapsWarsawDate(warning, dateKey); countyTeryt: string | null | undefined,
dateKey: string,
) {
return (
warning.kind === "meteo" &&
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
warningOverlapsWarsawDate(warning, dateKey)
);
} }
function compareWarnings(a: WeatherWarning, b: WeatherWarning) { function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
@@ -172,7 +181,12 @@ function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => bo
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode)); 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 hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86); const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82)); const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
@@ -207,7 +221,18 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation"; return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
} }
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { export function buildWeatherBrief({
forecast,
warnings,
province,
countyTeryt,
locationName,
language,
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); const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null; if (!hours.length) return null;
@@ -222,12 +247,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability); const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null; const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null const temperatureRange =
? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}` minimumTemperature !== null && maximumTemperature !== null
: null; ? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
: null;
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5; const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
const hasWindSignal = (maximumWind ?? 0) >= 8; 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 provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning const headline = topWarning
@@ -249,45 +279,71 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const body: string[] = []; const body: string[] = [];
if (topWarning) { if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : ""; const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null const probability =
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.` topWarning.probability !== null
: ""; ? language === "pl"
body.push(language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.`
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}` : ` Probability: ${topWarning.probability}%.`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`); : "";
body.push(
language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
} }
if (temperatureRange) { if (temperatureRange) {
body.push(language === "pl" body.push(
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.` language === "pl"
: `Temperature over the next 24 hours: ${temperatureRange}.`); ? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
: `Temperature over the next 24 hours: ${temperatureRange}.`,
);
} }
if (rainfallTotal !== null || maximumProbability !== null) { if (rainfallTotal !== null || maximumProbability !== null) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20 const rainWindow =
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.` rainPeakHour && maximumProbability !== null && maximumProbability >= 20
: ""; ? language === "pl"
body.push(language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: `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) { if (maximumWind !== null) {
body.push(language === "pl" body.push(
? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.` language === "pl"
: `Wind up to ${formatWind(maximumWind, language)}.`); ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
} }
if (conditionPeakHour) { if (conditionPeakHour) {
body.push(language === "pl" body.push(
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.` language === "pl"
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`); ? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`,
);
} }
const pushParts = [ const pushParts = [
`${locationName}:`, `${locationName}:`,
temperatureRange, temperatureRange,
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null, maximumProbability !== null
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : 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); ].filter(Boolean);
const warningPrefix = topWarning 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 { return {
@@ -300,7 +356,18 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
}; };
} }
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, 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 targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey); const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null; if (!hours.length) return null;
@@ -316,16 +383,19 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability); const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95); const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal = hasThunderstorm const hasRainSignal =
|| (rainfallTotal ?? 0) >= 0.5 hasThunderstorm ||
|| (maximumProbability ?? 0) >= 35 (rainfallTotal ?? 0) >= 0.5 ||
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82)); (maximumProbability ?? 0) >= 35 ||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasWindSignal = (maximumWind ?? 0) >= 8; const hasWindSignal = (maximumWind ?? 0) >= 8;
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language); const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null const temperatureRange =
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}` minimumTemperature !== null && maximumTemperature !== null
: null; ? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal"; : null;
const severity: WeatherBriefSeverity =
topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null; const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning const headline = topWarning
@@ -351,49 +421,66 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const body: string[] = []; const body: string[] = [];
if (topWarning) { if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : ""; const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null const probability =
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.` topWarning.probability !== null
: ""; ? language === "pl"
body.push(language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.`
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}` : ` Probability: ${topWarning.probability}%.`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`); : "";
body.push(
language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
} }
if (temperatureRange) { if (temperatureRange) {
body.push(language === "pl" body.push(
? `Temperatura jutro: ${temperatureRange}.` language === "pl" ? `Temperatura jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
: `Temperature tomorrow: ${temperatureRange}.`); );
} }
body.push(language === "pl" body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
? `Sygnał modelu: ${condition}.`
: `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) { if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20 const rainWindow =
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.` rainPeakHour && maximumProbability !== null && maximumProbability >= 20
: ""; ? language === "pl"
body.push(language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: `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 { } else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast."); body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
} }
if (maximumWind !== null) { if (maximumWind !== null) {
body.push(language === "pl" body.push(
? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.` language === "pl"
: `Wind up to ${formatWindKmh(maximumWind, language)}.`); ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
} }
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null const rainPushPart =
? formatRainfall(rainfallTotal, language) (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
: null; ? formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)
: null;
const pushParts = [ const pushParts = [
`${locationName}:`, `${locationName}:`,
temperatureRange, temperatureRange,
condition, condition,
rainPushPart, rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(maximumWind, language)}`) : null, maximumWind !== null
? language === "pl"
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: null,
].filter(Boolean); ].filter(Boolean);
const warningPrefix = topWarning 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 { return {

View File

@@ -11,8 +11,50 @@ import type {
import { translate, type Language } from "@/lib/i18n"; import { translate, type Language } from "@/lib/i18n";
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces"; import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
import type { CurrentWeatherCondition } from "@/types/imgw-current"; import type { CurrentWeatherCondition } from "@/types/imgw-current";
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" }; 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", "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",
f: "°F",
};
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 { export function toNumber(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "number") return Number.isFinite(value) ? value : null;
@@ -69,10 +111,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null
} }
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning { export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
const provinces = [...new Set([ const provinces = [
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)), ...new Set(
...(raw.teryt ?? []).map(getProvinceFromTeryt), [
].filter((province) => province !== null))]; ...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
...(raw.teryt ?? []).map(getProvinceFromTeryt),
].filter((province) => province !== null),
),
];
const describedAreas = (raw.obszary ?? []) const describedAreas = (raw.obszary ?? [])
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim()) .map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
.filter((area): area is string => Boolean(area)); .filter((area): area is string => Boolean(area));
@@ -90,32 +136,201 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
publishedAt: normalizeDate(raw.opublikowano), publishedAt: normalizeDate(raw.opublikowano),
probability: toNumber(raw.prawdopodobienstwo), probability: toNumber(raw.prawdopodobienstwo),
areas, 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, provinces,
office: raw.biuro?.trim() || null, office: raw.biuro?.trim() || null,
}; };
} }
export function formatTemperature(value: number | null, language: Language = "pl") { export function isTemperatureUnit(value: unknown): value is TemperatureUnit {
return value === null ? translate(language, "common.noData") : `${Math.round(value)}°`; return typeof value === "string" && TEMPERATURE_UNITS.includes(value as TemperatureUnit);
} }
export function formatPressure(value: number | null, language: Language = "pl") { export function getTemperatureUnitLabel(unit: TemperatureUnit) {
return value === null ? translate(language, "common.noData") : `${value.toFixed(1)} hPa`; return temperatureUnitLabels[unit];
}
export function convertTemperature(value: number, unit: TemperatureUnit) {
if (unit === "f") return (value * 9) / 5 + 32;
return value;
}
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 formatHumidity(value: number | null, language: Language = "pl") { export function formatHumidity(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`; return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
} }
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl") { export function isWindSpeedUnit(value: unknown): value is WindSpeedUnit {
if (speed === null) return translate(language, "common.noData"); return typeof value === "string" && WIND_SPEED_UNITS.includes(value as WindSpeedUnit);
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
return `${speed.toFixed(1)} m/s${directionLabel}`;
} }
export function formatRainfall(value: number | null, language: Language = "pl") { export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`; 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 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], {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(convertedSpeed);
return `${formattedSpeed} ${getWindSpeedUnitLabel(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",
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") { export function formatWaterLevel(value: number | null, language: Language = "pl") {
@@ -126,7 +341,11 @@ export function formatFlow(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`; 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; if (!value) return fallback;
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback; if (Number.isNaN(date.getTime())) return fallback;
@@ -154,9 +373,17 @@ export function calculateFeelsLike(temperature: number | null, humidity: number
const c7 = 0.002211732; const c7 = 0.002211732;
const c8 = 0.00072546; const c8 = 0.00072546;
const c9 = -0.000003582; const c9 = -0.000003582;
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 + return (
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 + c1 +
c9 * temperature ** 2 * humidity ** 2; 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; return temperature;
} }
@@ -176,11 +403,50 @@ export function getWeatherMoodFromData(station: SynopStation, date = new Date())
return "mild"; return "mild";
} }
export function getWeatherDescription(station: SynopStation, language: Language = "pl", condition?: CurrentWeatherCondition) { function getForecastConditionDescription(code: number | null, language: Language) {
if (code === 0) return translate(language, "forecast.condition.clear");
if (code === 1 || code === 2) return translate(language, "forecast.condition.partlyCloudy");
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 >= 95) return translate(language, "forecast.condition.thunderstorm");
return null;
}
function getCloudCoverDescription(cloudCover: number | null | undefined, language: Language) {
if (cloudCover === null || cloudCover === undefined) return null;
if (cloudCover >= 75) return translate(language, "forecast.condition.cloudy");
if (cloudCover >= 25) return translate(language, "forecast.condition.partlyCloudy");
return null;
}
export function getWeatherDescription(
station: SynopStation,
language: Language = "pl",
condition?: CurrentWeatherCondition,
currentWeatherCode?: number | null,
currentCloudCover?: number | null,
forecastWeatherCode?: number | null,
) {
if (condition === "thunderstorm") return translate(language, "weather.thunderstorm"); if (condition === "thunderstorm") return translate(language, "weather.thunderstorm");
if (condition === "snow") return translate(language, "weather.currentSnow"); if (condition === "snow") return translate(language, "weather.currentSnow");
if (condition === "rain") return translate(language, "weather.currentRain"); if (condition === "rain") return translate(language, "weather.currentRain");
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind"); 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 (currentDescription) return currentDescription;
if (cloudCoverDescription) return cloudCoverDescription;
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);
if (forecastDescription) return forecastDescription;
if ((station.humidity ?? 0) >= 90) return translate(language, "weather.humid"); if ((station.humidity ?? 0) >= 90) return translate(language, "weather.humid");
return translate(language, "weather.calm"); return translate(language, "weather.calm");
} }

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.0.174"], allowedDevOrigins: ["192.168.0.174"],
reactStrictMode: true, reactStrictMode: true,
serverExternalPackages: ["better-sqlite3"],
}; };
export default nextConfig; export default nextConfig;

1492
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,16 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "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" "notifications:worker": "node scripts/notification-worker.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",
"better-sqlite3": "^12.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.0.0", "framer-motion": "^12.0.0",
@@ -24,6 +30,7 @@
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
@@ -32,7 +39,9 @@
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"vitest": "^4.1.8"
} }
} }

View File

@@ -1,42 +1,136 @@
const CACHE_NAME = "wtr-shell-v4"; const CACHE_VERSION = "v6";
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_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) => { 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.skipWaiting();
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( 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.clients.claim();
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
const url = new URL(event.request.url); const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") { if (url.origin !== self.location.origin) return;
event.respondWith(
fetch(event.request) if (isWeatherApiRequest(url)) {
.then((response) => { event.respondWith(networkFirst(event.request, API_CACHE));
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request)),
);
return; return;
} }
if (event.request.mode === "navigate") { 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) => { self.addEventListener("push", (event) => {
const fallbackPayload = { const fallbackPayload = {
title: "IMGW", title: "IMGW",
body: "Nowe ostrzeżenie meteorologiczne.", body: "Nowe ostrze\u017cenie meteorologiczne.",
url: "/warnings", url: "/warnings",
}; };
let payload = fallbackPayload; let payload = fallbackPayload;

View File

@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
const DEFAULT_APP_URL = "http://127.0.0.1:3000"; const DEFAULT_APP_URL = "http://127.0.0.1:3000";
const DEFAULT_WARNING_INTERVAL_MINUTES = 5; const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
const DEFAULT_BRIEF_INTERVAL_MINUTES = 5;
const DEFAULT_MORNING_BRIEF_TIME = "07:00"; const DEFAULT_MORNING_BRIEF_TIME = "07:00";
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00"; const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
const LOOP_INTERVAL_MS = 30_000; const LOOP_INTERVAL_MS = 30_000;
@@ -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 appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? ""; const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES); const warningIntervalMinutes = readPositiveNumber(
const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME); process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES,
const tomorrowBriefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME); 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 lastWarningCheckAt = 0;
let lastMorningBriefDate = ""; let lastMorningBriefCheckAt = 0;
let lastTomorrowBriefDate = ""; let lastTomorrowBriefCheckAt = 0;
let isRunningTick = false; let isRunningTick = false;
function loadEnvFile(path, overrideLoadedValues) { function loadEnvFile(path, overrideLoadedValues) {
@@ -50,27 +64,6 @@ function normalizeTime(value, fallback) {
return /^\d{2}:\d{2}$/.test(value) ? value : fallback; return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
} }
function getWarsawDateParts(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
return {
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
time: `${part("hour")}:${part("minute")}`,
};
}
function isAtOrAfterTime(currentTime, targetTime) {
return currentTime >= targetTime;
}
async function callNotificationEndpoint(path) { async function callNotificationEndpoint(path) {
const response = await fetch(`${appUrl}${path}`, { const response = await fetch(`${appUrl}${path}`, {
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {}, headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
@@ -106,6 +99,7 @@ async function tick() {
isRunningTick = true; isRunningTick = true;
const now = Date.now(); const now = Date.now();
const warningIntervalMs = warningIntervalMinutes * 60_000; const warningIntervalMs = warningIntervalMinutes * 60_000;
const briefIntervalMs = briefIntervalMinutes * 60_000;
try { try {
if (now - lastWarningCheckAt >= warningIntervalMs) { if (now - lastWarningCheckAt >= warningIntervalMs) {
@@ -113,15 +107,14 @@ async function tick() {
await callNotificationEndpointSafely("/api/notifications/check"); await callNotificationEndpointSafely("/api/notifications/check");
} }
const warsawTime = getWarsawDateParts(); if (now - lastMorningBriefCheckAt >= briefIntervalMs) {
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) { lastMorningBriefCheckAt = now;
const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief"); await callNotificationEndpointSafely("/api/notifications/daily-brief");
if (sentBrief) lastMorningBriefDate = warsawTime.dateKey;
} }
if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) { if (now - lastTomorrowBriefCheckAt >= briefIntervalMs) {
const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief"); lastTomorrowBriefCheckAt = now;
if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey; await callNotificationEndpointSafely("/api/notifications/tomorrow-brief");
} }
} catch (error) { } catch (error) {
console.error(formatWorkerError(error)); console.error(formatWorkerError(error));
@@ -131,6 +124,8 @@ async function tick() {
} }
console.log(`wtr. notification worker: ${appUrl}`); console.log(`wtr. notification worker: ${appUrl}`);
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, tomorrow brief at ${tomorrowBriefTime} Europe/Warsaw.`); console.log(
`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`,
);
void tick(); void tick();
setInterval(tick, LOOP_INTERVAL_MS); setInterval(tick, LOOP_INTERVAL_MS);

View File

@@ -2,12 +2,7 @@ import type { Config } from "tailwindcss";
export default { export default {
darkMode: ["class"], darkMode: ["class"],
content: [ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./hooks/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {

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

View 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");
});
});

View 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
View 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");
});
});

View 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);
});
});

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,19 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", ".next/dev/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@@ -65,8 +65,12 @@ export interface DailyForecast {
export interface WeatherForecast { export interface WeatherForecast {
latitude: number; latitude: number;
longitude: number; longitude: number;
region: WeatherRegion;
timezone: string; timezone: string;
hourly: HourlyForecast[]; hourly: HourlyForecast[];
daily: DailyForecast[]; daily: DailyForecast[];
sources: ForecastSource[]; sources: ForecastSource[];
capabilities: WeatherCapabilities;
unavailableReasons: string[];
} }
import type { WeatherCapabilities, WeatherRegion } from "@/types/weather-region";

View File

@@ -23,10 +23,11 @@ export interface RawImgwHybridWeatherResponse {
} }
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only"; export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only" | "global-model";
export interface ImgwCurrentWeather { export interface ImgwCurrentWeather extends WeatherSourceMetadata {
coverage: ImgwCurrentWeatherCoverage; coverage: ImgwCurrentWeatherCoverage;
region: WeatherRegion;
measuredAt: string; measuredAt: string;
temperature: number | null; temperature: number | null;
feelsLike: number | null; feelsLike: number | null;
@@ -41,3 +42,4 @@ export interface ImgwCurrentWeather {
weatherCode: number | null; weatherCode: number | null;
condition: CurrentWeatherCondition; condition: CurrentWeatherCondition;
} }
import type { WeatherRegion, WeatherSourceMetadata } from "@/types/weather-region";

View File

@@ -81,7 +81,7 @@ export interface RawWarning {
id?: string | null; id?: string | null;
opublikowano?: string | null; opublikowano?: string | null;
stopien?: string | null; stopien?: string | null;
"stopień"?: string | null; stopień?: string | null;
nazwa_zdarzenia?: string | null; nazwa_zdarzenia?: string | null;
data_od?: string | null; data_od?: string | null;
data_do?: string | null; data_do?: string | null;

View File

@@ -1,3 +1,5 @@
import type { WeatherRegion } from "@/types/weather-region";
export interface LocationSearchResult { export interface LocationSearchResult {
id: number; id: number;
name: string; name: string;
@@ -5,22 +7,40 @@ export interface LocationSearchResult {
longitude: number; longitude: number;
province: string | null; province: string | null;
district: string | null; district: string | null;
country: string | null;
countryCode: string | null;
admin1: string | null;
admin2: string | null;
timezone: string | null;
region: WeatherRegion;
} }
export interface ReverseLocationResult { export interface ReverseLocationResult {
name: string; name: string;
province: string | null; province: string | null;
district: string | null; district: string | null;
country: string | null;
countryCode: string | null;
admin1: string | null;
admin2: string | null;
timezone: string | null;
region: WeatherRegion;
} }
export interface SelectedLocation { export interface SelectedLocation {
name: string; name: string;
province: string | null; province: string | null;
district: string | null; district: string | null;
country: string | null;
countryCode: string | null;
admin1: string | null;
admin2: string | null;
timezone: string | null;
region: WeatherRegion;
countyTeryt: string | null; countyTeryt: string | null;
latitude: number; latitude: number;
longitude: number; longitude: number;
stationId: string; stationId: string | null;
stationName: string; stationName: string | null;
distanceKm: number; distanceKm: number | null;
} }

View File

@@ -1,5 +1,7 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import type { WeatherRegion } from "@/types/weather-region";
export interface PushSubscriptionKeys { export interface PushSubscriptionKeys {
p256dh: string; p256dh: string;
@@ -15,7 +17,8 @@ export interface BrowserPushSubscription {
export interface WarningPushSubscription { export interface WarningPushSubscription {
endpoint: string; endpoint: string;
subscription: BrowserPushSubscription; subscription: BrowserPushSubscription;
province: Province; province: Province | null;
region: WeatherRegion;
language: Language; language: Language;
enabled: boolean; enabled: boolean;
morningBriefEnabled: boolean; morningBriefEnabled: boolean;
@@ -23,7 +26,13 @@ export interface WarningPushSubscription {
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
locationName: string | null; locationName: string | null;
timezone: string | null;
countyTeryt: string | null; countyTeryt: string | null;
temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit;
precipitationUnit: PrecipitationUnit;
pressureUnit: PressureUnit;
distanceUnit: DistanceUnit;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

5
types/units.ts Normal file
View File

@@ -0,0 +1,5 @@
export type TemperatureUnit = "c" | "f";
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";

Some files were not shown because too many files have changed in this diff Show More