diff --git a/AGENTS.md b/AGENTS.md index 801dfcf..55f2258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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. 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. @@ -37,6 +37,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - 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. +- 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. - 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. - `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. diff --git a/README.md b/README.md index ec5a98f..b85eebd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **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ą Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami. 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. @@ -46,6 +46,7 @@ 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` - 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/` @@ -60,11 +61,13 @@ Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api 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`. Dzięki temu bieżące warunki są aktualizowane częściej niż godzinowe dane synoptyczne i mogą pokazać rzeczywisty opad z ostatnich 10 minut. Jeśli usługa Hybrid nie odpowiada, hero zachowuje pomiar `synop` jako fallback. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu. + +Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. 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. diff --git a/app/api/imgw-current/route.ts b/app/api/imgw-current/route.ts new file mode 100644 index 0000000..90ca543 --- /dev/null +++ b/app/api/imgw-current/route.ts @@ -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 }); + } +} diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index a649940..29c855d 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -11,6 +11,7 @@ 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"; @@ -20,23 +21,26 @@ export function DashboardPage() { const { data: positions = [] } = useMeteoStationPositions(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); - if (isPending) return ; - if (isError || !stations?.length) return 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 } = useCurrentWeather(forecastLatitude, forecastLongitude); + if (isPending) return ; + if (isError || !stations?.length || !selectedStation) return refetch()} description={t("dashboard.error")} />; return (
- - + +
diff --git a/components/weather/weather-effects.tsx b/components/weather/weather-effects.tsx index 21b8531..c789d65 100644 --- a/components/weather/weather-effects.tsx +++ b/components/weather/weather-effects.tsx @@ -15,9 +15,16 @@ const stars = Array.from({ length: 16 }, (_, index) => ({ delay: (index % 6) * 0.35, })); -export function WeatherEffects({ station, mood }: { station: SynopStation; mood: WeatherMood }) { +const rainDrops = Array.from({ length: 22 }, (_, index) => ({ + left: `${(index * 43 + 7) % 101}%`, + delay: (index % 9) * 0.18, + duration: 0.8 + (index % 4) * 0.14, +})); + +export function WeatherEffects({ station, mood, precipitation10m, thunderstorm = false }: { station: SynopStation; mood: WeatherMood; precipitation10m?: number | null; thunderstorm?: boolean }) { const reduceMotion = useReducedMotion(); const isWindy = (station.windSpeed ?? 0) >= 8; + const isRaining = (precipitation10m ?? 0) > 0; return (