diff --git a/AGENTS.md b/AGENTS.md index be8fb69..6403cd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Projekt -`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. +`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera. @@ -36,11 +36,11 @@ 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. +- 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 lokalnego rekordu aktualnej godziny UTC, zgodnie z portalem `meteo.imgw.pl`; rekord może być 10-minutowy albo godzinowy. 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 godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. +- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. - 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. - 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. diff --git a/README.md b/README.md index f182eae..ae9f925 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ **Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.** -`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami. +`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z 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. -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. @@ -47,6 +47,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` +- 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/` @@ -55,7 +56,7 @@ 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ą. @@ -67,7 +68,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z lokalnego rekordu aktualnej godziny dla współrzędnych miejscowości, zgodnie z zachowaniem portalu IMGW, i mogą dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut. Interfejs oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu. -Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. +Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu. @@ -77,7 +78,7 @@ Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs po ```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 diff --git a/app/api/forecast/route.ts b/app/api/forecast/route.ts index a9fbfff..66d14de 100644 --- a/app/api/forecast/route.ts +++ b/app/api/forecast/route.ts @@ -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 { diff --git a/components/forecast/day-forecast-modal.tsx b/components/forecast/day-forecast-modal.tsx index 98bf396..009e32e 100644 --- a/components/forecast/day-forecast-modal.tsx +++ b/components/forecast/day-forecast-modal.tsx @@ -2,9 +2,10 @@ import { useEffect, useMemo, useRef } from "react"; 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 +16,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 "—"; @@ -43,11 +44,13 @@ export function DayForecastModal({ day, hours, locationName, + sources, onClose, }: { day: DailyForecast | null; hours: HourlyForecast[]; locationName: string; + sources: ForecastSource[]; onClose: () => void; }) { const { language, locale, t } = useI18n(); @@ -169,12 +172,7 @@ export function DayForecastModal({ -

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

+ diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index a31a6fe..4fc6d8b 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -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"; @@ -188,11 +189,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude? )} -

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

+ {forecast && } - + ); } diff --git a/components/forecast/forecast-sources.tsx b/components/forecast/forecast-sources.tsx new file mode 100644 index 0000000..e6a4710 --- /dev/null +++ b/components/forecast/forecast-sources.tsx @@ -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 ( +

+ {t("forecast.source")}{" "} + {hasImgw && ( + <> + + IMGW ALARO + + {", "} + + )} + + Open-Meteo + + . {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} +

+ ); +} diff --git a/lib/forecast-api.ts b/lib/forecast-api.ts index 66a31ee..5fc8a8b 100644 --- a/lib/forecast-api.ts +++ b/lib/forecast-api.ts @@ -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; + 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; + 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 { 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); } diff --git a/lib/forecast-merge.ts b/lib/forecast-merge.ts new file mode 100644 index 0000000..3dc71fc --- /dev/null +++ b/lib/forecast-merge.ts @@ -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>(); + + 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>()); +} + +function getAvailableValues(values: Array) { + return values.filter((value): value is number => value !== null); +} + +function getMinimum(values: Array, fallback: number | null) { + const availableValues = getAvailableValues(values); + return availableValues.length ? Math.min(...availableValues) : fallback; +} + +function getMaximum(values: Array, fallback: number | null) { + const availableValues = getAvailableValues(values); + return availableValues.length ? Math.max(...availableValues) : fallback; +} + +function getTotal(values: Array, 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"], + }; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 25c639a..cfb1de5 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -90,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żące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową.", + "forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.", "forecast.hourly": "Najbliższe 24 godziny", "forecast.daily": "Prognoza 7-dniowa", "forecast.today": "Dzisiaj", @@ -115,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", @@ -267,7 +269,7 @@ const translations = { "weather.temperatureDetail": "Air temperature", "forecast.label": "Model forecast", "forecast.title": "Upcoming hours and days", - "forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast.", + "forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.", "forecast.hourly": "Next 24 hours", "forecast.daily": "7-day forecast", "forecast.today": "Today", @@ -292,9 +294,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", diff --git a/public/sw.js b/public/sw.js index 5ba54e1..948493c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "wtr-shell-v2"; +const CACHE_NAME = "wtr-shell-v3"; const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"]; 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/") || url.pathname === "/api/imgw-current") { + if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") { event.respondWith( fetch(event.request) .then((response) => { diff --git a/types/forecast.ts b/types/forecast.ts index 177e1a2..faf3a4b 100644 --- a/types/forecast.ts +++ b/types/forecast.ts @@ -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[]; }