diff --git a/README.md b/README.md index a3296b9..a8d16c5 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. Aplikacja prezentuje bieżące odczyty synoptyczne, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. +`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. 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. @@ -38,9 +38,9 @@ npm run build npm run start ``` -## Dane IMGW +## Źródła danych -Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW: +Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW: - dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop` - pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}` @@ -50,20 +50,23 @@ Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW: - dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/` - lista produktów: `https://danepubliczne.imgw.pl/api/data/product` -Do wyszukiwania nazw miejscowości, bez pobierania danych pogodowych, 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ć geokoder własnym dostawcą. +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. -Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. +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ą. + +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`. ## Ograniczenia API -Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. +Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie 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`. Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące. ## Struktura projektu ```text -app/ routing, layout, proxy IMGW, offline fallback +app/ routing, layout, proxy danych, offline fallback +components/forecast/ prognoza godzinowa i dzienna Open-Meteo components/dashboard dashboard aplikacji components/weather/ hero, stacje, metryki i szczegóły components/warnings/ alerty meteo i hydro diff --git a/app/api/forecast/route.ts b/app/api/forecast/route.ts new file mode 100644 index 0000000..0a03a0d --- /dev/null +++ b/app/api/forecast/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +const FORECAST_URL = "https://api.open-meteo.com/v1/forecast"; + +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({ + latitude: String(latitude), + longitude: String(longitude), + hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m", + daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset", + timezone: "Europe/Warsaw", + forecast_hours: "24", + forecast_days: "7", + wind_speed_unit: "ms", + }); + + 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(), { + headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" }, + }); + } catch { + return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 }); + } +} diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index aa8aa65..a649940 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -11,6 +11,8 @@ 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 { ForecastPanel } from "@/components/forecast/forecast-panel"; +import { locateSynopStations } from "@/lib/location-utils"; export function DashboardPage() { const { t } = useI18n(); @@ -24,11 +26,17 @@ export function DashboardPage() { ?? 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 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; return (
+
diff --git a/components/forecast/forecast-icon.tsx b/components/forecast/forecast-icon.tsx new file mode 100644 index 0000000..191d167 --- /dev/null +++ b/components/forecast/forecast-icon.tsx @@ -0,0 +1,13 @@ +import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, CloudSun, Sun } from "lucide-react"; + +export function ForecastIcon({ code, className = "" }: { code: number | null; className?: string }) { + const Icon = code === 0 ? Sun + : code === 1 || code === 2 ? CloudSun + : code === 45 || code === 48 ? CloudFog + : code !== null && code >= 51 && code <= 57 ? CloudDrizzle + : code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain + : code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow + : code !== null && code >= 95 ? CloudLightning + : Cloud; + return ; +} diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx new file mode 100644 index 0000000..3857f3b --- /dev/null +++ b/components/forecast/forecast-panel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { motion } from "framer-motion"; +import { CalendarDays, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react"; +import { ForecastIcon } from "@/components/forecast/forecast-icon"; +import { LoadingSkeleton } from "@/components/states/loading-skeleton"; +import { EmptyState } from "@/components/states/empty-state"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useForecast } from "@/hooks/use-forecast"; +import { useI18n } from "@/lib/i18n"; +import { formatForecastRainfall, formatForecastTemperature, getForecastCondition } from "@/lib/forecast-utils"; +import type { DailyForecast } from "@/types/forecast"; + +function formatHour(value: string) { + return value.slice(11, 16); +} + +function formatDay(value: string, locale: string, todayLabel: string, index: number) { + if (index === 0) return todayLabel; + return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(new Date(`${value}T12:00:00Z`)); +} + +function DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) { + const { language, locale, t } = useI18n(); + return ( + +

{formatDay(day.date, locale, t("forecast.today"), index)}

+
+ + {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} +
+ {day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} +

{formatForecastTemperature(day.temperatureMax, language)}{formatForecastTemperature(day.temperatureMin, language)}

+
+ ); +} + +export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { + const { language, t } = useI18n(); + const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); + + return ( +
+
+

{t("forecast.label")}

+

{t("forecast.title")}

+

{t("forecast.description", { location: locationName })}

+
+ + {!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? ( +
+ + +
+ ) : isError || !forecast ? ( + +

{t("forecast.error")}

+ +
+ ) : !forecast.hourly.length || !forecast.daily.length ? ( + + ) : ( +
+ +

{t("forecast.hourly")}

+
+
    + {forecast.hourly.map((hour, index) => ( + +

    {formatHour(hour.time)}

    + +

    {formatForecastTemperature(hour.temperature, language)}

    +

    {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}

    +
    + ))} +
+
+
+ +

{t("forecast.daily")}

+
    {forecast.daily.map((day, index) => )}
+
+
+ )} + +

+ {t("forecast.source")} Open-Meteo +

+
+ ); +} diff --git a/hooks/use-forecast.ts b/hooks/use-forecast.ts new file mode 100644 index 0000000..bac5ac4 --- /dev/null +++ b/hooks/use-forecast.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchForecast } from "@/lib/forecast-api"; +import { QUERY_GC_TIME } from "@/lib/constants"; + +export function useForecast(latitude?: number, longitude?: number) { + return useQuery({ + queryKey: ["forecast", latitude, longitude], + queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal), + enabled: Number.isFinite(latitude) && Number.isFinite(longitude), + staleTime: 15 * 60 * 1000, + gcTime: QUERY_GC_TIME, + retry: 2, + }); +} diff --git a/lib/forecast-api.ts b/lib/forecast-api.ts new file mode 100644 index 0000000..66a31ee --- /dev/null +++ b/lib/forecast-api.ts @@ -0,0 +1,68 @@ +import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, 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]; + 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); + 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), + }]; + }); +} + +function normalizeDailyForecast(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), + }]; + }); +} + +function normalizeForecast(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 : "Europe/Warsaw", + hourly: normalizeHourlyForecast(raw.hourly), + daily: normalizeDailyForecast(raw.daily), + }; +} + +export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) { + 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); +} diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts new file mode 100644 index 0000000..d123a3c --- /dev/null +++ b/lib/forecast-utils.ts @@ -0,0 +1,28 @@ +import type { Language, TranslationKey } from "@/lib/i18n"; +import { translate } from "@/lib/i18n"; + +export function getForecastConditionKey(code: number | null): TranslationKey { + if (code === 0) return "forecast.condition.clear"; + if (code === 1 || code === 2) return "forecast.condition.partlyCloudy"; + if (code === 3) return "forecast.condition.cloudy"; + if (code === 45 || code === 48) return "forecast.condition.fog"; + if (code !== null && code >= 51 && code <= 57) return "forecast.condition.drizzle"; + if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return "forecast.condition.rain"; + if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return "forecast.condition.snow"; + if (code !== null && code >= 95) return "forecast.condition.thunderstorm"; + return "forecast.condition.unknown"; +} + +export function getForecastCondition(code: number | null, language: Language) { + return translate(language, getForecastConditionKey(code)); +} + +export function formatForecastTemperature(value: number | null, language: Language) { + if (value === null) return "—"; + return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`; +} + +export function formatForecastRainfall(value: number | null, language: Language) { + if (value === null) return "—"; + return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 0d47edf..3c0a37a 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -66,6 +66,25 @@ const translations = { "weather.windDirectionDetail": "Kierunek napływu wiatru", "weather.rainfallDetail": "Suma opadu z pomiaru IMGW", "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.hourly": "Najbliższe 24 godziny", + "forecast.daily": "Prognoza 7-dniowa", + "forecast.today": "Dzisiaj", + "forecast.source": "Źródło prognozy:", + "forecast.error": "Nie udało się pobrać prognozy Open-Meteo.", + "forecast.emptyTitle": "Brak prognozy", + "forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.", + "forecast.condition.clear": "Bezchmurnie", + "forecast.condition.partlyCloudy": "Częściowe zachmurzenie", + "forecast.condition.cloudy": "Pochmurno", + "forecast.condition.fog": "Mgła", + "forecast.condition.drizzle": "Mżawka", + "forecast.condition.rain": "Opady deszczu", + "forecast.condition.snow": "Opady śniegu", + "forecast.condition.thunderstorm": "Burza", + "forecast.condition.unknown": "Brak opisu", "stations.emptyTitle": "Brak pasujących stacji", "station.all": "Wszystkie stacje", "station.label": "Stacja {name}", @@ -176,6 +195,25 @@ const translations = { "weather.windDirectionDetail": "Direction the wind is coming from", "weather.rainfallDetail": "Total rainfall from the IMGW reading", "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.hourly": "Next 24 hours", + "forecast.daily": "7-day forecast", + "forecast.today": "Today", + "forecast.source": "Forecast source:", + "forecast.error": "Unable to load the Open-Meteo forecast.", + "forecast.emptyTitle": "Forecast unavailable", + "forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.", + "forecast.condition.clear": "Clear sky", + "forecast.condition.partlyCloudy": "Partly cloudy", + "forecast.condition.cloudy": "Cloudy", + "forecast.condition.fog": "Fog", + "forecast.condition.drizzle": "Drizzle", + "forecast.condition.rain": "Rain", + "forecast.condition.snow": "Snow", + "forecast.condition.thunderstorm": "Thunderstorm", + "forecast.condition.unknown": "Description unavailable", "stations.emptyTitle": "No matching stations", "station.all": "All stations", "station.label": "Station {name}", diff --git a/lib/location-utils.ts b/lib/location-utils.ts index 7898ec5..fe02402 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -59,6 +59,8 @@ export function findNearestSynopStation(location: LocationSearchResult, stations return { name: location.name, province: location.province, + latitude: location.latitude, + longitude: location.longitude, stationId: nearest.station.id, stationName: nearest.station.name, distanceKm: Math.round(nearest.distanceKm), diff --git a/types/forecast.ts b/types/forecast.ts new file mode 100644 index 0000000..177e1a2 --- /dev/null +++ b/types/forecast.ts @@ -0,0 +1,52 @@ +export interface RawForecastSeries { + time?: unknown; + temperature_2m?: unknown; + apparent_temperature?: unknown; + precipitation_probability?: unknown; + precipitation?: unknown; + weather_code?: unknown; + wind_speed_10m?: unknown; + temperature_2m_max?: unknown; + temperature_2m_min?: unknown; + precipitation_probability_max?: unknown; + precipitation_sum?: unknown; + sunrise?: unknown; + sunset?: unknown; +} + +export interface RawWeatherForecast { + latitude?: unknown; + longitude?: unknown; + timezone?: unknown; + hourly?: RawForecastSeries; + daily?: RawForecastSeries; +} + +export interface HourlyForecast { + time: string; + temperature: number | null; + feelsLike: number | null; + precipitationProbability: number | null; + precipitation: number | null; + weatherCode: number | null; + windSpeed: number | null; +} + +export interface DailyForecast { + date: string; + temperatureMax: number | null; + temperatureMin: number | null; + precipitationProbability: number | null; + precipitation: number | null; + weatherCode: number | null; + sunrise: string | null; + sunset: string | null; +} + +export interface WeatherForecast { + latitude: number; + longitude: number; + timezone: string; + hourly: HourlyForecast[]; + daily: DailyForecast[]; +} diff --git a/types/location.ts b/types/location.ts index 768391d..dbf0cbc 100644 --- a/types/location.ts +++ b/types/location.ts @@ -10,6 +10,8 @@ export interface LocationSearchResult { export interface SelectedLocation { name: string; province: string | null; + latitude: number; + longitude: number; stationId: string; stationName: string; distanceKm: number;