feat: prefer IMGW ALARO forecast data
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Projekt
|
## Projekt
|
||||||
|
|
||||||
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
||||||
|
|
||||||
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
||||||
|
|
||||||
@@ -36,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/`.
|
- Trzymaj routing w `app/`, komponenty funkcjonalne w odpowiednim podkatalogu `components/`, zapytania Query w `hooks/`, fetchery i normalizację w `lib/`, a typy danych w `types/`.
|
||||||
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
|
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
|
||||||
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
||||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
||||||
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
||||||
- Hybrid wybieraj z 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`.
|
- 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.
|
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
||||||
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||||
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
||||||
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
||||||
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
||||||
|
|
||||||
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
|
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z 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.
|
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
||||||
|
|
||||||
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames. Bieżące warunki są analizowane lokalnie przez IMGW Hybrid, a najbliższa rzeczywista stacja IMGW jest pokazywana jako kontekst i fallback. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
||||||
|
|
||||||
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ npm run start
|
|||||||
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
||||||
|
|
||||||
- bieżąca analiza IMGW Hybrid używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi`
|
- bieżąca analiza IMGW Hybrid używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi`
|
||||||
|
- prognoza godzinowa IMGW ALARO używana przez oficjalny portal: `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro`
|
||||||
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
|
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
|
||||||
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
|
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
|
||||||
- dane hydrologiczne: `https://danepubliczne.imgw.pl/api/data/hydro/`
|
- dane hydrologiczne: `https://danepubliczne.imgw.pl/api/data/hydro/`
|
||||||
@@ -55,7 +56,7 @@ Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW
|
|||||||
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
||||||
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
||||||
|
|
||||||
Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.open-meteo.com/v1/forecast`. Jest prezentowana oddzielnie od bieżących pomiarów IMGW i podpisana w interfejsie jako prognoza modelowa.
|
Prognoza modelowa łączy dwa źródła. IMGW ALARO dostarcza dostępne godziny prognozy, zwykle około 72 godzin od cyklu modelu. Open-Meteo Forecast API (`https://api.open-meteo.com/v1/forecast`) dostarcza prawdopodobieństwo opadu dla całego zakresu, uzupełnia dalszy horyzont do pełnych 7 dni i pozostaje fallbackiem, jeśli ALARO chwilowo nie odpowiada. Interfejs pokazuje oba źródła i ich role.
|
||||||
|
|
||||||
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
||||||
|
|
||||||
@@ -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.
|
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.
|
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
|
```text
|
||||||
app/ routing, layout, proxy danych, offline fallback
|
app/ routing, layout, proxy danych, offline fallback
|
||||||
components/forecast/ prognoza godzinowa i dzienna Open-Meteo
|
components/forecast/ prognoza godzinowa i dzienna IMGW ALARO + Open-Meteo
|
||||||
components/charts/ wykresy odczytów i szczegółów prognozy
|
components/charts/ wykresy odczytów i szczegółów prognozy
|
||||||
components/dashboard dashboard aplikacji
|
components/dashboard dashboard aplikacji
|
||||||
components/weather/ hero, stacje, metryki i szczegóły
|
components/weather/ hero, stacje, metryki i szczegóły
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { mergeForecastSources } from "@/lib/forecast-merge";
|
||||||
|
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
|
||||||
|
|
||||||
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
||||||
|
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
|
||||||
|
// This browser token is published by the official meteo.imgw.pl frontend.
|
||||||
|
const IMGW_FORECAST_TOKEN = "p4DXKjsYadfBV21TYrDk";
|
||||||
|
const OPEN_METEO_TIMEOUT_MS = 12_000;
|
||||||
|
const IMGW_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
function parseCoordinate(value: string | null, min: number, max: number) {
|
function parseCoordinate(value: string | null, min: number, max: number) {
|
||||||
if (!value?.trim()) return null;
|
if (!value?.trim()) return null;
|
||||||
@@ -8,6 +15,15 @@ function parseCoordinate(value: string | null, min: number, max: number) {
|
|||||||
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readImgwPayload(response: Response | null) {
|
||||||
|
if (!response?.ok) return null;
|
||||||
|
try {
|
||||||
|
return await response.json() as RawImgwForecastResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||||
@@ -16,7 +32,7 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const openMeteoParams = new URLSearchParams({
|
||||||
latitude: String(latitude),
|
latitude: String(latitude),
|
||||||
longitude: String(longitude),
|
longitude: String(longitude),
|
||||||
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
||||||
@@ -25,11 +41,22 @@ export async function GET(request: Request) {
|
|||||||
forecast_days: "7",
|
forecast_days: "7",
|
||||||
wind_speed_unit: "ms",
|
wind_speed_unit: "ms",
|
||||||
});
|
});
|
||||||
|
const imgwParams = new URLSearchParams({
|
||||||
|
token: IMGW_FORECAST_TOKEN,
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
m: "alaro",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${FORECAST_URL}?${params}`, { next: { revalidate: 900 } });
|
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||||
if (!response.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
||||||
return NextResponse.json(await response.json(), {
|
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!openMeteoResponse.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
||||||
|
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
||||||
|
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||||
|
return NextResponse.json(mergeForecastSources(openMeteoPayload, imgwPayload), {
|
||||||
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { CloudSun, Droplets, ExternalLink, Sunrise, Sunset, Wind, X } from "lucide-react";
|
import { CloudSun, Droplets, Sunrise, Sunset, Wind, X } from "lucide-react";
|
||||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||||
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
getForecastCondition,
|
getForecastCondition,
|
||||||
isForecastHourPast,
|
isForecastHourPast,
|
||||||
} from "@/lib/forecast-utils";
|
} from "@/lib/forecast-utils";
|
||||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
function formatHour(value: string | null) {
|
function formatHour(value: string | null) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
@@ -43,11 +44,13 @@ export function DayForecastModal({
|
|||||||
day,
|
day,
|
||||||
hours,
|
hours,
|
||||||
locationName,
|
locationName,
|
||||||
|
sources,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
day: DailyForecast | null;
|
day: DailyForecast | null;
|
||||||
hours: HourlyForecast[];
|
hours: HourlyForecast[];
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
sources: ForecastSource[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { language, locale, t } = useI18n();
|
const { language, locale, t } = useI18n();
|
||||||
@@ -169,12 +172,7 @@ export function DayForecastModal({
|
|||||||
|
|
||||||
<DayForecastCharts hours={hours} />
|
<DayForecastCharts hours={hours} />
|
||||||
|
|
||||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
<ForecastSources sources={sources} />
|
||||||
{t("forecast.source")}{" "}
|
|
||||||
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">
|
|
||||||
Open-Meteo <ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, ExternalLink, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react";
|
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react";
|
||||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||||
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
||||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||||
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
||||||
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { EmptyState } from "@/components/states/empty-state";
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -188,11 +189,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
{forecast && <ForecastSources sources={forecast.sources} />}
|
||||||
{t("forecast.source")} <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo <ExternalLink className="size-3" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} onClose={closeDayDetails} />
|
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
components/forecast/forecast-sources.tsx
Normal file
28
components/forecast/forecast-sources.tsx
Normal 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-slate-500 dark:text-slate-400">
|
||||||
|
{t("forecast.source")}{" "}
|
||||||
|
{hasImgw && (
|
||||||
|
<>
|
||||||
|
<a href="https://meteo.imgw.pl/pogoda/" 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">
|
||||||
|
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-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">
|
||||||
|
Open-Meteo <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,53 +1,61 @@
|
|||||||
import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, WeatherForecast } from "@/types/forecast";
|
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
|
||||||
|
|
||||||
function asArray(value: unknown): unknown[] {
|
function readNumber(value: unknown) {
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
|
||||||
const value = asArray(series[key])[index];
|
|
||||||
return typeof value === "string" && value ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
|
||||||
const value = asArray(series[key])[index];
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHourlyForecast(series: RawForecastSeries = {}): HourlyForecast[] {
|
function readString(value: unknown) {
|
||||||
return asArray(series.time).flatMap((_, index) => {
|
return typeof value === "string" && value ? value : null;
|
||||||
const time = readString(series, "time", index);
|
}
|
||||||
|
|
||||||
|
function isForecastSource(value: unknown): value is ForecastSource {
|
||||||
|
return value === "imgw-alaro" || value === "open-meteo";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSources(value: unknown): ForecastSource[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isForecastSource) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||||
|
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object") return [];
|
||||||
|
const row = candidate as Partial<HourlyForecast>;
|
||||||
|
const time = readString(row.time);
|
||||||
if (!time) return [];
|
if (!time) return [];
|
||||||
return [{
|
return [{
|
||||||
time,
|
time,
|
||||||
temperature: readNumber(series, "temperature_2m", index),
|
temperature: readNumber(row.temperature),
|
||||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
feelsLike: readNumber(row.feelsLike),
|
||||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
precipitationProbability: readNumber(row.precipitationProbability),
|
||||||
precipitation: readNumber(series, "precipitation", index),
|
precipitation: readNumber(row.precipitation),
|
||||||
weatherCode: readNumber(series, "weather_code", index),
|
weatherCode: readNumber(row.weatherCode),
|
||||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
windSpeed: readNumber(row.windSpeed),
|
||||||
|
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||||
}];
|
}];
|
||||||
});
|
}) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
|
function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||||
return asArray(series.time).flatMap((_, index) => {
|
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||||
const date = readString(series, "time", index);
|
if (!candidate || typeof candidate !== "object") return [];
|
||||||
|
const row = candidate as Partial<DailyForecast>;
|
||||||
|
const date = readString(row.date);
|
||||||
if (!date) return [];
|
if (!date) return [];
|
||||||
return [{
|
return [{
|
||||||
date,
|
date,
|
||||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
temperatureMax: readNumber(row.temperatureMax),
|
||||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
temperatureMin: readNumber(row.temperatureMin),
|
||||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
precipitationProbability: readNumber(row.precipitationProbability),
|
||||||
precipitation: readNumber(series, "precipitation_sum", index),
|
precipitation: readNumber(row.precipitation),
|
||||||
weatherCode: readNumber(series, "weather_code", index),
|
weatherCode: readNumber(row.weatherCode),
|
||||||
sunrise: readString(series, "sunrise", index),
|
sunrise: readString(row.sunrise),
|
||||||
sunset: readString(series, "sunset", index),
|
sunset: readString(row.sunset),
|
||||||
|
sources: normalizeSources(row.sources),
|
||||||
}];
|
}];
|
||||||
});
|
}) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
|
||||||
const latitude = Number(raw.latitude);
|
const latitude = Number(raw.latitude);
|
||||||
const longitude = Number(raw.longitude);
|
const longitude = Number(raw.longitude);
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||||
@@ -57,6 +65,7 @@ function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
|||||||
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
||||||
hourly: normalizeHourlyForecast(raw.hourly),
|
hourly: normalizeHourlyForecast(raw.hourly),
|
||||||
daily: normalizeDailyForecast(raw.daily),
|
daily: normalizeDailyForecast(raw.daily),
|
||||||
|
sources: normalizeSources(raw.sources),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,5 +73,5 @@ export async function fetchForecast(latitude: number, longitude: number, signal?
|
|||||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||||
const response = await fetch(`/api/forecast?${params}`, { signal });
|
const response = await fetch(`/api/forecast?${params}`, { signal });
|
||||||
if (!response.ok) throw new Error("Unable to load forecast.");
|
if (!response.ok) throw new Error("Unable to load forecast.");
|
||||||
return normalizeForecast(await response.json() as RawWeatherForecast);
|
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
||||||
}
|
}
|
||||||
|
|||||||
216
lib/forecast-merge.ts
Normal file
216
lib/forecast-merge.ts
Normal 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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
16
lib/i18n.tsx
16
lib/i18n.tsx
@@ -90,7 +90,7 @@ const translations = {
|
|||||||
"weather.temperatureDetail": "Temperatura powietrza",
|
"weather.temperatureDetail": "Temperatura powietrza",
|
||||||
"forecast.label": "Prognoza modelowa",
|
"forecast.label": "Prognoza modelowa",
|
||||||
"forecast.title": "Najbliższe godziny i dni",
|
"forecast.title": "Najbliższe godziny i dni",
|
||||||
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową.",
|
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
|
||||||
"forecast.hourly": "Najbliższe 24 godziny",
|
"forecast.hourly": "Najbliższe 24 godziny",
|
||||||
"forecast.daily": "Prognoza 7-dniowa",
|
"forecast.daily": "Prognoza 7-dniowa",
|
||||||
"forecast.today": "Dzisiaj",
|
"forecast.today": "Dzisiaj",
|
||||||
@@ -115,9 +115,11 @@ const translations = {
|
|||||||
"forecast.maxProbability": "Maks. szansa opadu",
|
"forecast.maxProbability": "Maks. szansa opadu",
|
||||||
"forecast.pastHour": "Miniona godzina",
|
"forecast.pastHour": "Miniona godzina",
|
||||||
"forecast.source": "Źródło prognozy:",
|
"forecast.source": "Źródło prognozy:",
|
||||||
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
|
||||||
|
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.",
|
||||||
|
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
|
||||||
"forecast.emptyTitle": "Brak prognozy",
|
"forecast.emptyTitle": "Brak prognozy",
|
||||||
"forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.",
|
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
|
||||||
"forecast.condition.clear": "Bezchmurnie",
|
"forecast.condition.clear": "Bezchmurnie",
|
||||||
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
||||||
"forecast.condition.cloudy": "Pochmurno",
|
"forecast.condition.cloudy": "Pochmurno",
|
||||||
@@ -267,7 +269,7 @@ const translations = {
|
|||||||
"weather.temperatureDetail": "Air temperature",
|
"weather.temperatureDetail": "Air temperature",
|
||||||
"forecast.label": "Model forecast",
|
"forecast.label": "Model forecast",
|
||||||
"forecast.title": "Upcoming hours and days",
|
"forecast.title": "Upcoming hours and days",
|
||||||
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast.",
|
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||||
"forecast.hourly": "Next 24 hours",
|
"forecast.hourly": "Next 24 hours",
|
||||||
"forecast.daily": "7-day forecast",
|
"forecast.daily": "7-day forecast",
|
||||||
"forecast.today": "Today",
|
"forecast.today": "Today",
|
||||||
@@ -292,9 +294,11 @@ const translations = {
|
|||||||
"forecast.maxProbability": "Max. rain chance",
|
"forecast.maxProbability": "Max. rain chance",
|
||||||
"forecast.pastHour": "Past hour",
|
"forecast.pastHour": "Past hour",
|
||||||
"forecast.source": "Forecast source:",
|
"forecast.source": "Forecast source:",
|
||||||
"forecast.error": "Unable to load the Open-Meteo forecast.",
|
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
|
||||||
|
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.",
|
||||||
|
"forecast.error": "Unable to load the model forecast.",
|
||||||
"forecast.emptyTitle": "Forecast unavailable",
|
"forecast.emptyTitle": "Forecast unavailable",
|
||||||
"forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.",
|
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
|
||||||
"forecast.condition.clear": "Clear sky",
|
"forecast.condition.clear": "Clear sky",
|
||||||
"forecast.condition.partlyCloudy": "Partly cloudy",
|
"forecast.condition.partlyCloudy": "Partly cloudy",
|
||||||
"forecast.condition.cloudy": "Cloudy",
|
"forecast.condition.cloudy": "Cloudy",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "wtr-shell-v2";
|
const CACHE_NAME = "wtr-shell-v3";
|
||||||
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -16,7 +16,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current") {
|
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/forecast") {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ export interface RawWeatherForecast {
|
|||||||
daily?: RawForecastSeries;
|
daily?: RawForecastSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawImgwForecastRow {
|
||||||
|
Date?: unknown;
|
||||||
|
Temperature?: unknown;
|
||||||
|
Chill?: unknown;
|
||||||
|
Precipitation?: unknown;
|
||||||
|
Icon?: unknown;
|
||||||
|
Wind_Speed?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawImgwForecastResponse {
|
||||||
|
data?: {
|
||||||
|
Data?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForecastSource = "imgw-alaro" | "open-meteo";
|
||||||
|
|
||||||
export interface HourlyForecast {
|
export interface HourlyForecast {
|
||||||
time: string;
|
time: string;
|
||||||
temperature: number | null;
|
temperature: number | null;
|
||||||
@@ -30,6 +47,7 @@ export interface HourlyForecast {
|
|||||||
precipitation: number | null;
|
precipitation: number | null;
|
||||||
weatherCode: number | null;
|
weatherCode: number | null;
|
||||||
windSpeed: number | null;
|
windSpeed: number | null;
|
||||||
|
source: ForecastSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyForecast {
|
export interface DailyForecast {
|
||||||
@@ -41,6 +59,7 @@ export interface DailyForecast {
|
|||||||
weatherCode: number | null;
|
weatherCode: number | null;
|
||||||
sunrise: string | null;
|
sunrise: string | null;
|
||||||
sunset: string | null;
|
sunset: string | null;
|
||||||
|
sources: ForecastSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherForecast {
|
export interface WeatherForecast {
|
||||||
@@ -49,4 +68,5 @@ export interface WeatherForecast {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
hourly: HourlyForecast[];
|
hourly: HourlyForecast[];
|
||||||
daily: DailyForecast[];
|
daily: DailyForecast[];
|
||||||
|
sources: ForecastSource[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user