Compare commits
18 Commits
22b8969379
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b9de13360 | |||
| 747868867f | |||
| a4f02d711a | |||
| 1eff0c258b | |||
| 4e66fb549a | |||
| cdce40e69c | |||
| 9395659f07 | |||
| 2aaa93e03f | |||
| f5bd719a0f | |||
| 7dcfc47375 | |||
| 99282c5280 | |||
| 5c150a193b | |||
| 0502c3d7d4 | |||
| b97a1cf1ea | |||
| ad4248efdf | |||
| e832d4e63b | |||
| 93c9b40931 | |||
| fe73bc23ef |
41
AGENTS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Projekt
|
## Projekt
|
||||||
|
|
||||||
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową 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 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.
|
||||||
|
|
||||||
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
||||||
|
|
||||||
@@ -36,10 +36,15 @@ 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ę Open-Meteo pokazuj oddzielnie jako prognozę modelową. 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ń. 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.
|
||||||
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
||||||
|
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
||||||
|
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
||||||
|
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||||
- `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`; 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`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
||||||
|
- Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
||||||
|
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
||||||
- 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.
|
||||||
@@ -55,6 +60,36 @@ Obsługa błędów:
|
|||||||
- Fetchery w `lib/` rzucają `Error`, a komponenty prezentują czytelny stan błędu i retry.
|
- Fetchery w `lib/` rzucają `Error`, a komponenty prezentują czytelny stan błędu i retry.
|
||||||
- Projekt nie ma warstwy logowania aplikacyjnego. Nie dodawaj przypadkowych `console.log`.
|
- Projekt nie ma warstwy logowania aplikacyjnego. Nie dodawaj przypadkowych `console.log`.
|
||||||
|
|
||||||
|
## Zmiany frontendowe
|
||||||
|
|
||||||
|
To jest istniejąca aplikacja produktowa. Nie przebudowuj frontendu od zera, jeśli celem jest poprawa wyglądu. Zachowuj routing, przepływ danych, strukturę komponentów i logikę biznesową. Restyle powinien najpierw przechodzić przez globalne style, theme, tokeny, layout oraz komponenty współdzielone, a dopiero później przez pojedyncze widoki.
|
||||||
|
|
||||||
|
Docelowy kierunek wizualny:
|
||||||
|
|
||||||
|
- spokojny, praktyczny i produktowy;
|
||||||
|
- nowoczesny, ale bez efektów modnych na siłę;
|
||||||
|
- neutralna baza kolorystyczna i jeden konsekwentny kolor akcentu;
|
||||||
|
- czytelna hierarchia, przewidywalne odstępy i spójne karty;
|
||||||
|
- wygląd nudny w dobrym sensie: dopracowany, czytelny, bez dekoracyjnego szumu.
|
||||||
|
|
||||||
|
Unikaj:
|
||||||
|
|
||||||
|
- neonowych kolorów, fioletowo-cyjanowych gradientów i świecących cieni;
|
||||||
|
- losowych dekoracyjnych blobów, nadmiaru blurów i efektów bez funkcji;
|
||||||
|
- przesadnie dużych zaokrągleń oraz niespójnych radiusów między sekcjami;
|
||||||
|
- generycznych sekcji typu SaaS landing page;
|
||||||
|
- arbitralnych kolorów `bg-[#...]`, `text-[#...]`, `border-[#...]` w komponentach;
|
||||||
|
- lokalnych wyjątków stylistycznych, które powodują inny wygląd `/`, `/warnings`, `/hydro` i modali.
|
||||||
|
|
||||||
|
Przy zmianach UI:
|
||||||
|
|
||||||
|
- nie dodawaj nowych zależności bez wyraźnego powodu;
|
||||||
|
- nie zmieniaj UX ani przepływu interakcji, chyba że problem jest oczywisty i opiszesz go przed zmianą;
|
||||||
|
- preferuj tokeny Tailwind/theme, klasy użytkowe z `app/globals.css`, `Card`, `Button` i komponenty współdzielone;
|
||||||
|
- ogranicz zakres do najmniejszego zestawu plików potrzebnych do poprawy spójności;
|
||||||
|
- po zmianie sprawdź, czy nie pojawiły się hardcodowane kolory, nowe gradienty, neonowe akcenty, niespójne karty ani zmiany logiki biznesowej;
|
||||||
|
- sprawdź responsywność co najmniej mentalnie dla mobile, tablet i desktop, a przy większych zmianach uruchom aplikację lokalnie.
|
||||||
|
|
||||||
## Instrukcje dla agenta
|
## Instrukcje dla agenta
|
||||||
|
|
||||||
- Przed zmianą przeczytaj pliki w obszarze funkcji i sprawdź `git status`.
|
- Przed zmianą przeczytaj pliki w obszarze funkcji i sprawdź `git status`.
|
||||||
|
|||||||
49
README.md
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
||||||
|
|
||||||
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżące odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
|
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
|
||||||
|
|
||||||
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
||||||
|
|
||||||
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames. Bieżące warunki są analizowane lokalnie przez IMGW Hybrid, a najbliższa rzeczywista stacja IMGW jest pokazywana jako kontekst i fallback. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
||||||
|
|
||||||
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
||||||
|
|
||||||
Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej.
|
Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego województwa. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ npm run start
|
|||||||
|
|
||||||
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
||||||
|
|
||||||
|
- bieżąca analiza IMGW Hybrid używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi`
|
||||||
|
- prognoza godzinowa IMGW ALARO używana przez oficjalny portal: `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/`
|
||||||
@@ -54,27 +56,60 @@ Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW
|
|||||||
- 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`
|
||||||
|
|
||||||
Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.open-meteo.com/v1/forecast`. Jest prezentowana oddzielnie od bieżących pomiarów IMGW i podpisana w interfejsie jako prognoza modelowa.
|
Prognoza modelowa łączy dwa źródła. IMGW ALARO dostarcza dostępne godziny prognozy, zwykle około 72 godzin od cyklu modelu. Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza prawdopodobieństwo opadu dla całego zakresu, uzupełnia dalszy horyzont do pełnych 7 dni i pozostaje fallbackiem, jeśli ALARO chwilowo nie odpowiada. Interfejs pokazuje oba źródła i ich role.
|
||||||
|
|
||||||
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
||||||
|
|
||||||
Opcjonalny reverse geocoding dla GPS korzysta z publicznego endpointu Nominatim: `https://nominatim.openstreetmap.org/reverse`. Wywołanie następuje wyłącznie po zgodzie użytkownika. Interfejs pokazuje atrybucję OpenStreetMap. Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użycia publicznej instancji Nominatim lub zastąpić ją własną usługą.
|
Opcjonalny reverse geocoding dla GPS korzysta z publicznego endpointu Nominatim: `https://nominatim.openstreetmap.org/reverse`. Wywołanie następuje wyłącznie po zgodzie użytkownika. Interfejs pokazuje atrybucję OpenStreetMap. Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użycia publicznej instancji Nominatim lub zastąpić ją własną usługą.
|
||||||
|
|
||||||
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`.
|
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Bieżącą analizę pogody obsługuje `app/api/imgw-current/route.ts`, prognozę `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`.
|
||||||
|
|
||||||
## Ograniczenia API
|
## Ograniczenia API
|
||||||
|
|
||||||
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
|
||||||
|
|
||||||
|
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
||||||
|
|
||||||
Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu.
|
Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu.
|
||||||
|
|
||||||
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
|
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
|
||||||
|
|
||||||
|
## Stany pogody i efekty wizualne
|
||||||
|
|
||||||
|
Prognoza godzinowa i dzienna rozpoznaje następujące stany warunków pogodowych:
|
||||||
|
|
||||||
|
| Stan | Opis w interfejsie |
|
||||||
|
| --- | --- |
|
||||||
|
| `clear` | Bezchmurnie |
|
||||||
|
| `partlyCloudy` | Częściowe zachmurzenie |
|
||||||
|
| `cloudy` | Pochmurno |
|
||||||
|
| `fog` | Mgła |
|
||||||
|
| `drizzle` | Mżawka |
|
||||||
|
| `rain` | Opady deszczu |
|
||||||
|
| `snow` | Opady śniegu |
|
||||||
|
| `thunderstorm` | Burza |
|
||||||
|
| `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`.
|
||||||
|
|
||||||
|
Hero aktualnej pogody korzysta z uproszczonego nastroju wizualnego do wyboru ikony, tekstu i małego akcentu stanu. Nie steruje już pełnoekranowym gradientem.
|
||||||
|
|
||||||
|
| Mood | Obecna reguła |
|
||||||
|
| --- | --- |
|
||||||
|
| `night` | godzina przed `06:00` lub od `21:00` |
|
||||||
|
| `wind` | wiatr od `8 m/s` |
|
||||||
|
| `cold` | temperatura do `3°C` |
|
||||||
|
| `cloudy` | wilgotność od `80%` |
|
||||||
|
| `warm` | temperatura od `20°C` |
|
||||||
|
| `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 obecnie heurystyką opartą o porę dnia, temperaturę, wilgotność i wiatr. Nie jest jeszcze pełną klasyfikacją sterowaną kodem warunków IMGW Hybrid.
|
||||||
|
|
||||||
## Struktura projektu
|
## Struktura projektu
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/ routing, layout, proxy danych, offline fallback
|
app/ routing, layout, proxy danych, offline fallback
|
||||||
components/forecast/ prognoza godzinowa i dzienna Open-Meteo
|
components/forecast/ prognoza godzinowa i dzienna IMGW ALARO + Open-Meteo
|
||||||
components/charts/ wykresy odczytów i szczegółów prognozy
|
components/charts/ wykresy odczytów i szczegółów prognozy
|
||||||
components/dashboard dashboard aplikacji
|
components/dashboard dashboard aplikacji
|
||||||
components/weather/ hero, stacje, metryki i szczegóły
|
components/weather/ hero, stacje, metryki i szczegóły
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { mergeForecastSources } from "@/lib/forecast-merge";
|
||||||
|
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
|
||||||
|
|
||||||
const 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";
|
||||||
|
// This browser token is published by the official meteo.imgw.pl frontend.
|
||||||
|
const IMGW_FORECAST_TOKEN = "p4DXKjsYadfBV21TYrDk";
|
||||||
|
const OPEN_METEO_TIMEOUT_MS = 12_000;
|
||||||
|
const IMGW_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
function parseCoordinate(value: string | null, min: number, max: number) {
|
function parseCoordinate(value: string | null, min: number, max: number) {
|
||||||
if (!value?.trim()) return null;
|
if (!value?.trim()) return null;
|
||||||
@@ -8,6 +15,15 @@ function parseCoordinate(value: string | null, min: number, max: number) {
|
|||||||
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readImgwPayload(response: Response | null) {
|
||||||
|
if (!response?.ok) return null;
|
||||||
|
try {
|
||||||
|
return await response.json() as RawImgwForecastResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||||
@@ -16,7 +32,7 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = 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",
|
||||||
@@ -25,11 +41,22 @@ export async function GET(request: Request) {
|
|||||||
forecast_days: "7",
|
forecast_days: "7",
|
||||||
wind_speed_unit: "ms",
|
wind_speed_unit: "ms",
|
||||||
});
|
});
|
||||||
|
const imgwParams = new URLSearchParams({
|
||||||
|
token: IMGW_FORECAST_TOKEN,
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
m: "alaro",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${FORECAST_URL}?${params}`, { next: { revalidate: 900 } });
|
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||||
if (!response.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
||||||
return NextResponse.json(await response.json(), {
|
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!openMeteoResponse.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
||||||
|
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
||||||
|
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||||
|
return NextResponse.json(mergeForecastSources(openMeteoPayload, imgwPayload), {
|
||||||
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
37
app/api/imgw-current/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (latitude === null || longitude === null) {
|
||||||
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: IMGW_HYBRID_TOKEN,
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
m: "hybrid",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } });
|
||||||
|
if (!response.ok) return NextResponse.json({ error: "IMGW Hybrid service is unavailable." }, { status: 502 });
|
||||||
|
return NextResponse.json(await response.json(), {
|
||||||
|
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "IMGW Hybrid service is unavailable." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,40 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
--background: 210 32% 95%;
|
||||||
|
--foreground: 214 35% 16%;
|
||||||
|
--surface: 210 35% 99%;
|
||||||
|
--surface-muted: 210 26% 92%;
|
||||||
|
--surface-raised: 0 0% 100%;
|
||||||
|
--border: 214 20% 82%;
|
||||||
|
--muted: 215 14% 43%;
|
||||||
|
--accent: 207 48% 34%;
|
||||||
|
--warning: 38 58% 42%;
|
||||||
|
--chart-temperature: 207 48% 36%;
|
||||||
|
--chart-feels-like: 216 24% 48%;
|
||||||
|
--chart-rainfall: 202 38% 45%;
|
||||||
|
--chart-probability: 214 28% 38%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
--background: 220 22% 12%;
|
||||||
|
--foreground: 210 31% 94%;
|
||||||
|
--surface: 220 18% 15%;
|
||||||
|
--surface-muted: 220 15% 18%;
|
||||||
|
--surface-raised: 220 16% 17%;
|
||||||
|
--border: 220 12% 30%;
|
||||||
|
--muted: 214 15% 70%;
|
||||||
|
--accent: 207 34% 64%;
|
||||||
|
--warning: 39 64% 63%;
|
||||||
|
--chart-temperature: 204 44% 66%;
|
||||||
|
--chart-feels-like: 216 22% 73%;
|
||||||
|
--chart-rainfall: 202 42% 68%;
|
||||||
|
--chart-probability: 214 26% 76%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: hsl(var(--border) / 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -21,14 +47,14 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #eef5fb;
|
background: hsl(var(--background));
|
||||||
color: #102238;
|
color: hsl(var(--foreground));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
.dark body {
|
||||||
background: #07111f;
|
background: hsl(var(--background));
|
||||||
color: #edf7ff;
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -40,11 +66,19 @@ select {
|
|||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.glass {
|
.glass {
|
||||||
@apply border border-white/35 bg-white/45 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30;
|
@apply border border-border/70 bg-surface shadow-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-subtle {
|
.glass-subtle {
|
||||||
@apply border border-white/25 bg-white/25 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/20;
|
@apply border border-border/70 bg-surface-muted shadow-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-control {
|
||||||
|
@apply border border-border/70 bg-surface shadow-soft dark:bg-surface-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
@apply text-xs font-semibold uppercase tracking-[0.16em] text-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-balance {
|
.text-balance {
|
||||||
@@ -54,11 +88,18 @@ select {
|
|||||||
.weather-scrollbar {
|
.weather-scrollbar {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: -1px 0 0;
|
||||||
|
min-height: calc(100dvh + 1px);
|
||||||
|
background: hsl(var(--foreground) / 0.55);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (-moz-appearance: none) {
|
@supports (-moz-appearance: none) {
|
||||||
.weather-scrollbar {
|
.weather-scrollbar {
|
||||||
scrollbar-color: rgba(8, 145, 178, 0.72) rgba(14, 116, 144, 0.1);
|
scrollbar-color: hsl(var(--accent) / 0.62) hsl(var(--border) / 0.32);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,28 +111,27 @@ select {
|
|||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-track {
|
.weather-scrollbar::-webkit-scrollbar-track {
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: rgba(14, 116, 144, 0.1);
|
background: hsl(var(--border) / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-thumb {
|
.weather-scrollbar::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid hsl(var(--surface) / 0.78);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: linear-gradient(90deg, rgba(8, 145, 178, 0.78), rgba(14, 116, 144, 0.88));
|
background: hsl(var(--accent) / 0.68);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
box-shadow: 0 1px 5px rgba(8, 47, 73, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-thumb:hover {
|
.weather-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(90deg, rgba(6, 182, 212, 0.9), rgba(3, 105, 161, 0.95));
|
background: hsl(var(--accent) / 0.82);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .weather-scrollbar::-webkit-scrollbar-track {
|
.dark .weather-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: hsl(var(--border) / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .weather-scrollbar::-webkit-scrollbar-thumb {
|
.dark .weather-scrollbar::-webkit-scrollbar-thumb {
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: hsl(var(--surface) / 0.72);
|
||||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.72), rgba(56, 189, 248, 0.82));
|
background: hsl(var(--accent) / 0.7);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { Providers } from "@/components/layout/providers";
|
import { Providers } from "@/components/layout/providers";
|
||||||
|
import { APP_THEME_COLORS } from "@/lib/theme";
|
||||||
import "@/app/globals.css";
|
import "@/app/globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin", "latin-ext"], variable: "--font-inter" });
|
const inter = Inter({ subsets: ["latin", "latin-ext"], variable: "--font-inter" });
|
||||||
@@ -36,8 +37,8 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
themeColor: [
|
themeColor: [
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#e8f4fb" },
|
{ media: "(prefers-color-scheme: light)", color: APP_THEME_COLORS.light },
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#07111f" },
|
{ media: "(prefers-color-scheme: dark)", color: APP_THEME_COLORS.dark },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
export default function OfflinePage() {
|
export default function OfflinePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] 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-full bg-sky-500/10 text-sky-700 dark:text-sky-300"><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-slate-600 dark:text-slate-300">{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-full bg-slate-950 px-4 py-2.5 text-sm font-medium text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:bg-white dark:text-slate-950">{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
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 { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||||
import type { HourlyForecast } from "@/types/forecast";
|
import type { HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
return value.slice(11, 16);
|
||||||
}
|
}
|
||||||
@@ -24,20 +27,20 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
<Card className="p-4 sm:p-5">
|
<Card className="p-4 sm:p-5">
|
||||||
<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-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p>
|
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
|
||||||
<div className="mt-4 h-56 w-full">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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="rgba(148,163,184,0.18)" 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 dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||||
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke="#0284c7" strokeWidth={3} dot={false} connectNulls />
|
<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="#818cf8" strokeWidth={2} strokeDasharray="5 4" 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>
|
||||||
@@ -45,16 +48,16 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
|
|
||||||
<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-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
|
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
|
||||||
<div className="mt-4 h-56 w-full">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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="rgba(148,163,184,0.18)" 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 dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
||||||
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||||
formatter={(value, name) => [
|
formatter={(value, name) => [
|
||||||
name === t("forecast.precipitation")
|
name === t("forecast.precipitation")
|
||||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
||||||
@@ -63,8 +66,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||||
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill="#38bdf8" radius={[5, 5, 0, 0]} />
|
<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="#6366f1" strokeWidth={2} dot={false} connectNulls />
|
<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>
|
||||||
|
|||||||
@@ -5,25 +5,32 @@ import type { SynopStation } from "@/types/imgw";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
const SNAPSHOT_COLORS = [
|
||||||
|
"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 rows = [
|
const rows = [
|
||||||
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
|
{ 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: "#818cf8" },
|
{ 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: "#22d3ee" },
|
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
|
||||||
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
].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">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p>
|
<p className="section-kicker">{t("snapshot.label")}</p>
|
||||||
<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-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
|
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
|
||||||
<div className="mt-5 h-52 w-full">
|
<div className="mt-5 h-52 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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 type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||||
<Tooltip cursor={{ fill: "rgba(148,163,184,0.08)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
|||||||
import { ErrorState } from "@/components/states/error-state";
|
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 { ForecastPanel } from "@/components/forecast/forecast-panel";
|
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
||||||
import { locateSynopStations } from "@/lib/location-utils";
|
import { locateSynopStations } from "@/lib/location-utils";
|
||||||
|
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -20,23 +22,28 @@ 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);
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||||
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||||
const selectedStation = stations.find((station) => station.id === selectedStationId)
|
?? stations?.[0];
|
||||||
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||||
?? stations[0];
|
const stationPosition = selectedStation
|
||||||
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
|
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||||
const stationPosition = locateSynopStations(stations, positions).find((station) => station.id === selectedStation.id);
|
: null;
|
||||||
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation.name : selectedStation.name;
|
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
||||||
|
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude);
|
||||||
|
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||||
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
|
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||||
|
|
||||||
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} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName} />
|
<DashboardWarnings />
|
||||||
|
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||||
<FavoritesSection stations={stations} />
|
<FavoritesSection stations={stations} />
|
||||||
<FeaturedStationsSection stations={stations} />
|
<FeaturedStationsSection stations={stations} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { CloudSun, Droplets, ExternalLink, Sunrise, Sunset, Wind, X } from "lucide-react";
|
import { CloudSun, Droplets, Sunrise, Sunset, Wind, X } from "lucide-react";
|
||||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||||
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,7 +17,7 @@ import {
|
|||||||
getForecastCondition,
|
getForecastCondition,
|
||||||
isForecastHourPast,
|
isForecastHourPast,
|
||||||
} from "@/lib/forecast-utils";
|
} from "@/lib/forecast-utils";
|
||||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
function formatHour(value: string | null) {
|
function formatHour(value: string | null) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
@@ -31,9 +33,9 @@ function getMaximumWind(hours: HourlyForecast[]) {
|
|||||||
|
|
||||||
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
|
||||||
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</p>
|
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{label}</p>
|
||||||
<p className="mt-1 text-base font-semibold">{value}</p>
|
<p className="mt-1 text-base font-semibold">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,15 +45,18 @@ export function DayForecastModal({
|
|||||||
day,
|
day,
|
||||||
hours,
|
hours,
|
||||||
locationName,
|
locationName,
|
||||||
|
sources,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
day: DailyForecast | null;
|
day: DailyForecast | null;
|
||||||
hours: HourlyForecast[];
|
hours: HourlyForecast[];
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
sources: ForecastSource[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { language, locale, t } = useI18n();
|
const { language, locale, t } = useI18n();
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const portalRoot = typeof document === "undefined" ? null : document.body;
|
||||||
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
|
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,11 +82,11 @@ export function DayForecastModal({
|
|||||||
? 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`))
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
const modal = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{day ? (
|
{day ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-md sm:p-4 lg:p-8"
|
className="modal-overlay z-[90] flex items-center justify-center p-0 sm:p-4 lg:p-8"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -91,7 +96,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="weather-scrollbar h-full w-full overflow-y-auto bg-gradient-to-b from-sky-100/95 via-slate-100/95 to-white/95 shadow-2xl dark:from-slate-900/95 dark:via-slate-950/95 dark:to-slate-950/95 sm:max-w-6xl sm:rounded-[2rem] sm:border sm:border-white/30 dark:sm:border-white/10"
|
className="weather-scrollbar h-full w-full overflow-y-auto bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70"
|
||||||
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 }}
|
||||||
@@ -101,32 +106,32 @@ export function DayForecastModal({
|
|||||||
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">
|
<p className="section-kicker flex items-center gap-2">
|
||||||
<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-slate-600 dark:text-slate-300">{formattedDate}</p>
|
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("forecast.closeDetails")}
|
aria-label={t("forecast.closeDetails")}
|
||||||
className="rounded-full border border-white/35 bg-white/35 p-3 text-slate-700 transition hover:bg-white/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-100 dark:hover:bg-white/20"
|
className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden bg-gradient-to-br from-sky-500/20 via-white/25 to-indigo-400/15 p-5 dark:from-sky-700/20 dark:via-white/5 dark:to-indigo-500/15 sm:p-6">
|
<Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
|
||||||
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300">{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-sky-700 dark:text-sky-300" />
|
<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">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
||||||
<p className="mb-2 text-2xl text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</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]">
|
||||||
@@ -148,15 +153,15 @@ export function DayForecastModal({
|
|||||||
<li
|
<li
|
||||||
key={hour.time}
|
key={hour.time}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[5.2rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5",
|
"w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center",
|
||||||
isPast && "opacity-45",
|
isPast && "opacity-45",
|
||||||
)}
|
)}
|
||||||
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
<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-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-sky-700 dark:text-sky-300">
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||||
<Droplets className="size-3" />
|
<Droplets className="size-3" />
|
||||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||||
</p>
|
</p>
|
||||||
@@ -169,16 +174,13 @@ export function DayForecastModal({
|
|||||||
|
|
||||||
<DayForecastCharts hours={hours} />
|
<DayForecastCharts hours={hours} />
|
||||||
|
|
||||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
<ForecastSources sources={sources} />
|
||||||
{t("forecast.source")}{" "}
|
|
||||||
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">
|
|
||||||
Open-Meteo <ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(modal, portalRoot) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
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, ExternalLink, 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";
|
||||||
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
||||||
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { EmptyState } from "@/components/states/empty-state";
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -48,9 +49,9 @@ function getTotal(values: Array<number | null>) {
|
|||||||
|
|
||||||
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
|
||||||
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">{label}</p>
|
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-muted">{label}</p>
|
||||||
<p className="mt-1 text-sm font-semibold">{value}</p>
|
<p className="mt-1 text-sm font-semibold">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -68,8 +69,8 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
|
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto hidden border-t border-white/30 pt-4 dark:border-white/10 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-slate-500 dark:text-slate-400">{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 icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
|
||||||
@@ -88,23 +89,23 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
|||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
||||||
className="border-t border-white/30 first:border-t-0 dark:border-white/10"
|
className="border-t border-border/65 first:border-t-0"
|
||||||
>
|
>
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
aria-label={t("forecast.openDayDetails", { day: label })}
|
aria-label={t("forecast.openDayDetails", { day: label })}
|
||||||
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-xl px-1 py-3 text-left transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:hover:bg-white/5 sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
|
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-card px-1 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
|
||||||
onClick={() => onSelect(day)}
|
onClick={() => onSelect(day)}
|
||||||
>
|
>
|
||||||
<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-sky-600 dark:text-sky-300" />
|
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
|
||||||
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 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"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
||||||
<ChevronRight className="hidden size-4 text-slate-400 sm:block" />
|
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
);
|
);
|
||||||
@@ -122,9 +123,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><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-slate-600 dark:text-slate-300">{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 ? (
|
||||||
@@ -134,7 +135,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
</div>
|
</div>
|
||||||
) : 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-slate-600 dark:text-slate-300">{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 ? (
|
||||||
@@ -143,7 +144,7 @@ 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-sky-600 dark:text-sky-300" />{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) => (
|
||||||
@@ -152,24 +153,24 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
||||||
className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5 lg:w-[5.5rem] lg:py-4"
|
className="w-[4.6rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center lg:w-[5.5rem] lg:py-4"
|
||||||
title={getForecastCondition(hour.weatherCode, language)}
|
title={getForecastCondition(hour.weatherCode, language)}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
<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-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-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||||
<div className="mt-3 hidden space-y-1.5 border-t border-white/35 pt-3 text-[0.66rem] text-slate-600 dark:border-white/10 dark:text-slate-300 lg:block">
|
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
||||||
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
||||||
<ThermometerSun className="size-3 text-sky-600 dark:text-sky-300" />
|
<ThermometerSun className="size-3 text-accent" />
|
||||||
{formatForecastTemperature(hour.feelsLike, language)}
|
{formatForecastTemperature(hour.feelsLike, language)}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
||||||
<Wind className="size-3 text-sky-600 dark:text-sky-300" />
|
<Wind className="size-3 text-accent" />
|
||||||
{formatForecastWind(hour.windSpeed, language)}
|
{formatForecastWind(hour.windSpeed, language)}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
||||||
<CloudRain className="size-3 text-sky-600 dark:text-sky-300" />
|
<CloudRain className="size-3 text-accent" />
|
||||||
{formatForecastRainfall(hour.precipitation, language)}
|
{formatForecastRainfall(hour.precipitation, language)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +181,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
<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-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
|
||||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,11 +189,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
{forecast && <ForecastSources sources={forecast.sources} />}
|
||||||
{t("forecast.source")} <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo <ExternalLink className="size-3" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} onClose={closeDayDetails} />
|
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
components/forecast/forecast-sources.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { ForecastSource } from "@/types/forecast";
|
||||||
|
|
||||||
|
export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const hasImgw = sources.includes("imgw-alaro");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-[0.68rem] leading-5 text-muted">
|
||||||
|
{t("forecast.source")}{" "}
|
||||||
|
{hasImgw && (
|
||||||
|
<>
|
||||||
|
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||||
|
IMGW ALARO <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
{", "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||||
|
Open-Meteo <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,16 +27,16 @@ export function HydroPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">{t("hydro.section")}</p>
|
<p className="section-kicker">{t("hydro.section")}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1>
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("hydro.description")}</p>
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("hydro.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="glass relative block rounded-[1.5rem] 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-slate-500" />
|
<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-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/5" />
|
<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" />
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
||||||
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
{!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">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
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-white/60 dark:hover:bg-slate-900/45">
|
<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-slate-500 dark:text-slate-400"><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">
|
||||||
@@ -23,7 +23,7 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
||||||
<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-slate-500 dark:text-slate-400">{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>
|
||||||
);
|
);
|
||||||
@@ -31,8 +31,8 @@ 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-2xl bg-white/35 p-2.5 dark:bg-white/5">
|
<div className="rounded-card bg-surface-muted/60 p-2.5">
|
||||||
<p className="flex items-center gap-1 text-[0.65rem] text-slate-500 dark:text-slate-400"><Icon className="size-3" />{label}</p>
|
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
|
||||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.28),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(129,140,248,0.18),transparent_31%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(14,116,144,0.22),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(49,46,129,0.22),transparent_31%)]">
|
<div className="min-h-screen overflow-x-hidden bg-background">
|
||||||
<header className="sticky top-0 z-40 border-b border-white/25 bg-white/30 backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30">
|
<header className="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-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-white">
|
<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-sky-600 dark:text-sky-300">.</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) => {
|
{NAV_ITEMS.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-full px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-white/60 text-slate-950 shadow-sm dark:bg-white/15 dark:text-white" : "text-slate-600 hover:bg-white/35 dark:text-slate-300 dark:hover:bg-white/10")}>
|
<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>
|
||||||
);
|
);
|
||||||
@@ -40,12 +40,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</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>
|
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
||||||
<nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-[1.4rem] border border-white/40 bg-white/65 p-1.5 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65 md:hidden">
|
<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">
|
||||||
{NAV_ITEMS.map((item, index) => {
|
{NAV_ITEMS.map((item, index) => {
|
||||||
const Icon = icons[index];
|
const Icon = icons[index];
|
||||||
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-[5rem] flex-col items-center gap-1 rounded-2xl px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "text-slate-600 dark:text-slate-300")}>
|
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-card px-3 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" />
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ 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-full bg-emerald-500/10 p-3 text-emerald-600 dark:text-emerald-300"><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-slate-600 dark:text-slate-300">{description}</p>
|
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export function ErrorState({ title, description, onRetry }: { title?: string; de
|
|||||||
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-full bg-amber-500/15 p-3 text-amber-700 dark:text-amber-300"><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-slate-600 dark:text-slate-300">{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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-[1.75rem] bg-white/40 dark:bg-white/10", 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() {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center gap-2 rounded-control text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-slate-950 px-4 py-2.5 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200",
|
default: "bg-foreground px-4 py-2.5 text-background hover:opacity-90",
|
||||||
glass: "border border-white/30 bg-white/30 px-4 py-2.5 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
glass: "surface-control px-4 py-2.5 text-foreground hover:bg-surface-raised/90",
|
||||||
ghost: "px-3 py-2 text-slate-700 hover:bg-white/50 dark:text-slate-200 dark:hover:bg-white/10",
|
ghost: "px-3 py-2 text-muted hover:bg-surface-muted/70 hover:text-foreground",
|
||||||
icon: "size-10 border border-white/30 bg-white/30 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
icon: "surface-control size-10 text-foreground hover:bg-surface-raised/90",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "default" },
|
defaultVariants: { variant: "default" },
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import type { HTMLAttributes } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("glass rounded-[1.75rem]", className)} {...props} />;
|
return <div className={cn("glass rounded-panel", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export function LanguageToggle() {
|
|||||||
return (
|
return (
|
||||||
<label className="relative flex items-center">
|
<label className="relative flex items-center">
|
||||||
<span className="sr-only">{t("language.label")}</span>
|
<span className="sr-only">{t("language.label")}</span>
|
||||||
<Languages className="pointer-events-none absolute left-3 size-4 text-slate-700 dark:text-slate-200" />
|
<Languages className="pointer-events-none absolute left-3 size-4 text-muted" />
|
||||||
<select
|
<select
|
||||||
aria-label={t("language.label")}
|
aria-label={t("language.label")}
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(event) => setLanguage(event.target.value as Language)}
|
onChange={(event) => setLanguage(event.target.value as Language)}
|
||||||
className="h-10 appearance-none rounded-full border border-white/30 bg-white/30 py-2 pl-9 pr-3 text-xs font-semibold uppercase text-slate-800 backdrop-blur-xl transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
|
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"
|
||||||
>
|
>
|
||||||
<option value="pl">{t("language.polish")}</option>
|
<option value="pl">{t("language.polish")}</option>
|
||||||
<option value="en">{t("language.english")}</option>
|
<option value="en">{t("language.english")}</option>
|
||||||
|
|||||||
141
components/warnings/dashboard-warnings.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, ArrowRight, CalendarClock } from "lucide-react";
|
||||||
|
import { useWarnings } from "@/hooks/use-warnings";
|
||||||
|
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import { formatDateTime } from "@/lib/weather-utils";
|
||||||
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_WARNINGS = 2;
|
||||||
|
|
||||||
|
function getTimestamp(value: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isNaN(timestamp) ? null : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarningActive(warning: WeatherWarning, now: number) {
|
||||||
|
const validFrom = getTimestamp(warning.validFrom);
|
||||||
|
return validFrom === null || validFrom <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDashboardWarnings(a: WeatherWarning, b: WeatherWarning, now: number) {
|
||||||
|
const activeDifference = Number(isWarningActive(b, now)) - Number(isWarningActive(a, now));
|
||||||
|
if (activeDifference) return activeDifference;
|
||||||
|
|
||||||
|
const levelDifference = (b.level ?? 0) - (a.level ?? 0);
|
||||||
|
if (levelDifference) return levelDifference;
|
||||||
|
|
||||||
|
return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWarnings() {
|
||||||
|
const { data: warnings } = useWarnings();
|
||||||
|
const { language, t } = useI18n();
|
||||||
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||||
|
const [now, setNow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => setNow(Date.now()), 0);
|
||||||
|
const intervalId = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||||
|
const relevantWarnings = useMemo(() => {
|
||||||
|
if (!warnings || !province || now === null) return [];
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
.filter((warning) => {
|
||||||
|
const validTo = getTimestamp(warning.validTo);
|
||||||
|
return warning.kind === "meteo"
|
||||||
|
&& warning.provinces.includes(province)
|
||||||
|
&& (validTo === null || validTo > now);
|
||||||
|
})
|
||||||
|
.sort((a, b) => compareDashboardWarnings(a, b, now));
|
||||||
|
}, [now, province, warnings]);
|
||||||
|
|
||||||
|
if (!province || !relevantWarnings.length || now === null) return null;
|
||||||
|
|
||||||
|
const visibleWarnings = relevantWarnings.slice(0, MAX_VISIBLE_WARNINGS);
|
||||||
|
const hiddenWarningsCount = relevantWarnings.length - visibleWarnings.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
aria-label={t("warnings.dashboard.title")}
|
||||||
|
className="rounded-panel border border-warning/25 bg-warning/10 p-4 shadow-soft sm:p-5"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-control border border-warning/30 bg-warning/10 p-2 text-warning">
|
||||||
|
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
|
||||||
|
IMGW · {formatProvinceName(province, language)}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-base font-semibold text-foreground">
|
||||||
|
{t("warnings.dashboard.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/warnings"
|
||||||
|
className="inline-flex w-fit items-center gap-1.5 rounded-control px-2 py-1 text-xs font-semibold text-warning transition hover:bg-warning/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-warning"
|
||||||
|
>
|
||||||
|
{t("warnings.dashboard.viewAll")}
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-2 md:grid-cols-2">
|
||||||
|
{visibleWarnings.map((warning) => {
|
||||||
|
const active = isWarningActive(warning, now);
|
||||||
|
const validityLabel = active
|
||||||
|
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) })
|
||||||
|
: warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={warning.id}
|
||||||
|
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
|
||||||
|
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
|
||||||
|
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-foreground">
|
||||||
|
{warning.title || t("warnings.genericMeteo")}
|
||||||
|
</p>
|
||||||
|
{validityLabel && (
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<CalendarClock className="size-3.5" aria-hidden="true" />
|
||||||
|
{validityLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hiddenWarningsCount > 0 && (
|
||||||
|
<p className="mt-3 text-xs font-medium text-warning">
|
||||||
|
{t("warnings.dashboard.more", { count: hiddenWarningsCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,17 +20,17 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
|
|||||||
<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-2xl bg-amber-500/15 p-2.5 text-amber-700 dark:text-amber-300"><Icon className="size-5" /></div>
|
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
|
||||||
<span className={cn("rounded-full border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-orange-300/40 bg-orange-400/15 text-orange-800 dark:text-orange-200" : "border-amber-300/40 bg-amber-400/15 text-amber-800 dark:text-amber-200")}>{levelLabel}</span>
|
<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-slate-500 dark:text-slate-400">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
||||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
<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-slate-600 dark:text-slate-300">{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-slate-500 dark:text-slate-400">
|
<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"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
||||||
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
<p className="flex items-start gap-2"><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-slate-600 dark:text-slate-300">{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function WarningsPageContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("warnings.section")}</p>
|
<p className="section-kicker">{t("warnings.section")}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.description")}</p>
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("warnings.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<WarningsPanel />
|
<WarningsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { EmptyState } from "@/components/states/empty-state";
|
|||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { formatProvinceName, getProvinceForStation, normalizeProvinceName } from "@/lib/provinces";
|
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import type { WeatherWarning } from "@/types/imgw";
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
|
||||||
@@ -29,8 +29,7 @@ export function WarningsPanel() {
|
|||||||
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 = normalizeProvinceName(selectedLocation?.province)
|
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||||
?? getProvinceForStation(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);
|
||||||
@@ -41,9 +40,9 @@ export function WarningsPanel() {
|
|||||||
<div className="space-y-9">
|
<div className="space-y-9">
|
||||||
<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-sky-700 dark:text-sky-300"><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">{provinceLabel}</h2>
|
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2>
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
|
||||||
</div>
|
</div>
|
||||||
{localWarnings.length
|
{localWarnings.length
|
||||||
? <WarningGrid warnings={localWarnings} />
|
? <WarningGrid warnings={localWarnings} />
|
||||||
@@ -53,8 +52,8 @@ export function WarningsPanel() {
|
|||||||
{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-slate-500 dark:text-slate-400"><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-slate-600 dark:text-slate-300">{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} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{showPrompt && (
|
{showPrompt && (
|
||||||
<div className="glass-subtle rounded-2xl 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-full bg-sky-500/10 p-2 text-sky-700 dark:text-sky-300"><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-slate-600 dark:text-slate-300">{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-amber-700 dark:text-amber-300"><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 || !stations.length}>
|
||||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
@@ -124,11 +124,11 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
{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-slate-600 dark:text-slate-300">{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-slate-500 dark:text-slate-400">
|
<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-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><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,13 +29,13 @@ 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-2xl p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10", active && "border-sky-400/60 bg-white/60 dark:bg-white/15")}
|
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-slate-600 dark:text-slate-300">{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)}</span>
|
||||||
</span>
|
</span>
|
||||||
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
|
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative z-30">
|
<section className="relative z-30">
|
||||||
<div className="glass rounded-[1.75rem] p-3 sm:p-4">
|
<div className="glass rounded-panel p-3 sm:p-4">
|
||||||
<div className="flex items-center gap-2 px-1 pb-3">
|
<div className="flex items-center gap-2 px-1 pb-3">
|
||||||
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
|
<MapPin className="size-4 text-accent" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
|
<p className="section-kicker">{t("location.label")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative block">
|
<label className="relative block">
|
||||||
<span className="sr-only">{t("location.searchLabel")}</span>
|
<span className="sr-only">{t("location.searchLabel")}</span>
|
||||||
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />}
|
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
@@ -41,25 +41,25 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
||||||
placeholder={t("location.placeholder")}
|
placeholder={t("location.placeholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10"
|
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"
|
||||||
/>
|
/>
|
||||||
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
|
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
|
||||||
</label>
|
</label>
|
||||||
<CurrentLocationControl stations={locatedStations} />
|
<CurrentLocationControl stations={locatedStations} />
|
||||||
{selectedLocation && (
|
{selectedLocation && (
|
||||||
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
|
<p className="mt-3 px-1 text-xs text-muted">
|
||||||
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
|
<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-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">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 && (
|
{showSuggestions && (
|
||||||
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
|
<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-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
|
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
|
||||||
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> :
|
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
|
||||||
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
|
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
||||||
suggestions.map(({ location, nearest }) => nearest && (
|
suggestions.map(({ location, nearest }) => nearest && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,13 +69,13 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"
|
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-sm font-semibold">{location.name}</span>
|
<span className="block text-sm font-semibold">{location.name}</span>
|
||||||
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
<span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { ic
|
|||||||
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-slate-500 dark:text-slate-400">
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
|
||||||
<Icon className="size-4 text-sky-600 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
|
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
|
||||||
{detail && <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{detail}</p>}
|
{detail && <p className="mt-1 text-xs text-muted">{detail}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,20 +22,20 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
|||||||
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
|
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
|
||||||
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-white/60 dark:hover:bg-slate-900/45">
|
<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-sky-500">
|
<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)}</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-sky-600 dark:text-sky-300" />
|
<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-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-400">
|
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||||
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
|
<span className="flex items-center gap-1"><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"><Wind className="size-3" />{compactWind}</span>
|
||||||
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
|
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ 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-full px-1 py-1 text-sm font-medium text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-300"><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")}
|
||||||
@@ -35,20 +35,20 @@ export function StationDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<WeatherHero station={station} />
|
<WeatherHero station={station} />
|
||||||
<section>
|
<section>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("station.label", { name: station.name })}</p>
|
<p className="section-kicker">{t("station.label", { name: station.name })}</p>
|
||||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</h1>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</h1>
|
||||||
<p className="mb-4 mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.parametersDescription")}</p>
|
<p className="mb-4 mt-1 text-sm leading-6 text-muted">{t("station.parametersDescription")}</p>
|
||||||
<WeatherDetailsGrid station={station} />
|
<WeatherDetailsGrid station={station} />
|
||||||
</section>
|
</section>
|
||||||
<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="flex items-center gap-2 text-sky-700 dark:text-sky-300"><ShieldCheck className="size-5" /><p className="text-xs font-semibold uppercase tracking-[0.18em]">{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-slate-600 dark:text-slate-300">{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-slate-500 dark:text-slate-400">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
|
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
|
||||||
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</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>
|
||||||
|
|||||||
@@ -1,71 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import type { SynopStation, WeatherMood } from "@/types/imgw";
|
|
||||||
|
|
||||||
const windLines = Array.from({ length: 7 }, (_, index) => ({
|
const rainDrops = Array.from({ length: 12 }, (_, index) => ({
|
||||||
top: `${18 + index * 11}%`,
|
left: `${(index * 43 + 7) % 101}%`,
|
||||||
delay: index * 0.22,
|
delay: (index % 9) * 0.18,
|
||||||
width: 70 + (index % 3) * 34,
|
duration: 1.1 + (index % 4) * 0.18,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const stars = Array.from({ length: 16 }, (_, index) => ({
|
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
|
||||||
left: `${(index * 37 + 11) % 96}%`,
|
|
||||||
top: `${(index * 23 + 8) % 72}%`,
|
|
||||||
delay: (index % 6) * 0.35,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function WeatherEffects({ station, mood }: { station: SynopStation; mood: WeatherMood }) {
|
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const isWindy = (station.windSpeed ?? 0) >= 8;
|
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">
|
||||||
{mood === "cloudy" && (
|
{isRaining && rainDrops.map((drop, index) => (
|
||||||
<>
|
<motion.span
|
||||||
<motion.div
|
key={`rain-${index}`}
|
||||||
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
|
initial={{ y: "-12vh", opacity: 0 }}
|
||||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
animate={reduceMotion ? { opacity: 0.18 } : { y: ["-12vh", "115vh"], opacity: [0, 0.22, 0] }}
|
||||||
className="absolute -left-24 -top-20 h-52 w-[78%] rounded-[50%] bg-slate-100/30 blur-3xl"
|
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
className="absolute -top-8 h-10 w-px rotate-[8deg] rounded-full bg-foreground/35"
|
||||||
<motion.div
|
style={{ left: drop.left }}
|
||||||
animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }}
|
/>
|
||||||
transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }}
|
))}
|
||||||
className="absolute -right-24 top-0 h-60 w-[82%] rounded-[50%] bg-slate-300/24 blur-3xl"
|
{thunderstorm && (
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-slate-100/25 via-slate-200/10 to-transparent" />
|
|
||||||
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-slate-950/15 to-transparent" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mood === "warm" && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
|
animate={reduceMotion ? { opacity: 0.08 } : { opacity: [0, 0, 0.16, 0, 0.08, 0] }}
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 6, repeat: Infinity, repeatDelay: 2.5 }}
|
||||||
className="absolute -right-16 -top-20 size-64 rounded-full bg-amber-200/45 blur-3xl"
|
className="absolute inset-0 bg-foreground"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mood === "night" && stars.map((star, index) => (
|
|
||||||
<motion.span
|
|
||||||
key={index}
|
|
||||||
animate={reduceMotion ? undefined : { opacity: [0.2, 0.75, 0.2] }}
|
|
||||||
transition={{ duration: 2.4, delay: star.delay, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
className="absolute size-1 rounded-full bg-white/75"
|
|
||||||
style={{ left: star.left, top: star.top }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{isWindy && windLines.map((line, index) => (
|
|
||||||
<motion.span
|
|
||||||
key={index}
|
|
||||||
initial={{ x: "-30%", opacity: 0 }}
|
|
||||||
animate={reduceMotion ? { opacity: 0.28 } : { x: ["-30%", "135%"], opacity: [0, 0.35, 0] }}
|
|
||||||
transition={{ duration: 3.4, delay: line.delay, repeat: Infinity, ease: "linear" }}
|
|
||||||
className="absolute h-px rounded-full bg-white/60"
|
|
||||||
style={{ top: line.top, width: line.width }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{mood === "cold" && (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-cyan-100/20 to-transparent" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { 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,
|
||||||
@@ -12,22 +12,48 @@ import {
|
|||||||
formatWind,
|
formatWind,
|
||||||
getWeatherDescription,
|
getWeatherDescription,
|
||||||
getWeatherMoodFromData,
|
getWeatherMoodFromData,
|
||||||
moodGradient,
|
|
||||||
} from "@/lib/weather-utils";
|
} from "@/lib/weather-utils";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation, WeatherMood } from "@/types/imgw";
|
||||||
|
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";
|
||||||
|
|
||||||
export function WeatherHero({ station, locationName, distanceKm }: { station: SynopStation; locationName?: string; distanceKm?: number }) {
|
function moodAccentClass(mood: WeatherMood) {
|
||||||
|
return {
|
||||||
|
warm: "border-accent/25 bg-accent/10 text-accent",
|
||||||
|
cloudy: "border-border/70 bg-surface-muted text-muted",
|
||||||
|
wind: "border-border/70 bg-surface-muted text-muted",
|
||||||
|
cold: "border-border/70 bg-surface-muted text-muted",
|
||||||
|
night: "border-border/70 bg-surface-muted text-muted",
|
||||||
|
mild: "border-accent/25 bg-accent/10 text-accent",
|
||||||
|
}[mood];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const mood = getWeatherMoodFromData(station);
|
const displayedLocationName = locationName ?? station.name;
|
||||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
||||||
|
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
||||||
|
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
|
||||||
|
const displayedStation = currentWeather ? {
|
||||||
|
...station,
|
||||||
|
measuredAt: hasFullHybridAnalysis ? 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 moodAccent = moodAccentClass(mood);
|
||||||
|
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language) },
|
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
|
||||||
{ icon: Wind, label: t("weather.wind"), value: formatWind(station.windSpeed, null, language) },
|
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) },
|
||||||
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language) },
|
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
|
||||||
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language) },
|
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,38 +61,53 @@ export function WeatherHero({ station, locationName, distanceKm }: { station: Sy
|
|||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.55, ease: "easeOut" }}
|
transition={{ duration: 0.55, ease: "easeOut" }}
|
||||||
className={`relative isolate overflow-hidden rounded-[2rem] bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-[0_24px_75px_rgba(15,23,42,0.24)] 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 station={station} mood={mood} />
|
<WeatherEffects precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} />
|
||||||
<div className="absolute -right-20 -top-20 size-72 rounded-full bg-white/15 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{locationName ?? station.name}</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{locationName && <span className="text-xs text-white/65">{t("location.heroSource", { station: station.name, distance: distanceKm ?? 0 })}</span>}
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span>
|
||||||
|
<span className={`rounded-control border px-2.5 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${moodAccent}`}>
|
||||||
|
{getWeatherDescription(displayedStation, language, currentWeather?.condition)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
|
<p>{currentWeatherLoading
|
||||||
|
? t("location.heroHybridLoading", { station: station.name })
|
||||||
|
: hasFullHybridAnalysis
|
||||||
|
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||||
|
: hasPartialHybridAnalysis
|
||||||
|
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
|
||||||
|
: locationName
|
||||||
|
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
|
||||||
|
: t("location.heroStationFallback", { station: station.name })}</p>
|
||||||
|
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
|
||||||
|
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
|
||||||
|
</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-medium leading-[0.85] tracking-[-0.11em] drop-shadow-[0_10px_24px_rgba(15,23,42,0.16)] sm:text-[8rem]">
|
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
|
||||||
{formatTemperature(station.temperature, language)}
|
{formatTemperature(displayedStation.temperature, language)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(station, language)}</p>
|
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
|
||||||
<p className="mt-1 text-sm text-white/75">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(station.measuredAt, language)}</p>
|
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
||||||
</div>
|
</div>
|
||||||
<WeatherIcon mood={mood} className="mb-4 size-20 text-white/80 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-2xl border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
|
<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-white/70"><Icon className="size-3.5" />{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{station.windDirection !== null && (
|
{displayedStation.windDirection !== null && (
|
||||||
<p className="mt-4 flex items-center gap-1.5 text-xs text-white/70">
|
<p className="mt-4 flex items-center gap-1.5 text-xs text-muted">
|
||||||
<Navigation className="size-3.5" style={{ transform: `rotate(${station.windDirection}deg)` }} />
|
<Navigation className="size-3.5" style={{ transform: `rotate(${displayedStation.windDirection}deg)` }} />
|
||||||
{t("weather.windDirection")}: {station.windDirection}°
|
{t("weather.windDirection")}: {displayedStation.windDirection}°
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Cloud, CloudSun, MoonStar, Snowflake, ThermometerSun, Wind } from "lucide-react";
|
import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, ThermometerSun, Wind } from "lucide-react";
|
||||||
import type { WeatherMood } from "@/types/imgw";
|
import type { WeatherMood } from "@/types/imgw";
|
||||||
|
import type { CurrentWeatherCondition } from "@/types/imgw-current";
|
||||||
|
|
||||||
export function WeatherIcon({ mood, className = "" }: { mood: WeatherMood; className?: string }) {
|
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
|
||||||
const Icon = {
|
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
|
||||||
warm: ThermometerSun,
|
warm: ThermometerSun,
|
||||||
cloudy: Cloud,
|
cloudy: Cloud,
|
||||||
wind: Wind,
|
wind: Wind,
|
||||||
|
|||||||
18
hooks/use-current-weather.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchImgwCurrentWeather } from "@/lib/imgw-current-api";
|
||||||
|
import { QUERY_GC_TIME } from "@/lib/constants";
|
||||||
|
|
||||||
|
const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
export function useCurrentWeather(latitude?: number, longitude?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["imgw-current-weather", latitude, longitude],
|
||||||
|
queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal),
|
||||||
|
staleTime: CURRENT_WEATHER_STALE_TIME,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 1,
|
||||||
|
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
|
||||||
|
});
|
||||||
|
}
|
||||||
10
lib/chart-theme.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const CHART_COLORS = {
|
||||||
|
grid: "hsl(var(--border) / 0.65)",
|
||||||
|
tooltipBorder: "hsl(var(--border) / 0.75)",
|
||||||
|
tooltipBackground: "hsl(var(--surface-raised) / 0.98)",
|
||||||
|
tooltipText: "hsl(var(--foreground))",
|
||||||
|
temperature: "hsl(var(--chart-temperature))",
|
||||||
|
feelsLike: "hsl(var(--chart-feels-like))",
|
||||||
|
rainfall: "hsl(var(--chart-rainfall))",
|
||||||
|
probability: "hsl(var(--chart-probability))",
|
||||||
|
} as const;
|
||||||
@@ -1,53 +1,61 @@
|
|||||||
import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, WeatherForecast } from "@/types/forecast";
|
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
|
||||||
|
|
||||||
function asArray(value: unknown): unknown[] {
|
function readNumber(value: unknown) {
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
|
||||||
const value = asArray(series[key])[index];
|
|
||||||
return typeof value === "string" && value ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
|
||||||
const value = asArray(series[key])[index];
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHourlyForecast(series: RawForecastSeries = {}): HourlyForecast[] {
|
function readString(value: unknown) {
|
||||||
return asArray(series.time).flatMap((_, index) => {
|
return typeof value === "string" && value ? value : null;
|
||||||
const time = readString(series, "time", index);
|
}
|
||||||
|
|
||||||
|
function isForecastSource(value: unknown): value is ForecastSource {
|
||||||
|
return value === "imgw-alaro" || value === "open-meteo";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSources(value: unknown): ForecastSource[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isForecastSource) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||||
|
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object") return [];
|
||||||
|
const row = candidate as Partial<HourlyForecast>;
|
||||||
|
const time = readString(row.time);
|
||||||
if (!time) return [];
|
if (!time) return [];
|
||||||
return [{
|
return [{
|
||||||
time,
|
time,
|
||||||
temperature: readNumber(series, "temperature_2m", index),
|
temperature: readNumber(row.temperature),
|
||||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
feelsLike: readNumber(row.feelsLike),
|
||||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
precipitationProbability: readNumber(row.precipitationProbability),
|
||||||
precipitation: readNumber(series, "precipitation", index),
|
precipitation: readNumber(row.precipitation),
|
||||||
weatherCode: readNumber(series, "weather_code", index),
|
weatherCode: readNumber(row.weatherCode),
|
||||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
windSpeed: readNumber(row.windSpeed),
|
||||||
|
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||||
}];
|
}];
|
||||||
});
|
}) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
|
function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||||
return asArray(series.time).flatMap((_, index) => {
|
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||||
const date = readString(series, "time", index);
|
if (!candidate || typeof candidate !== "object") return [];
|
||||||
|
const row = candidate as Partial<DailyForecast>;
|
||||||
|
const date = readString(row.date);
|
||||||
if (!date) return [];
|
if (!date) return [];
|
||||||
return [{
|
return [{
|
||||||
date,
|
date,
|
||||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
temperatureMax: readNumber(row.temperatureMax),
|
||||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
temperatureMin: readNumber(row.temperatureMin),
|
||||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
precipitationProbability: readNumber(row.precipitationProbability),
|
||||||
precipitation: readNumber(series, "precipitation_sum", index),
|
precipitation: readNumber(row.precipitation),
|
||||||
weatherCode: readNumber(series, "weather_code", index),
|
weatherCode: readNumber(row.weatherCode),
|
||||||
sunrise: readString(series, "sunrise", index),
|
sunrise: readString(row.sunrise),
|
||||||
sunset: readString(series, "sunset", index),
|
sunset: readString(row.sunset),
|
||||||
|
sources: normalizeSources(row.sources),
|
||||||
}];
|
}];
|
||||||
});
|
}) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForecast(raw: RawWeatherForecast): 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.");
|
||||||
@@ -57,6 +65,7 @@ function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
|||||||
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,5 +73,5 @@ export async function fetchForecast(latitude: number, longitude: number, signal?
|
|||||||
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/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 RawWeatherForecast);
|
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
||||||
}
|
}
|
||||||
|
|||||||
216
lib/forecast-merge.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import type {
|
||||||
|
DailyForecast,
|
||||||
|
ForecastSource,
|
||||||
|
HourlyForecast,
|
||||||
|
RawForecastSeries,
|
||||||
|
RawImgwForecastResponse,
|
||||||
|
RawImgwForecastRow,
|
||||||
|
RawWeatherForecast,
|
||||||
|
WeatherForecast,
|
||||||
|
} from "@/types/forecast";
|
||||||
|
|
||||||
|
const WARSAW_TIME_ZONE = "Europe/Warsaw";
|
||||||
|
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: WARSAW_TIME_ZONE,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
|
||||||
|
function asArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
||||||
|
const value = asArray(series[key])[index];
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
||||||
|
const value = asArray(series[key])[index];
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown) {
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCelsius(value: unknown) {
|
||||||
|
const temperature = toNumber(value);
|
||||||
|
if (temperature === null) return null;
|
||||||
|
return temperature > 150 ? temperature - 273.15 : temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImgwWeatherCode(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const match = value.match(/z(\d{2})/i);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWarsawHour(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
const parts = warsawHourFormatter.formatToParts(date);
|
||||||
|
const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? "";
|
||||||
|
return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecast[] {
|
||||||
|
return asArray(series.time).flatMap((_, index) => {
|
||||||
|
const time = readString(series, "time", index);
|
||||||
|
if (!time) return [];
|
||||||
|
return [{
|
||||||
|
time,
|
||||||
|
temperature: readNumber(series, "temperature_2m", index),
|
||||||
|
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||||
|
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||||
|
precipitation: readNumber(series, "precipitation", index),
|
||||||
|
weatherCode: readNumber(series, "weather_code", index),
|
||||||
|
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||||
|
source: "open-meteo" as const,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[] {
|
||||||
|
return asArray(series.time).flatMap((_, index) => {
|
||||||
|
const date = readString(series, "time", index);
|
||||||
|
if (!date) return [];
|
||||||
|
return [{
|
||||||
|
date,
|
||||||
|
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||||
|
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||||
|
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||||
|
precipitation: readNumber(series, "precipitation_sum", index),
|
||||||
|
weatherCode: readNumber(series, "weather_code", index),
|
||||||
|
sunrise: readString(series, "sunrise", index),
|
||||||
|
sunset: readString(series, "sunset", index),
|
||||||
|
sources: ["open-meteo" as const],
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast {
|
||||||
|
const latitude = Number(raw.latitude);
|
||||||
|
const longitude = Number(raw.longitude);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
|
||||||
|
hourly: normalizeOpenMeteoHourly(raw.hourly),
|
||||||
|
daily: normalizeOpenMeteoDaily(raw.daily),
|
||||||
|
sources: ["open-meteo"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImgwHourly(payload?: RawImgwForecastResponse | null) {
|
||||||
|
if (!Array.isArray(payload?.data?.Data)) return new Map<string, Partial<HourlyForecast>>();
|
||||||
|
|
||||||
|
return payload.data.Data.reduce((rows, candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object") return rows;
|
||||||
|
const row = candidate as RawImgwForecastRow;
|
||||||
|
const time = toWarsawHour(row.Date);
|
||||||
|
if (!time) return rows;
|
||||||
|
rows.set(time, {
|
||||||
|
time,
|
||||||
|
temperature: toCelsius(row.Temperature),
|
||||||
|
feelsLike: toCelsius(row.Chill),
|
||||||
|
precipitation: toNumber(row.Precipitation),
|
||||||
|
weatherCode: readImgwWeatherCode(row.Icon),
|
||||||
|
windSpeed: toNumber(row.Wind_Speed),
|
||||||
|
source: "imgw-alaro",
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}, new Map<string, Partial<HourlyForecast>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableValues(values: Array<number | null>) {
|
||||||
|
return values.filter((value): value is number => value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMinimum(values: Array<number | null>, fallback: number | null) {
|
||||||
|
const availableValues = getAvailableValues(values);
|
||||||
|
return availableValues.length ? Math.min(...availableValues) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaximum(values: Array<number | null>, fallback: number | null) {
|
||||||
|
const availableValues = getAvailableValues(values);
|
||||||
|
return availableValues.length ? Math.max(...availableValues) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotal(values: Array<number | null>, fallback: number | null) {
|
||||||
|
const availableValues = getAvailableValues(values);
|
||||||
|
return availableValues.length ? availableValues.reduce((total, value) => total + value, 0) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeatherCodePriority(code: number | null) {
|
||||||
|
if (code === null) return -1;
|
||||||
|
if (code >= 95) return 8;
|
||||||
|
if (code === 85 || code === 86 || (code >= 71 && code <= 77)) return 7;
|
||||||
|
if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return 6;
|
||||||
|
if (code >= 51 && code <= 57) return 5;
|
||||||
|
if (code === 45 || code === 48) return 4;
|
||||||
|
if (code === 3) return 3;
|
||||||
|
if (code === 1 || code === 2) return 2;
|
||||||
|
if (code === 0) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
|
||||||
|
if (!hours.length) return fallback;
|
||||||
|
return hours.reduce((selected, hour) => (
|
||||||
|
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
|
||||||
|
), null as number | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
|
||||||
|
const sources = Array.from(new Set(hours.map((hour) => hour.source)));
|
||||||
|
return sources.length ? sources : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecast {
|
||||||
|
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
|
||||||
|
return {
|
||||||
|
...day,
|
||||||
|
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
|
||||||
|
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin),
|
||||||
|
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability),
|
||||||
|
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation),
|
||||||
|
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
|
||||||
|
sources: getSources(dayHours, day.sources),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast {
|
||||||
|
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
|
||||||
|
const imgwHours = normalizeImgwHourly(imgwPayload);
|
||||||
|
let hasImgwHours = false;
|
||||||
|
const hourly = openMeteoForecast.hourly.map((hour) => {
|
||||||
|
const imgwHour = imgwHours.get(hour.time);
|
||||||
|
if (!imgwHour) return hour;
|
||||||
|
hasImgwHours = true;
|
||||||
|
return {
|
||||||
|
...hour,
|
||||||
|
temperature: imgwHour.temperature ?? hour.temperature,
|
||||||
|
feelsLike: imgwHour.feelsLike ?? hour.feelsLike,
|
||||||
|
precipitation: imgwHour.precipitation ?? hour.precipitation,
|
||||||
|
weatherCode: imgwHour.weatherCode ?? hour.weatherCode,
|
||||||
|
windSpeed: imgwHour.windSpeed ?? hour.windSpeed,
|
||||||
|
source: "imgw-alaro" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...openMeteoForecast,
|
||||||
|
hourly,
|
||||||
|
daily: openMeteoForecast.daily.map((day) => summarizeDay(day, hourly)),
|
||||||
|
sources: hasImgwHours ? ["imgw-alaro", "open-meteo"] : ["open-meteo"],
|
||||||
|
};
|
||||||
|
}
|
||||||
62
lib/i18n.tsx
@@ -33,8 +33,14 @@ const translations = {
|
|||||||
"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 w Polsce.",
|
||||||
"location.nearest": "Najbliższa stacja IMGW",
|
"location.nearest": "Najbliższa stacja IMGW",
|
||||||
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
|
"location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.",
|
||||||
"location.heroSource": "stacja IMGW: {station} · około {distance} km",
|
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
|
||||||
|
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
|
||||||
|
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
|
||||||
|
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
|
||||||
|
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
|
||||||
|
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
|
||||||
|
"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ę…",
|
||||||
@@ -52,7 +58,7 @@ const translations = {
|
|||||||
"location.gpsSelected": "Wybrano lokalizację: {location}.",
|
"location.gpsSelected": "Wybrano lokalizację: {location}.",
|
||||||
"location.gpsAttribution": "Nazwy miejsc dla GPS:",
|
"location.gpsAttribution": "Nazwy miejsc dla GPS:",
|
||||||
"featured.label": "Szybki wybór",
|
"featured.label": "Szybki wybór",
|
||||||
"featured.title": "Wybrane stacje IMGW",
|
"featured.title": "Popularne lokalizacje",
|
||||||
"favorites.title": "Ulubione lokalizacje",
|
"favorites.title": "Ulubione lokalizacje",
|
||||||
"favorites.addStation": "Dodaj {name} do ulubionych",
|
"favorites.addStation": "Dodaj {name} do ulubionych",
|
||||||
"favorites.removeStation": "Usuń {name} z ulubionych",
|
"favorites.removeStation": "Usuń {name} z ulubionych",
|
||||||
@@ -61,6 +67,7 @@ const translations = {
|
|||||||
"weather.humidity": "Wilgotność",
|
"weather.humidity": "Wilgotność",
|
||||||
"weather.wind": "Wiatr",
|
"weather.wind": "Wiatr",
|
||||||
"weather.rainfall": "Suma opadu",
|
"weather.rainfall": "Suma opadu",
|
||||||
|
"weather.rainfall10m": "Opad 10 min",
|
||||||
"weather.pressure": "Ciśnienie",
|
"weather.pressure": "Ciśnienie",
|
||||||
"weather.feelsLike": "Odczuwalna",
|
"weather.feelsLike": "Odczuwalna",
|
||||||
"weather.measurement": "pomiar",
|
"weather.measurement": "pomiar",
|
||||||
@@ -68,6 +75,9 @@ const translations = {
|
|||||||
"weather.calm": "Spokojne warunki",
|
"weather.calm": "Spokojne warunki",
|
||||||
"weather.humid": "Wilgotno",
|
"weather.humid": "Wilgotno",
|
||||||
"weather.strongWind": "Silny wiatr",
|
"weather.strongWind": "Silny wiatr",
|
||||||
|
"weather.currentRain": "Opady deszczu",
|
||||||
|
"weather.currentSnow": "Opady śniegu",
|
||||||
|
"weather.thunderstorm": "Burza",
|
||||||
"weather.airTemperature": "Temperatura",
|
"weather.airTemperature": "Temperatura",
|
||||||
"weather.windSpeed": "Prędkość wiatru",
|
"weather.windSpeed": "Prędkość wiatru",
|
||||||
"weather.rainfallTotal": "Suma opadu",
|
"weather.rainfallTotal": "Suma opadu",
|
||||||
@@ -80,7 +90,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żący pomiar powyżej pochodzi ze stacji IMGW, a poniższe wartości są prognozą modelową.",
|
"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.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",
|
||||||
@@ -105,9 +115,11 @@ 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.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
|
||||||
|
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.",
|
||||||
|
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
|
||||||
"forecast.emptyTitle": "Brak prognozy",
|
"forecast.emptyTitle": "Brak prognozy",
|
||||||
"forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.",
|
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
|
||||||
"forecast.condition.clear": "Bezchmurnie",
|
"forecast.condition.clear": "Bezchmurnie",
|
||||||
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
||||||
"forecast.condition.cloudy": "Pochmurno",
|
"forecast.condition.cloudy": "Pochmurno",
|
||||||
@@ -155,6 +167,13 @@ const translations = {
|
|||||||
"warnings.probability": "Prawdopodobieństwo: {value}%",
|
"warnings.probability": "Prawdopodobieństwo: {value}%",
|
||||||
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
|
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
|
||||||
"warnings.genericMeteo": "Ostrzeżenie meteorologiczne",
|
"warnings.genericMeteo": "Ostrzeżenie meteorologiczne",
|
||||||
|
"warnings.dashboard.title": "Ostrzeżenia meteo dla Twojego regionu",
|
||||||
|
"warnings.dashboard.active": "Aktywne ostrzeżenie",
|
||||||
|
"warnings.dashboard.upcoming": "Nadchodzące ostrzeżenie",
|
||||||
|
"warnings.dashboard.validUntil": "Do {date}",
|
||||||
|
"warnings.dashboard.validFrom": "Od {date}",
|
||||||
|
"warnings.dashboard.more": "+{count} kolejne ostrzeżenia",
|
||||||
|
"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 dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
|
||||||
@@ -200,8 +219,14 @@ const translations = {
|
|||||||
"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 in Poland.",
|
||||||
"location.nearest": "Nearest IMGW station",
|
"location.nearest": "Nearest IMGW station",
|
||||||
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
|
"location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.",
|
||||||
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
|
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
|
||||||
|
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
|
||||||
|
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||||
|
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
|
||||||
|
"location.heroStationFallback": "Fallback data from IMGW station: {station}",
|
||||||
|
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||||
|
"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…",
|
||||||
@@ -219,7 +244,7 @@ const translations = {
|
|||||||
"location.gpsSelected": "Selected location: {location}.",
|
"location.gpsSelected": "Selected location: {location}.",
|
||||||
"location.gpsAttribution": "GPS place names:",
|
"location.gpsAttribution": "GPS place names:",
|
||||||
"featured.label": "Quick select",
|
"featured.label": "Quick select",
|
||||||
"featured.title": "Selected IMGW stations",
|
"featured.title": "Popular locations",
|
||||||
"favorites.title": "Favourite locations",
|
"favorites.title": "Favourite locations",
|
||||||
"favorites.addStation": "Add {name} to favourites",
|
"favorites.addStation": "Add {name} to favourites",
|
||||||
"favorites.removeStation": "Remove {name} from favourites",
|
"favorites.removeStation": "Remove {name} from favourites",
|
||||||
@@ -228,6 +253,7 @@ const translations = {
|
|||||||
"weather.humidity": "Humidity",
|
"weather.humidity": "Humidity",
|
||||||
"weather.wind": "Wind",
|
"weather.wind": "Wind",
|
||||||
"weather.rainfall": "Rainfall total",
|
"weather.rainfall": "Rainfall total",
|
||||||
|
"weather.rainfall10m": "Rainfall 10 min",
|
||||||
"weather.pressure": "Pressure",
|
"weather.pressure": "Pressure",
|
||||||
"weather.feelsLike": "Feels like",
|
"weather.feelsLike": "Feels like",
|
||||||
"weather.measurement": "measurement",
|
"weather.measurement": "measurement",
|
||||||
@@ -235,6 +261,9 @@ const translations = {
|
|||||||
"weather.calm": "Calm conditions",
|
"weather.calm": "Calm conditions",
|
||||||
"weather.humid": "Humid",
|
"weather.humid": "Humid",
|
||||||
"weather.strongWind": "Strong wind",
|
"weather.strongWind": "Strong wind",
|
||||||
|
"weather.currentRain": "Rain",
|
||||||
|
"weather.currentSnow": "Snow",
|
||||||
|
"weather.thunderstorm": "Thunderstorm",
|
||||||
"weather.airTemperature": "Temperature",
|
"weather.airTemperature": "Temperature",
|
||||||
"weather.windSpeed": "Wind speed",
|
"weather.windSpeed": "Wind speed",
|
||||||
"weather.rainfallTotal": "Rainfall total",
|
"weather.rainfallTotal": "Rainfall total",
|
||||||
@@ -247,7 +276,7 @@ const translations = {
|
|||||||
"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 reading above comes from an IMGW station. The values below are a model forecast.",
|
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||||
"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",
|
||||||
@@ -272,9 +301,11 @@ 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.error": "Unable to load the Open-Meteo forecast.",
|
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
|
||||||
|
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.",
|
||||||
|
"forecast.error": "Unable to load the model forecast.",
|
||||||
"forecast.emptyTitle": "Forecast unavailable",
|
"forecast.emptyTitle": "Forecast unavailable",
|
||||||
"forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.",
|
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
|
||||||
"forecast.condition.clear": "Clear sky",
|
"forecast.condition.clear": "Clear sky",
|
||||||
"forecast.condition.partlyCloudy": "Partly cloudy",
|
"forecast.condition.partlyCloudy": "Partly cloudy",
|
||||||
"forecast.condition.cloudy": "Cloudy",
|
"forecast.condition.cloudy": "Cloudy",
|
||||||
@@ -322,6 +353,13 @@ const translations = {
|
|||||||
"warnings.probability": "Probability: {value}%",
|
"warnings.probability": "Probability: {value}%",
|
||||||
"warnings.genericHydro": "Hydrological warning",
|
"warnings.genericHydro": "Hydrological warning",
|
||||||
"warnings.genericMeteo": "Meteorological warning",
|
"warnings.genericMeteo": "Meteorological warning",
|
||||||
|
"warnings.dashboard.title": "Weather warnings for your region",
|
||||||
|
"warnings.dashboard.active": "Active warning",
|
||||||
|
"warnings.dashboard.upcoming": "Upcoming warning",
|
||||||
|
"warnings.dashboard.validUntil": "Until {date}",
|
||||||
|
"warnings.dashboard.validFrom": "From {date}",
|
||||||
|
"warnings.dashboard.more": "+{count} more warnings",
|
||||||
|
"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 available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Pro
|
|||||||
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
|
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
|
||||||
|
if (a.kind !== b.kind) return a.kind === "meteo" ? -1 : 1;
|
||||||
|
return (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarning[]> {
|
export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
fetchWarningsByKind("meteo", signal),
|
fetchWarningsByKind("meteo", signal),
|
||||||
@@ -65,5 +70,5 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
|
|||||||
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.");
|
||||||
}
|
}
|
||||||
return warnings.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""));
|
return warnings.sort(compareWarnings);
|
||||||
}
|
}
|
||||||
|
|||||||
98
lib/imgw-current-api.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { toNumber } from "@/lib/weather-utils";
|
||||||
|
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
||||||
|
|
||||||
|
function toCelsius(value: unknown) {
|
||||||
|
const temperature = toNumber(value);
|
||||||
|
if (temperature === null) return null;
|
||||||
|
return temperature > 150 ? temperature - 273.15 : temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHectopascals(value: unknown) {
|
||||||
|
const pressure = toNumber(value);
|
||||||
|
if (pressure === null) return null;
|
||||||
|
return pressure > 2_000 ? pressure / 100 : pressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeatherCode(iconCode: unknown) {
|
||||||
|
if (typeof iconCode !== "string") return null;
|
||||||
|
const match = iconCode.match(/z(\d{2})/i);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCondition(weatherCode: number | null, rainfall10m: number | null, snowfall10m: number | null) {
|
||||||
|
if (weatherCode !== null && weatherCode >= 95) return "thunderstorm" as const;
|
||||||
|
if ((snowfall10m ?? 0) > 0) return "snow" as const;
|
||||||
|
if ((rainfall10m ?? 0) > 0) return "rain" as const;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNumericValue(value: unknown) {
|
||||||
|
return toNumber(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
|
||||||
|
return hasNumericValue(candidate.Temperature)
|
||||||
|
&& hasNumericValue(candidate.Chill)
|
||||||
|
&& hasNumericValue(candidate.Humidity)
|
||||||
|
&& hasNumericValue(candidate.Wind_Speed)
|
||||||
|
&& hasNumericValue(candidate.PressureMSL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
|
||||||
|
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
||||||
|
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||||
|
|
||||||
|
const rows = payload.data.Data
|
||||||
|
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
|
||||||
|
if (!candidate || typeof candidate !== "object") return false;
|
||||||
|
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
|
||||||
|
})
|
||||||
|
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
|
||||||
|
const fullRow = rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate))
|
||||||
|
?? rows.find((candidate) => candidate.Type === "Type_Hour" && isFullWeatherRow(candidate));
|
||||||
|
const precipitationRow = fullRow && hasPrecipitationValue(fullRow)
|
||||||
|
? fullRow
|
||||||
|
: rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate));
|
||||||
|
const row = fullRow ?? precipitationRow;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const measuredAt = normalizeDate(row.Date);
|
||||||
|
if (!measuredAt) return null;
|
||||||
|
const precipitationSource = precipitationRow ?? row;
|
||||||
|
const rainfall10m = toNumber(precipitationSource.Rain10m);
|
||||||
|
const snowfall10m = toNumber(precipitationSource.Snow10m);
|
||||||
|
const weatherCode = getWeatherCode(row.Icon10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||||
|
measuredAt,
|
||||||
|
temperature: toCelsius(row.Temperature),
|
||||||
|
feelsLike: toCelsius(row.Chill),
|
||||||
|
windSpeed: toNumber(row.Wind_Speed),
|
||||||
|
windDirection: toNumber(row.Wind_Dir),
|
||||||
|
humidity: toNumber(row.Humidity),
|
||||||
|
pressure: toHectopascals(row.PressureMSL),
|
||||||
|
precipitation10m: toNumber(precipitationSource.Precipitation10m),
|
||||||
|
rainfall10m,
|
||||||
|
snowfall10m,
|
||||||
|
cloudCover: toNumber(row.Cloud),
|
||||||
|
weatherCode,
|
||||||
|
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImgwCurrentWeather(latitude: number, longitude: number, signal?: AbortSignal) {
|
||||||
|
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||||
|
const response = await fetch(`/api/imgw-current?${params}`, { signal });
|
||||||
|
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
|
||||||
|
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
|
||||||
|
}
|
||||||
@@ -128,6 +128,10 @@ 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) {
|
||||||
|
return normalizeProvinceName(locationProvince) ?? getProvinceForStation(stationId);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatProvinceName(province: Province, language: Language) {
|
export function formatProvinceName(province: Province, language: Language) {
|
||||||
return provinceLabels[province][language];
|
return provinceLabels[province][language];
|
||||||
}
|
}
|
||||||
|
|||||||
4
lib/theme.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const APP_THEME_COLORS = {
|
||||||
|
light: "#eef3f7",
|
||||||
|
dark: "#171d25",
|
||||||
|
} as const;
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
} from "@/types/imgw";
|
} from "@/types/imgw";
|
||||||
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";
|
||||||
|
|
||||||
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
|
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
|
||||||
|
|
||||||
@@ -174,19 +175,11 @@ export function getWeatherMoodFromData(station: SynopStation, date = new Date())
|
|||||||
return "mild";
|
return "mild";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeatherDescription(station: SynopStation, language: Language = "pl") {
|
export function getWeatherDescription(station: SynopStation, language: Language = "pl", condition?: CurrentWeatherCondition) {
|
||||||
|
if (condition === "thunderstorm") return translate(language, "weather.thunderstorm");
|
||||||
|
if (condition === "snow") return translate(language, "weather.currentSnow");
|
||||||
|
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");
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moodGradient(mood: WeatherMood) {
|
|
||||||
return {
|
|
||||||
warm: "from-sky-400 via-blue-500 to-indigo-700",
|
|
||||||
cloudy: "from-slate-600 via-slate-700 to-slate-900",
|
|
||||||
wind: "from-cyan-600 via-slate-600 to-blue-950",
|
|
||||||
cold: "from-cyan-300 via-blue-500 to-indigo-900",
|
|
||||||
night: "from-slate-800 via-indigo-950 to-slate-950",
|
|
||||||
mild: "from-sky-500 via-cyan-700 to-blue-900",
|
|
||||||
}[mood];
|
|
||||||
}
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,12 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="wtr.">
|
||||||
<defs>
|
<rect width="512" height="512" rx="120" fill="#171d25"/>
|
||||||
<linearGradient id="g" x1="70" y1="44" x2="450" y2="484" gradientUnits="userSpaceOnUse">
|
<rect x="22" y="22" width="468" height="468" rx="102" fill="none" stroke="#eef3f7" stroke-opacity=".16" stroke-width="2"/>
|
||||||
<stop stop-color="#38bdf8"/>
|
<text x="62" y="318" fill="#eef3f7" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr</text>
|
||||||
<stop offset=".52" stop-color="#2563eb"/>
|
<circle cx="382" cy="295" r="18" fill="#8fb4ce"/>
|
||||||
<stop offset="1" stop-color="#312e81"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" rx="142" fill="url(#g)"/>
|
|
||||||
<circle cx="388" cy="138" r="78" fill="#fff" fill-opacity=".12"/>
|
|
||||||
<text x="62" y="318" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr.</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 482 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -1,12 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="wtr.">
|
||||||
<defs>
|
<rect width="512" height="512" fill="#171d25"/>
|
||||||
<linearGradient id="g" x1="40" y1="20" x2="480" y2="500" gradientUnits="userSpaceOnUse">
|
<rect x="44" y="44" width="424" height="424" rx="96" fill="#1f2630"/>
|
||||||
<stop stop-color="#38bdf8"/>
|
<rect x="68" y="68" width="376" height="376" rx="78" fill="none" stroke="#eef3f7" stroke-opacity=".14" stroke-width="2"/>
|
||||||
<stop offset=".55" stop-color="#2563eb"/>
|
<text x="84" y="318" fill="#eef3f7" font-family="Arial, Helvetica, sans-serif" font-size="172" font-weight="700" letter-spacing="-26">wtr</text>
|
||||||
<stop offset="1" stop-color="#312e81"/>
|
<circle cx="386" cy="296" r="17" fill="#8fb4ce"/>
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" fill="url(#g)"/>
|
|
||||||
<circle cx="392" cy="124" r="90" fill="#fff" fill-opacity=".12"/>
|
|
||||||
<text x="62" y="318" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr.</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 544 B |
@@ -4,8 +4,8 @@
|
|||||||
"description": "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.",
|
"description": "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#07111f",
|
"background_color": "#171d25",
|
||||||
"theme_color": "#0c4a6e",
|
"theme_color": "#171d25",
|
||||||
"lang": "pl",
|
"lang": "pl",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "wtr-shell-v1";
|
const CACHE_NAME = "wtr-shell-v3";
|
||||||
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -16,7 +16,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
if (url.pathname.startsWith("/api/imgw/")) {
|
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
@@ -10,11 +10,28 @@ export default {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
surface: "hsl(var(--surface) / <alpha-value>)",
|
||||||
|
"surface-muted": "hsl(var(--surface-muted) / <alpha-value>)",
|
||||||
|
"surface-raised": "hsl(var(--surface-raised) / <alpha-value>)",
|
||||||
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
|
muted: "hsl(var(--muted) / <alpha-value>)",
|
||||||
|
accent: "hsl(var(--accent) / <alpha-value>)",
|
||||||
|
warning: "hsl(var(--warning) / <alpha-value>)",
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||||
},
|
},
|
||||||
|
borderRadius: {
|
||||||
|
panel: "1.5rem",
|
||||||
|
card: "1.25rem",
|
||||||
|
control: "9999px",
|
||||||
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
glass: "0 18px 55px rgba(15, 23, 42, 0.13)",
|
card: "0 16px 42px hsl(215 32% 18% / 0.08)",
|
||||||
|
soft: "0 10px 28px hsl(215 32% 18% / 0.06)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ export interface RawWeatherForecast {
|
|||||||
daily?: RawForecastSeries;
|
daily?: RawForecastSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawImgwForecastRow {
|
||||||
|
Date?: unknown;
|
||||||
|
Temperature?: unknown;
|
||||||
|
Chill?: unknown;
|
||||||
|
Precipitation?: unknown;
|
||||||
|
Icon?: unknown;
|
||||||
|
Wind_Speed?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawImgwForecastResponse {
|
||||||
|
data?: {
|
||||||
|
Data?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForecastSource = "imgw-alaro" | "open-meteo";
|
||||||
|
|
||||||
export interface HourlyForecast {
|
export interface HourlyForecast {
|
||||||
time: string;
|
time: string;
|
||||||
temperature: number | null;
|
temperature: number | null;
|
||||||
@@ -30,6 +47,7 @@ export interface HourlyForecast {
|
|||||||
precipitation: number | null;
|
precipitation: number | null;
|
||||||
weatherCode: number | null;
|
weatherCode: number | null;
|
||||||
windSpeed: number | null;
|
windSpeed: number | null;
|
||||||
|
source: ForecastSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyForecast {
|
export interface DailyForecast {
|
||||||
@@ -41,6 +59,7 @@ export interface DailyForecast {
|
|||||||
weatherCode: number | null;
|
weatherCode: number | null;
|
||||||
sunrise: string | null;
|
sunrise: string | null;
|
||||||
sunset: string | null;
|
sunset: string | null;
|
||||||
|
sources: ForecastSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherForecast {
|
export interface WeatherForecast {
|
||||||
@@ -49,4 +68,5 @@ export interface WeatherForecast {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
hourly: HourlyForecast[];
|
hourly: HourlyForecast[];
|
||||||
daily: DailyForecast[];
|
daily: DailyForecast[];
|
||||||
|
sources: ForecastSource[];
|
||||||
}
|
}
|
||||||
|
|||||||
43
types/imgw-current.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface RawImgwHybridWeatherRow {
|
||||||
|
Icon10?: unknown;
|
||||||
|
Wind_Dir?: unknown;
|
||||||
|
Temperature?: unknown;
|
||||||
|
Chill?: unknown;
|
||||||
|
Rain10m?: unknown;
|
||||||
|
Snow10m?: unknown;
|
||||||
|
Wind_Speed?: unknown;
|
||||||
|
MODEL?: unknown;
|
||||||
|
Date?: unknown;
|
||||||
|
Precipitation10m?: unknown;
|
||||||
|
Type?: unknown;
|
||||||
|
Humidity?: unknown;
|
||||||
|
Cloud?: unknown;
|
||||||
|
PressureMSL?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawImgwHybridWeatherResponse {
|
||||||
|
data?: {
|
||||||
|
Valid?: unknown;
|
||||||
|
Data?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
|
||||||
|
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only";
|
||||||
|
|
||||||
|
export interface ImgwCurrentWeather {
|
||||||
|
coverage: ImgwCurrentWeatherCoverage;
|
||||||
|
measuredAt: string;
|
||||||
|
temperature: number | null;
|
||||||
|
feelsLike: number | null;
|
||||||
|
windSpeed: number | null;
|
||||||
|
windDirection: number | null;
|
||||||
|
humidity: number | null;
|
||||||
|
pressure: number | null;
|
||||||
|
precipitation10m: number | null;
|
||||||
|
rainfall10m: number | null;
|
||||||
|
snowfall10m: number | null;
|
||||||
|
cloudCover: number | null;
|
||||||
|
weatherCode: number | null;
|
||||||
|
condition: CurrentWeatherCondition;
|
||||||
|
}
|
||||||