Compare commits

...

18 Commits

55 changed files with 1216 additions and 384 deletions

View File

@@ -2,7 +2,7 @@
## Projekt
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
@@ -36,10 +36,15 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
- Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`.
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
- 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.
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
- 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.
- 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.
@@ -55,6 +60,36 @@ Obsługa błędów:
- 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`.
## 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
- 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.**
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżące odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
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.
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
@@ -46,6 +46,8 @@ npm run start
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
- bieżąca analiza IMGW Hybrid używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi`
- prognoza godzinowa IMGW ALARO używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro`
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
- dane hydrologiczne: `https://danepubliczne.imgw.pl/api/data/hydro/`
@@ -54,27 +56,60 @@ Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
- 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ą.
Opcjonalny reverse geocoding dla GPS korzysta z publicznego endpointu Nominatim: `https://nominatim.openstreetmap.org/reverse`. Wywołanie następuje wyłącznie po zgodzie użytkownika. Interfejs pokazuje atrybucję OpenStreetMap. Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użycia publicznej instancji Nominatim lub zastąpić ją własną usługą.
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`.
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Bieżącą analizę pogody obsługuje `app/api/imgw-current/route.ts`, prognozę `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`.
## Ograniczenia API
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu.
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
```text
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/dashboard dashboard aplikacji
components/weather/ hero, stacje, metryki i szczegóły

View File

@@ -1,6 +1,13 @@
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) {
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;
}
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) {
const { searchParams } = new URL(request.url);
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 });
}
const params = new URLSearchParams({
const openMeteoParams = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
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",
wind_speed_unit: "ms",
});
const imgwParams = new URLSearchParams({
token: IMGW_FORECAST_TOKEN,
lat: String(latitude),
lon: String(longitude),
m: "alaro",
});
try {
const response = await fetch(`${FORECAST_URL}?${params}`, { next: { revalidate: 900 } });
if (!response.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
return NextResponse.json(await response.json(), {
const [openMeteoResponse, imgwResponse] = await Promise.all([
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
]);
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" },
});
} catch {

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
const IMGW_HYBRID_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
// This browser token is published by the official meteo.imgw.pl frontend.
const IMGW_HYBRID_TOKEN = "p4DXKjsYadfBV21TYrDk";
function parseCoordinate(value: string | null, min: number, max: number) {
if (!value?.trim()) return null;
const coordinate = Number(value);
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
}
const params = new URLSearchParams({
token: IMGW_HYBRID_TOKEN,
lat: String(latitude),
lon: String(longitude),
m: "hybrid",
});
try {
const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } });
if (!response.ok) return NextResponse.json({ error: "IMGW Hybrid service is unavailable." }, { status: 502 });
return NextResponse.json(await response.json(), {
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
});
} catch {
return NextResponse.json({ error: "IMGW Hybrid service is unavailable." }, { status: 502 });
}
}

View File

@@ -4,14 +4,40 @@
:root {
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 {
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 {
@@ -21,14 +47,14 @@ html {
body {
min-height: 100vh;
background: #eef5fb;
color: #102238;
background: hsl(var(--background));
color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased;
}
.dark body {
background: #07111f;
color: #edf7ff;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
button,
@@ -40,11 +66,19 @@ select {
@layer utilities {
.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 {
@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 {
@@ -54,11 +88,18 @@ select {
.weather-scrollbar {
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) {
.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;
}
}
@@ -70,28 +111,27 @@ select {
.weather-scrollbar::-webkit-scrollbar-track {
border-radius: 9999px;
background: rgba(14, 116, 144, 0.1);
background: hsl(var(--border) / 0.28);
}
.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;
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;
box-shadow: 0 1px 5px rgba(8, 47, 73, 0.25);
}
.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;
}
.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 {
border-color: rgba(255, 255, 255, 0.18);
background: linear-gradient(90deg, rgba(34, 211, 238, 0.72), rgba(56, 189, 248, 0.82));
border-color: hsl(var(--surface) / 0.72);
background: hsl(var(--accent) / 0.7);
background-clip: padding-box;
}

View File

@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import Script from "next/script";
import { AppShell } from "@/components/layout/app-shell";
import { Providers } from "@/components/layout/providers";
import { APP_THEME_COLORS } from "@/lib/theme";
import "@/app/globals.css";
const inter = Inter({ subsets: ["latin", "latin-ext"], variable: "--font-inter" });
@@ -36,8 +37,8 @@ export const viewport: Viewport = {
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#e8f4fb" },
{ media: "(prefers-color-scheme: dark)", color: "#07111f" },
{ media: "(prefers-color-scheme: light)", color: APP_THEME_COLORS.light },
{ 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() {
const { t } = useI18n();
return (
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] 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>
<section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent"><WifiOff className="size-6" /></div>
<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>
<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>
<p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p>
<Link href="/" className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">{t("offline.back")}</Link>
</section>
);
}

View File

@@ -2,10 +2,13 @@
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card } from "@/components/ui/card";
import { CHART_COLORS } from "@/lib/chart-theme";
import { useI18n } from "@/lib/i18n";
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
import type { HourlyForecast } from "@/types/forecast";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
function formatHour(value: string) {
return value.slice(11, 16);
}
@@ -24,20 +27,20 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<div className="grid gap-3 lg:grid-cols-2">
<Card className="p-4 sm:p-5">
<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>
<div className="mt-4 h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
<div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<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 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
<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)]}
/>
<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="feelsLike" name={t("forecast.apparentTemperature")} stroke="#818cf8" strokeWidth={2} strokeDasharray="5 4" 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={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -45,16 +48,16 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<Card className="p-4 sm:p-5">
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<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 }} />
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
<Tooltip
contentStyle={{ borderRadius: 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) => [
name === t("forecast.precipitation")
? formatForecastRainfall(typeof value === "number" ? value : null, language)
@@ -63,8 +66,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill="#38bdf8" radius={[5, 5, 0, 0]} />
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke="#6366f1" strokeWidth={2} dot={false} connectNulls />
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</div>

View File

@@ -5,25 +5,32 @@ import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card";
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 }) {
const { t } = useI18n();
const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" },
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: "#22d3ee" },
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] },
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
return (
<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>
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
<div className="mt-5 h-52 w-full">
<ResponsiveContainer width="100%" height="100%">
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<div className="mt-5 h-52 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
<Tooltip cursor={{ fill: "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}>
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
</Bar>

View File

@@ -11,8 +11,10 @@ import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
import { ErrorState } from "@/components/states/error-state";
import { useI18n } from "@/lib/i18n";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useCurrentWeather } from "@/hooks/use-current-weather";
import { ForecastPanel } from "@/components/forecast/forecast-panel";
import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
export function DashboardPage() {
const { t } = useI18n();
@@ -20,23 +22,28 @@ export function DashboardPage() {
const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
if (isPending) return <PageLoadingSkeleton />;
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
const selectedStation = stations.find((station) => station.id === selectedStationId)
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
?? stations[0];
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
const stationPosition = locateSynopStations(stations, positions).find((station) => station.id === selectedStation.id);
const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
?? stations?.[0];
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null;
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation.name : selectedStation.name;
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude);
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
if (isPending) return <PageLoadingSkeleton />;
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
return (
<div className="space-y-10">
<LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<DashboardWarnings />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} />
</div>

View File

@@ -1,10 +1,12 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
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 { ForecastIcon } from "@/components/forecast/forecast-icon";
import { ForecastSources } from "@/components/forecast/forecast-sources";
import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
@@ -15,7 +17,7 @@ import {
getForecastCondition,
isForecastHourPast,
} from "@/lib/forecast-utils";
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
function formatHour(value: string | null) {
if (!value) return "—";
@@ -31,9 +33,9 @@ function getMaximumWind(hours: HourlyForecast[]) {
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return (
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</p>
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
<Icon className="size-4 text-accent" />
<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>
</div>
);
@@ -43,15 +45,18 @@ export function DayForecastModal({
day,
hours,
locationName,
sources,
onClose,
}: {
day: DailyForecast | null;
hours: HourlyForecast[];
locationName: string;
sources: ForecastSource[];
onClose: () => void;
}) {
const { language, locale, t } = useI18n();
const closeButtonRef = useRef<HTMLButtonElement>(null);
const portalRoot = typeof document === "undefined" ? null : document.body;
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
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`))
: "";
return (
const modal = (
<AnimatePresence>
{day ? (
<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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -91,7 +96,7 @@ export function DayForecastModal({
role="dialog"
aria-modal="true"
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 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
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="flex items-start justify-between gap-4">
<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" />
{t("forecast.dayDetails")}
</p>
<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>
<button
ref={closeButtonRef}
type="button"
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}
>
<X className="size-5" />
</button>
</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>
<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">
<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="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 className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
@@ -148,15 +153,15 @@ export function DayForecastModal({
<li
key={hour.time}
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",
)}
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>
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<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="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" />
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
</p>
@@ -169,16 +174,13 @@ export function DayForecastModal({
<DayForecastCharts hours={hours} />
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{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>
<ForecastSources sources={sources} />
</div>
</motion.section>
</motion.div>
) : null}
</AnimatePresence>
);
return portalRoot ? createPortal(modal, portalRoot) : null;
}

View File

@@ -2,10 +2,11 @@
import { useCallback, useState } from "react";
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 { DayForecastModal } from "@/components/forecast/day-forecast-modal";
import { ForecastIcon } from "@/components/forecast/forecast-icon";
import { ForecastSources } from "@/components/forecast/forecast-sources";
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
import { EmptyState } from "@/components/states/empty-state";
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 }) {
return (
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">{label}</p>
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
<Icon className="size-4 text-accent" />
<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>
</div>
);
@@ -68,8 +69,8 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
return (
<div className="mt-auto hidden border-t border-white/30 pt-4 dark:border-white/10 lg:block">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{t("forecast.nextHoursOverview")}</p>
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
<div className="mt-3 grid grid-cols-4 gap-2">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
@@ -88,23 +89,23 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
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
type="button"
whileTap={{ scale: 0.99 }}
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)}
>
<p className="text-sm font-semibold capitalize">{label}</p>
<div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
</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>
<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>
<ChevronRight className="hidden size-4 text-slate-400 sm:block" />
<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-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
<ChevronRight className="hidden size-4 text-muted sm:block" />
</motion.button>
</motion.li>
);
@@ -122,9 +123,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
return (
<section className="space-y-3">
<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>
<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>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -134,7 +135,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
</div>
) : isError || !forecast ? (
<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>
</Card>
) : !forecast.hourly.length || !forecast.daily.length ? (
@@ -143,7 +144,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<div className="space-y-3">
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-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">
<ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => (
@@ -152,24 +153,24 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
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)}
>
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<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="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>
<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">
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
<ThermometerSun className="size-3 text-sky-600 dark:text-sky-300" />
<ThermometerSun className="size-3 text-accent" />
{formatForecastTemperature(hour.feelsLike, language)}
</p>
<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)}
</p>
<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)}
</p>
</div>
@@ -180,7 +181,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<HourlyForecastSummary hours={upcomingHours} />
</Card>
<Card className="p-4 sm:p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-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>
</Card>
</div>
@@ -188,11 +189,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
</div>
)}
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{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>
{forecast && <ForecastSources sources={forecast.sources} />}
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} onClose={closeDayDetails} />
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
</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 (
<div className="space-y-5">
<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>
<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>
<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>
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
<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" />
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" />
</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")} /> : (
<>
<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();
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 }}>
<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>
<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 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={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
</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>
</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 }) {
return (
<div className="rounded-2xl bg-white/35 p-2.5 dark:bg-white/5">
<p className="flex items-center gap-1 text-[0.65rem] text-slate-500 dark:text-slate-400"><Icon className="size-3" />{label}</p>
<div className="rounded-card bg-surface-muted/60 p-2.5">
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
</div>
);

View File

@@ -16,17 +16,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { t } = useI18n();
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%)]">
<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">
<div className="min-h-screen overflow-x-hidden bg-background">
<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">
<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">
wtr<span className="text-sky-600 dark:text-sky-300">.</span>
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
wtr<span className="text-accent">.</span>
</Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
{NAV_ITEMS.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
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)}
</Link>
);
@@ -40,12 +40,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
</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>
<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) => {
const Icon = icons[index];
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
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" />
{t(item.labelKey)}
</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 }) {
return (
<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>
<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>
);
}

View File

@@ -9,9 +9,9 @@ export function ErrorState({ title, description, onRetry }: { title?: string; de
const { t } = useI18n();
return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-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>
<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>
</Card>
);

View File

@@ -5,7 +5,7 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) {
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() {

View File

@@ -3,14 +3,14 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
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: {
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",
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",
ghost: "px-3 py-2 text-slate-700 hover:bg-white/50 dark:text-slate-200 dark:hover:bg-white/10",
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",
default: "bg-foreground px-4 py-2.5 text-background hover:opacity-90",
glass: "surface-control px-4 py-2.5 text-foreground hover:bg-surface-raised/90",
ghost: "px-3 py-2 text-muted hover:bg-surface-muted/70 hover:text-foreground",
icon: "surface-control size-10 text-foreground hover:bg-surface-raised/90",
},
},
defaultVariants: { variant: "default" },

View File

@@ -2,5 +2,5 @@ import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
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 (
<label className="relative flex items-center">
<span className="sr-only">{t("language.label")}</span>
<Languages className="pointer-events-none absolute left-3 size-4 text-slate-700 dark:text-slate-200" />
<Languages className="pointer-events-none absolute left-3 size-4 text-muted" />
<select
aria-label={t("language.label")}
value={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="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 }}>
<Card className="h-full overflow-hidden p-5">
<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>
<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>
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
</div>
<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>
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-slate-600 dark:text-slate-300">{warning.description}</p>}
<div className="mt-5 space-y-2 text-xs text-slate-500 dark:text-slate-400">
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
<div className="mt-5 space-y-2 text-xs text-muted">
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
</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>
</motion.article>
);

View File

@@ -8,9 +8,9 @@ export function WarningsPageContent() {
return (
<div className="space-y-5">
<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>
<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>
<WarningsPanel />
</div>

View File

@@ -8,7 +8,7 @@ import { EmptyState } from "@/components/states/empty-state";
import { ErrorState } from "@/components/states/error-state";
import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n";
import { formatProvinceName, getProvinceForStation, normalizeProvinceName } from "@/lib/provinces";
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store";
import type { WeatherWarning } from "@/types/imgw";
@@ -29,8 +29,7 @@ export function WarningsPanel() {
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
const province = normalizeProvinceName(selectedLocation?.province)
?? getProvinceForStation(selectedStationId ?? DEFAULT_STATION_ID);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language);
@@ -41,9 +40,9 @@ export function WarningsPanel() {
<div className="space-y-9">
<section className="space-y-4">
<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>
<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>
{localWarnings.length
? <WarningGrid warnings={localWarnings} />
@@ -53,8 +52,8 @@ export function WarningsPanel() {
{otherWarnings.length > 0 && (
<section className="space-y-4">
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400"><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="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
</div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
</section>

View File

@@ -100,13 +100,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
return (
<div className="mt-3 space-y-2">
{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="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">
<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>
{!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>}
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
{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 ? t("location.gpsLocating") : t("location.gpsUse")}
</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>
)}
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{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>
<p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
</p>
</div>
);

View File

@@ -18,7 +18,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return (
<section className="space-y-3">
<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>
</div>
<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"
key={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="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>
<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>
);
})}

View File

@@ -26,14 +26,14 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
return (
<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">
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
<MapPin className="size-4 text-accent" />
<p className="section-kicker">{t("location.label")}</p>
</div>
<label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-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
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -41,25 +41,25 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
placeholder={t("location.placeholder")}
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>
<CurrentLocationControl stations={locatedStations} />
{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 })}
</p>
)}
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
{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>
<p className="mt-3 px-1 text-[0.68rem] text-muted">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
</p>
</div>
{showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{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> :
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
suggestions.map(({ location, nearest }) => nearest && (
<button
type="button"
@@ -69,13 +69,13 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
setQuery("");
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 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 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>
))}
</div>

View File

@@ -8,12 +8,12 @@ export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { ic
return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
<Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
<Icon className="size-4 text-sky-600 dark:text-sky-300" />
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" />
{label}
</div>
<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>
</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`;
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 }}>
<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">
<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="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
</Link>
<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)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button>
</div>
</div>
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-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"><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>

View File

@@ -27,7 +27,7 @@ export function StationDetailPage({ id }: { id: string }) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-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)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")}
@@ -35,20 +35,20 @@ export function StationDetailPage({ id }: { id: string }) {
</div>
<WeatherHero station={station} />
<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>
<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} />
</section>
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} />
<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>
<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">
<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-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.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
</dl>
</Card>
</div>

View File

@@ -1,70 +1,35 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import type { SynopStation, WeatherMood } from "@/types/imgw";
const windLines = Array.from({ length: 7 }, (_, index) => ({
top: `${18 + index * 11}%`,
delay: index * 0.22,
width: 70 + (index % 3) * 34,
const rainDrops = Array.from({ length: 12 }, (_, index) => ({
left: `${(index * 43 + 7) % 101}%`,
delay: (index % 9) * 0.18,
duration: 1.1 + (index % 4) * 0.18,
}));
const stars = Array.from({ length: 16 }, (_, index) => ({
left: `${(index * 37 + 11) % 96}%`,
top: `${(index * 23 + 8) % 72}%`,
delay: (index % 6) * 0.35,
}));
export function WeatherEffects({ station, mood }: { station: SynopStation; mood: WeatherMood }) {
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
const reduceMotion = useReducedMotion();
const isWindy = (station.windSpeed ?? 0) >= 8;
const isRaining = (precipitation10m ?? 0) > 0;
return (
<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) => (
{isRaining && rainDrops.map((drop, 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 }}
key={`rain-${index}`}
initial={{ y: "-12vh", opacity: 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" }}
className="absolute -top-8 h-10 w-px rotate-[8deg] rounded-full bg-foreground/35"
style={{ left: drop.left }}
/>
))}
{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 }}
{thunderstorm && (
<motion.div
animate={reduceMotion ? { opacity: 0.08 } : { opacity: [0, 0, 0.16, 0, 0.08, 0] }}
transition={{ duration: 6, repeat: Infinity, repeatDelay: 2.5 }}
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>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } from "lucide-react";
import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } from "lucide-react";
import {
calculateFeelsLike,
formatDateTime,
@@ -12,22 +12,48 @@ import {
formatWind,
getWeatherDescription,
getWeatherMoodFromData,
moodGradient,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw";
import type { SynopStation, WeatherMood } from "@/types/imgw";
import type { ImgwCurrentWeather } from "@/types/imgw-current";
import { WeatherIcon } from "@/components/weather/weather-icon";
import { WeatherEffects } from "@/components/weather/weather-effects";
import { useI18n } from "@/lib/i18n";
export function WeatherHero({ station, locationName, distanceKm }: { station: SynopStation; locationName?: string; distanceKm?: number }) {
function moodAccentClass(mood: WeatherMood) {
return {
warm: "border-accent/25 bg-accent/10 text-accent",
cloudy: "border-border/70 bg-surface-muted text-muted",
wind: "border-border/70 bg-surface-muted text-muted",
cold: "border-border/70 bg-surface-muted text-muted",
night: "border-border/70 bg-surface-muted text-muted",
mild: "border-accent/25 bg-accent/10 text-accent",
}[mood];
}
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
const { language, t } = useI18n();
const mood = getWeatherMoodFromData(station);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const displayedLocationName = locationName ?? station.name;
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
const displayedStation = currentWeather ? {
...station,
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station;
const mood = getWeatherMoodFromData(displayedStation);
const moodAccent = moodAccentClass(mood);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(station.windSpeed, null, language) },
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language) },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language) },
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) },
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
];
return (
@@ -35,38 +61,53 @@ export function WeatherHero({ station, locationName, distanceKm }: { station: Sy
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: "easeOut" }}
className={`relative isolate overflow-hidden rounded-[2rem] bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-[0_24px_75px_rgba(15,23,42,0.24)] sm:px-8 sm:py-8 lg:px-10`}
className="relative isolate overflow-hidden rounded-panel border border-border/70 bg-surface-raised px-5 py-6 shadow-card sm:px-8 sm:py-8 lg:px-10"
>
<WeatherEffects station={station} mood={mood} />
<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" />
<WeatherEffects precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-3">
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{locationName ?? station.name}</span>
{locationName && <span className="text-xs text-white/65">{t("location.heroSource", { station: station.name, distance: distanceKm ?? 0 })}</span>}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span>
<span className={`rounded-control border px-2.5 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${moodAccent}`}>
{getWeatherDescription(displayedStation, language, currentWeather?.condition)}
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: t("location.heroStationFallback", { station: station.name })}</p>
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
</div>
</div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<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]">
{formatTemperature(station.temperature, language)}
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
{formatTemperature(displayedStation.temperature, language)}
</div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(station, language)}</p>
<p className="mt-1 text-sm text-white/75">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(station.measuredAt, language)}</p>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
</div>
<WeatherIcon mood={mood} className="mb-4 size-20 text-white/80 sm:size-28" />
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
</div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-2xl border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
<div className="flex items-center gap-2 text-xs text-white/70"><Icon className="size-3.5" />{label}</div>
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5">
<div className="flex items-center gap-2 text-xs text-muted"><Icon className="size-3.5 text-accent" />{label}</div>
<p className="mt-2 text-base font-semibold">{value}</p>
</div>
))}
</div>
{station.windDirection !== null && (
<p className="mt-4 flex items-center gap-1.5 text-xs text-white/70">
<Navigation className="size-3.5" style={{ transform: `rotate(${station.windDirection}deg)` }} />
{t("weather.windDirection")}: {station.windDirection}°
{displayedStation.windDirection !== null && (
<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)` }} />
{t("weather.windDirection")}: {displayedStation.windDirection}°
</p>
)}
</div>

View File

@@ -1,8 +1,9 @@
import { Cloud, CloudSun, MoonStar, Snowflake, ThermometerSun, Wind } from "lucide-react";
import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, ThermometerSun, Wind } from "lucide-react";
import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, className = "" }: { mood: WeatherMood; className?: string }) {
const Icon = {
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,

View File

@@ -0,0 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchImgwCurrentWeather } from "@/lib/imgw-current-api";
import { QUERY_GC_TIME } from "@/lib/constants";
const CURRENT_WEATHER_STALE_TIME = 2 * 60 * 1000;
export function useCurrentWeather(latitude?: number, longitude?: number) {
return useQuery({
queryKey: ["imgw-current-weather", latitude, longitude],
queryFn: ({ signal }) => fetchImgwCurrentWeather(latitude as number, longitude as number, signal),
staleTime: CURRENT_WEATHER_STALE_TIME,
gcTime: QUERY_GC_TIME,
retry: 1,
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
});
}

10
lib/chart-theme.ts Normal file
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[] {
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];
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeHourlyForecast(series: RawForecastSeries = {}): HourlyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const time = readString(series, "time", index);
function readString(value: unknown) {
return typeof value === "string" && value ? value : null;
}
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 [];
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),
temperature: readNumber(row.temperature),
feelsLike: readNumber(row.feelsLike),
precipitationProbability: readNumber(row.precipitationProbability),
precipitation: readNumber(row.precipitation),
weatherCode: readNumber(row.weatherCode),
windSpeed: readNumber(row.windSpeed),
source: isForecastSource(row.source) ? row.source : "open-meteo",
}];
});
}) : [];
}
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index);
function normalizeDailyForecast(value: unknown): DailyForecast[] {
return Array.isArray(value) ? value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const row = candidate as Partial<DailyForecast>;
const date = readString(row.date);
if (!date) return [];
return [{
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),
temperatureMax: readNumber(row.temperatureMax),
temperatureMin: readNumber(row.temperatureMin),
precipitationProbability: readNumber(row.precipitationProbability),
precipitation: readNumber(row.precipitation),
weatherCode: readNumber(row.weatherCode),
sunrise: readString(row.sunrise),
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 longitude = Number(raw.longitude);
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",
hourly: normalizeHourlyForecast(raw.hourly),
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 response = await fetch(`/api/forecast?${params}`, { signal });
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,8 +33,14 @@ const translations = {
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
"location.nearest": "Najbliższa stacja IMGW",
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
"location.heroSource": "stacja 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.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
"location.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.",
"location.attribution": "Wyszukiwanie miejscowości:",
"location.gpsUse": "Użyj mojej lokalizacji",
"location.gpsLocating": "Ustalam lokalizację…",
@@ -52,7 +58,7 @@ const translations = {
"location.gpsSelected": "Wybrano lokalizację: {location}.",
"location.gpsAttribution": "Nazwy miejsc dla GPS:",
"featured.label": "Szybki wybór",
"featured.title": "Wybrane stacje IMGW",
"featured.title": "Popularne lokalizacje",
"favorites.title": "Ulubione lokalizacje",
"favorites.addStation": "Dodaj {name} do ulubionych",
"favorites.removeStation": "Usuń {name} z ulubionych",
@@ -61,6 +67,7 @@ const translations = {
"weather.humidity": "Wilgotność",
"weather.wind": "Wiatr",
"weather.rainfall": "Suma opadu",
"weather.rainfall10m": "Opad 10 min",
"weather.pressure": "Ciśnienie",
"weather.feelsLike": "Odczuwalna",
"weather.measurement": "pomiar",
@@ -68,6 +75,9 @@ const translations = {
"weather.calm": "Spokojne warunki",
"weather.humid": "Wilgotno",
"weather.strongWind": "Silny wiatr",
"weather.currentRain": "Opady deszczu",
"weather.currentSnow": "Opady śniegu",
"weather.thunderstorm": "Burza",
"weather.airTemperature": "Temperatura",
"weather.windSpeed": "Prędkość wiatru",
"weather.rainfallTotal": "Suma opadu",
@@ -80,7 +90,7 @@ const translations = {
"weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa",
"forecast.title": "Najbliższe godziny i dni",
"forecast.description": "Prognoza dla {location}. Bieżący pomiar powyżej pochodzi ze stacji IMGW, a poniższe wartości są prognozą modelową.",
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
"forecast.hourly": "Najbliższe 24 godziny",
"forecast.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj",
@@ -105,9 +115,11 @@ const translations = {
"forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina",
"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.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.partlyCloudy": "Częściowe zachmurzenie",
"forecast.condition.cloudy": "Pochmurno",
@@ -155,6 +167,13 @@ const translations = {
"warnings.probability": "Prawdopodobieństwo: {value}%",
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
"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.title": "Hydro",
"hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
@@ -200,8 +219,14 @@ const translations = {
"location.preparing": "Preparing the nearest IMGW stations…",
"location.empty": "No matching place was found in Poland.",
"location.nearest": "Nearest IMGW station",
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
"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.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
"location.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
"location.attribution": "Place search:",
"location.gpsUse": "Use my location",
"location.gpsLocating": "Finding your location…",
@@ -219,7 +244,7 @@ const translations = {
"location.gpsSelected": "Selected location: {location}.",
"location.gpsAttribution": "GPS place names:",
"featured.label": "Quick select",
"featured.title": "Selected IMGW stations",
"featured.title": "Popular locations",
"favorites.title": "Favourite locations",
"favorites.addStation": "Add {name} to favourites",
"favorites.removeStation": "Remove {name} from favourites",
@@ -228,6 +253,7 @@ const translations = {
"weather.humidity": "Humidity",
"weather.wind": "Wind",
"weather.rainfall": "Rainfall total",
"weather.rainfall10m": "Rainfall 10 min",
"weather.pressure": "Pressure",
"weather.feelsLike": "Feels like",
"weather.measurement": "measurement",
@@ -235,6 +261,9 @@ const translations = {
"weather.calm": "Calm conditions",
"weather.humid": "Humid",
"weather.strongWind": "Strong wind",
"weather.currentRain": "Rain",
"weather.currentSnow": "Snow",
"weather.thunderstorm": "Thunderstorm",
"weather.airTemperature": "Temperature",
"weather.windSpeed": "Wind speed",
"weather.rainfallTotal": "Rainfall total",
@@ -247,7 +276,7 @@ const translations = {
"weather.temperatureDetail": "Air temperature",
"forecast.label": "Model forecast",
"forecast.title": "Upcoming hours and days",
"forecast.description": "Forecast for {location}. The current reading above comes from an IMGW station. The values below are a model forecast.",
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
"forecast.hourly": "Next 24 hours",
"forecast.daily": "7-day forecast",
"forecast.today": "Today",
@@ -272,9 +301,11 @@ const translations = {
"forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour",
"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.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.partlyCloudy": "Partly cloudy",
"forecast.condition.cloudy": "Cloudy",
@@ -322,6 +353,13 @@ const translations = {
"warnings.probability": "Probability: {value}%",
"warnings.genericHydro": "Hydrological 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.title": "Hydro",
"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)) : [];
}
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[]> {
const results = await Promise.allSettled([
fetchWarningsByKind("meteo", signal),
@@ -65,5 +70,5 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
if (results.every((result) => result.status === "rejected")) {
throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
}
return warnings.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""));
return warnings.sort(compareWarnings);
}

98
lib/imgw-current-api.ts Normal file
View File

@@ -0,0 +1,98 @@
import { toNumber } from "@/lib/weather-utils";
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
function toCelsius(value: unknown) {
const temperature = toNumber(value);
if (temperature === null) return null;
return temperature > 150 ? temperature - 273.15 : temperature;
}
function toHectopascals(value: unknown) {
const pressure = toNumber(value);
if (pressure === null) return null;
return pressure > 2_000 ? pressure / 100 : pressure;
}
function normalizeDate(value: unknown) {
if (typeof value !== "string") return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function getWeatherCode(iconCode: unknown) {
if (typeof iconCode !== "string") return null;
const match = iconCode.match(/z(\d{2})/i);
return match ? Number(match[1]) : null;
}
function getCondition(weatherCode: number | null, rainfall10m: number | null, snowfall10m: number | null) {
if (weatherCode !== null && weatherCode >= 95) return "thunderstorm" as const;
if ((snowfall10m ?? 0) > 0) return "snow" as const;
if ((rainfall10m ?? 0) > 0) return "rain" as const;
return null;
}
function hasNumericValue(value: unknown) {
return toNumber(value) !== null;
}
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Temperature)
&& hasNumericValue(candidate.Chill)
&& hasNumericValue(candidate.Humidity)
&& hasNumericValue(candidate.Wind_Speed)
&& hasNumericValue(candidate.PressureMSL);
}
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
}
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const rows = payload.data.Data
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
if (!candidate || typeof candidate !== "object") return false;
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
})
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
const fullRow = rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate))
?? rows.find((candidate) => candidate.Type === "Type_Hour" && isFullWeatherRow(candidate));
const precipitationRow = fullRow && hasPrecipitationValue(fullRow)
? fullRow
: rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate));
const row = fullRow ?? precipitationRow;
if (!row) return null;
const measuredAt = normalizeDate(row.Date);
if (!measuredAt) return null;
const precipitationSource = precipitationRow ?? row;
const rainfall10m = toNumber(precipitationSource.Rain10m);
const snowfall10m = toNumber(precipitationSource.Snow10m);
const weatherCode = getWeatherCode(row.Icon10);
return {
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
measuredAt,
temperature: toCelsius(row.Temperature),
feelsLike: toCelsius(row.Chill),
windSpeed: toNumber(row.Wind_Speed),
windDirection: toNumber(row.Wind_Dir),
humidity: toNumber(row.Humidity),
pressure: toHectopascals(row.PressureMSL),
precipitation10m: toNumber(precipitationSource.Precipitation10m),
rainfall10m,
snowfall10m,
cloudCover: toNumber(row.Cloud),
weatherCode,
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
};
}
export async function fetchImgwCurrentWeather(latitude: number, longitude: number, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
const response = await fetch(`/api/imgw-current?${params}`, { signal });
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
}

View File

@@ -128,6 +128,10 @@ export function normalizeProvinceName(value: string | null | undefined) {
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) {
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

@@ -10,6 +10,7 @@ import type {
} from "@/types/imgw";
import { translate, type Language } from "@/lib/i18n";
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
@@ -174,19 +175,11 @@ export function getWeatherMoodFromData(station: SynopStation, date = new Date())
return "mild";
}
export function getWeatherDescription(station: SynopStation, language: Language = "pl") {
export function getWeatherDescription(station: SynopStation, language: Language = "pl", condition?: CurrentWeatherCondition) {
if (condition === "thunderstorm") return translate(language, "weather.thunderstorm");
if (condition === "snow") return translate(language, "weather.currentSnow");
if (condition === "rain") return translate(language, "weather.currentRain");
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
if ((station.humidity ?? 0) >= 90) return translate(language, "weather.humid");
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">
<defs>
<linearGradient id="g" x1="70" y1="44" x2="450" y2="484" gradientUnits="userSpaceOnUse">
<stop stop-color="#38bdf8"/>
<stop offset=".52" stop-color="#2563eb"/>
<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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="wtr.">
<rect width="512" height="512" rx="120" fill="#171d25"/>
<rect x="22" y="22" width="468" height="468" rx="102" fill="none" stroke="#eef3f7" stroke-opacity=".16" stroke-width="2"/>
<text x="62" y="318" fill="#eef3f7" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr</text>
<circle cx="382" cy="295" r="18" fill="#8fb4ce"/>
</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">
<defs>
<linearGradient id="g" x1="40" y1="20" x2="480" y2="500" gradientUnits="userSpaceOnUse">
<stop stop-color="#38bdf8"/>
<stop offset=".55" stop-color="#2563eb"/>
<stop offset="1" stop-color="#312e81"/>
</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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="wtr.">
<rect width="512" height="512" fill="#171d25"/>
<rect x="44" y="44" width="424" height="424" rx="96" fill="#1f2630"/>
<rect x="68" y="68" width="376" height="376" rx="78" fill="none" stroke="#eef3f7" stroke-opacity=".14" stroke-width="2"/>
<text x="84" y="318" fill="#eef3f7" font-family="Arial, Helvetica, sans-serif" font-size="172" font-weight="700" letter-spacing="-26">wtr</text>
<circle cx="386" cy="296" r="17" fill="#8fb4ce"/>
</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.",
"start_url": "/",
"display": "standalone",
"background_color": "#07111f",
"theme_color": "#0c4a6e",
"background_color": "#171d25",
"theme_color": "#171d25",
"lang": "pl",
"orientation": "portrait-primary",
"icons": [

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "wtr-shell-v1";
const CACHE_NAME = "wtr-shell-v3";
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
self.addEventListener("install", (event) => {
@@ -16,7 +16,7 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/imgw/")) {
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") {
event.respondWith(
fetch(event.request)
.then((response) => {

View File

@@ -10,11 +10,28 @@ export default {
],
theme: {
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: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
},
borderRadius: {
panel: "1.5rem",
card: "1.25rem",
control: "9999px",
},
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;
}
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 {
time: string;
temperature: number | null;
@@ -30,6 +47,7 @@ export interface HourlyForecast {
precipitation: number | null;
weatherCode: number | null;
windSpeed: number | null;
source: ForecastSource;
}
export interface DailyForecast {
@@ -41,6 +59,7 @@ export interface DailyForecast {
weatherCode: number | null;
sunrise: string | null;
sunset: string | null;
sources: ForecastSource[];
}
export interface WeatherForecast {
@@ -49,4 +68,5 @@ export interface WeatherForecast {
timezone: string;
hourly: HourlyForecast[];
daily: DailyForecast[];
sources: ForecastSource[];
}

43
types/imgw-current.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface RawImgwHybridWeatherRow {
Icon10?: unknown;
Wind_Dir?: unknown;
Temperature?: unknown;
Chill?: unknown;
Rain10m?: unknown;
Snow10m?: unknown;
Wind_Speed?: unknown;
MODEL?: unknown;
Date?: unknown;
Precipitation10m?: unknown;
Type?: unknown;
Humidity?: unknown;
Cloud?: unknown;
PressureMSL?: unknown;
}
export interface RawImgwHybridWeatherResponse {
data?: {
Valid?: unknown;
Data?: unknown;
};
}
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only";
export interface ImgwCurrentWeather {
coverage: ImgwCurrentWeatherCoverage;
measuredAt: string;
temperature: number | null;
feelsLike: number | null;
windSpeed: number | null;
windDirection: number | null;
humidity: number | null;
pressure: number | null;
precipitation10m: number | null;
rainfall10m: number | null;
snowfall10m: number | null;
cloudCover: number | null;
weatherCode: number | null;
condition: CurrentWeatherCondition;
}