Compare commits

...

16 Commits

52 changed files with 965 additions and 386 deletions

View File

@@ -2,7 +2,7 @@
## Projekt ## Projekt
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz 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,12 +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.
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji. - Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
- Route handler prognozy pobiera 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. - 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.
@@ -57,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`.

View File

@@ -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żącą analizę pogody IMGW Hybrid, 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
@@ -47,6 +47,7 @@ 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` - 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/`
@@ -55,7 +56,7 @@ 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ą.
@@ -65,19 +66,50 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i
## Ograniczenia API ## Ograniczenia API
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Dzięki temu bieżące warunki są analizowane dla współrzędnych wybranej miejscowości, aktualizowane częściej niż godzinowe dane synoptyczne i mogą pokazać rzeczywisty opad z ostatnich 10 minut. Interfejs oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Jeśli usługa Hybrid nie odpowiada, hero zachowuje pomiar `synop` jako jawnie oznaczony fallback i ostrzega, gdy stacja jest oddalona od miejscowości o co najmniej 30 km. 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. 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 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 oraz bieżącej analizy Hybrid. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. 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

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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 },
], ],
}; };

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -14,6 +14,7 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useCurrentWeather } from "@/hooks/use-current-weather"; 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();
@@ -41,6 +42,7 @@ export function DashboardPage() {
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> <WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<DashboardWarnings />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} /> <ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<FavoritesSection stations={stations} /> <FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} /> <FeaturedStationsSection stations={stations} />

View File

@@ -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;
} }

View File

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

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

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

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

View File

@@ -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>
); );

View File

@@ -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() {

View File

@@ -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" },

View File

@@ -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} />;
} }

View File

@@ -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>

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

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );

View File

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

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,95 +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}%`,
delay: index * 0.22,
width: 70 + (index % 3) * 34,
}));
const stars = Array.from({ length: 16 }, (_, index) => ({
left: `${(index * 37 + 11) % 96}%`,
top: `${(index * 23 + 8) % 72}%`,
delay: (index % 6) * 0.35,
}));
const rainDrops = Array.from({ length: 22 }, (_, index) => ({
left: `${(index * 43 + 7) % 101}%`, left: `${(index * 43 + 7) % 101}%`,
delay: (index % 9) * 0.18, delay: (index % 9) * 0.18,
duration: 0.8 + (index % 4) * 0.14, duration: 1.1 + (index % 4) * 0.18,
})); }));
export function WeatherEffects({ station, mood, precipitation10m, thunderstorm = false }: { station: SynopStation; mood: WeatherMood; precipitation10m?: number | null; thunderstorm?: boolean }) { export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const isWindy = (station.windSpeed ?? 0) >= 8;
const isRaining = (precipitation10m ?? 0) > 0; const isRaining = (precipitation10m ?? 0) > 0;
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden">
{mood === "cloudy" && (
<>
<motion.div
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
className="absolute -left-24 -top-20 h-52 w-[78%] rounded-[50%] bg-slate-100/30 blur-3xl"
/>
<motion.div
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"
/>
<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
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-16 -top-20 size-64 rounded-full bg-amber-200/45 blur-3xl"
/>
)}
{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 }}
/>
))}
{isRaining && rainDrops.map((drop, index) => ( {isRaining && rainDrops.map((drop, index) => (
<motion.span <motion.span
key={`rain-${index}`} key={`rain-${index}`}
initial={{ y: "-12vh", opacity: 0 }} initial={{ y: "-12vh", opacity: 0 }}
animate={reduceMotion ? { opacity: 0.36 } : { y: ["-12vh", "115vh"], opacity: [0, 0.55, 0] }} animate={reduceMotion ? { opacity: 0.18 } : { y: ["-12vh", "115vh"], opacity: [0, 0.22, 0] }}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }} transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className="absolute -top-8 h-14 w-px rotate-[8deg] rounded-full bg-gradient-to-b from-transparent via-white/55 to-transparent blur-[0.35px]" className="absolute -top-8 h-10 w-px rotate-[8deg] rounded-full bg-foreground/35"
style={{ left: drop.left }} style={{ left: drop.left }}
/> />
))} ))}
{thunderstorm && ( {thunderstorm && (
<motion.div <motion.div
animate={reduceMotion ? { opacity: 0.12 } : { opacity: [0, 0, 0.34, 0, 0.18, 0] }} animate={reduceMotion ? { opacity: 0.08 } : { opacity: [0, 0, 0.16, 0, 0.08, 0] }}
transition={{ duration: 6, repeat: Infinity, repeatDelay: 2.5 }} transition={{ duration: 6, repeat: Infinity, repeatDelay: 2.5 }}
className="absolute inset-0 bg-white" className="absolute inset-0 bg-foreground"
/> />
)} )}
{mood === "cold" && (
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-cyan-100/20 to-transparent" />
)}
</div> </div>
); );
} }

View File

@@ -12,34 +12,47 @@ 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 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";
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 }) { 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 displayedLocationName = locationName ?? station.name; const displayedLocationName = locationName ?? station.name;
const hasDistantFallback = !currentWeather && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
const displayedStation = currentWeather ? { const displayedStation = currentWeather ? {
...station, ...station,
measuredAt: currentWeather.measuredAt, measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature, temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed, windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection, windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity, humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure, pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m, rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station; } : station;
const mood = getWeatherMoodFromData(displayedStation); const mood = getWeatherMoodFromData(displayedStation);
const moodAccent = moodAccentClass(mood);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [ const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) }, { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) },
{ icon: Umbrella, label: currentWeather ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.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(displayedStation.pressure, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
]; ];
@@ -48,46 +61,51 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
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={displayedStation} mood={mood} precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} /> <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> <div>
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{displayedLocationName}</span> <div className="flex flex-wrap items-center gap-2">
<div className="mt-1.5 space-y-1 text-xs text-white/65"> <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 <p>{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name }) ? t("location.heroHybridLoading", { station: station.name })
: currentWeather : hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName }) ? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName : locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 }) ? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: t("location.heroStationFallback", { station: station.name })}</p> : t("location.heroStationFallback", { station: station.name })}</p>
{currentWeather && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>} {hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
{hasDistantFallback && <p className="flex items-start gap-1.5 text-amber-100"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>} {hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
</div> </div>
</div> </div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10"> <div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<div> <div>
<div className="text-[5.8rem] font-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(displayedStation.temperature, language)} {formatTemperature(displayedStation.temperature, language)}
</div> </div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p> <p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{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(displayedStation.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} condition={currentWeather?.condition} 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>
{displayedStation.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(${displayedStation.windDirection}deg)` }} /> <Navigation className="size-3.5" style={{ transform: `rotate(${displayedStation.windDirection}deg)` }} />
{t("weather.windDirection")}: {displayedStation.windDirection}° {t("weather.windDirection")}: {displayedStation.windDirection}°
</p> </p>

10
lib/chart-theme.ts Normal file
View 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;

View File

@@ -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
View 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"],
};
}

View File

@@ -33,9 +33,10 @@ 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}: bieżąca pogoda jest analizowana lokalnie dla współrzędnych miejscowości. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", "location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.",
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}", "location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
"location.heroNearestStation": "Najbliższa stacja pomiarowa 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.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km", "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
@@ -57,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",
@@ -89,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żące warunki powyżej pochodzą z 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",
@@ -114,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",
@@ -164,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.",
@@ -209,9 +219,10 @@ 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}: current weather is analysed locally for the place coordinates. Nearest IMGW measurement station: {station} · approximately {distance} km away.", "location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.",
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}", "location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroNearestStation": "Nearest IMGW measurement 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.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away", "location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",
@@ -233,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",
@@ -265,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 conditions above come from IMGW. 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",
@@ -290,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",
@@ -340,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.",

View File

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

View File

@@ -32,27 +32,48 @@ function getCondition(weatherCode: number | null, rainfall10m: number | null, sn
return null; 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 { export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const row = payload.data.Data const rows = payload.data.Data
.filter((candidate): candidate is RawImgwHybridWeatherRow => { .filter((candidate): candidate is RawImgwHybridWeatherRow => {
if (!candidate || typeof candidate !== "object") return false; if (!candidate || typeof candidate !== "object") return false;
return candidate.Type === "Type_Ten_Minutes" return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
&& typeof candidate.MODEL === "string"
&& candidate.MODEL.includes("AROME")
&& normalizeDate(candidate.Date) !== null;
}) })
.sort((left, right) => String(right.Date).localeCompare(String(left.Date)))[0]; .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; if (!row) return null;
const measuredAt = normalizeDate(row.Date); const measuredAt = normalizeDate(row.Date);
if (!measuredAt) return null; if (!measuredAt) return null;
const rainfall10m = toNumber(row.Rain10m); const precipitationSource = precipitationRow ?? row;
const snowfall10m = toNumber(row.Snow10m); const rainfall10m = toNumber(precipitationSource.Rain10m);
const snowfall10m = toNumber(precipitationSource.Snow10m);
const weatherCode = getWeatherCode(row.Icon10); const weatherCode = getWeatherCode(row.Icon10);
return { return {
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
measuredAt, measuredAt,
temperature: toCelsius(row.Temperature), temperature: toCelsius(row.Temperature),
feelsLike: toCelsius(row.Chill), feelsLike: toCelsius(row.Chill),
@@ -60,7 +81,7 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
windDirection: toNumber(row.Wind_Dir), windDirection: toNumber(row.Wind_Dir),
humidity: toNumber(row.Humidity), humidity: toNumber(row.Humidity),
pressure: toHectopascals(row.PressureMSL), pressure: toHectopascals(row.PressureMSL),
precipitation10m: toNumber(row.Precipitation10m), precipitation10m: toNumber(precipitationSource.Precipitation10m),
rainfall10m, rainfall10m,
snowfall10m, snowfall10m,
cloudCover: toNumber(row.Cloud), cloudCover: toNumber(row.Cloud),

View File

@@ -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
View File

@@ -0,0 +1,4 @@
export const APP_THEME_COLORS = {
light: "#eef3f7",
dark: "#171d25",
} as const;

View File

@@ -183,14 +183,3 @@ export function getWeatherDescription(station: SynopStation, language: Language
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];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -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

View File

@@ -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": [

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "wtr-shell-v2"; 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/") || url.pathname === "/api/imgw-current") { 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) => {

View File

@@ -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)",
}, },
}, },
}, },

View File

@@ -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[];
} }

View File

@@ -23,8 +23,10 @@ export interface RawImgwHybridWeatherResponse {
} }
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only";
export interface ImgwCurrentWeather { export interface ImgwCurrentWeather {
coverage: ImgwCurrentWeatherCoverage;
measuredAt: string; measuredAt: string;
temperature: number | null; temperature: number | null;
feelsLike: number | null; feelsLike: number | null;