From 3309e03acb3fce493c841474d3de41061bd3a711 Mon Sep 17 00:00:00 2001 From: zv Date: Fri, 12 Jun 2026 21:51:44 +0200 Subject: [PATCH] fix: treat empty IMGW warning products as no warnings --- AGENTS.md | 1 + README.md | 2 ++ app/api/imgw/[...path]/route.ts | 7 +++++++ lib/imgw-empty-response.ts | 17 +++++++++++++++++ lib/server-warnings.ts | 6 ++++-- 5 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 lib/imgw-empty-response.ts diff --git a/AGENTS.md b/AGENTS.md index 5793ce2..736f132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - 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 regułowy brief dnia, 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` i `lib/warning-regions.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. +- HTTP `404` z IMGW dla ostrzeżeń z JSON `{"status":false,"message":"No products were found"}` oznacza brak produktów i ma być normalizowany do pustej listy, nie do błędu UI ani błędu workera. - Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. diff --git a/README.md b/README.md index 17cd79a..d2cd6b1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrą Widok ostrzeżeń priorytetyzuje komunikaty dla obszaru wynikającego z miejscowości lub stacji wybranej w pogodzie. Dla ostrzeżeń meteorologicznych, gdy aplikacja rozpozna powiat wybranej miejscowości, filtrowanie odbywa się po kodzie TERYT powiatu; w przeciwnym razie pozostaje fallback wojewódzki. W każdej grupie ostrzeżenia meteorologiczne, np. o burzach lub silnym wietrze, są wyświetlane przed hydrologicznymi. Ostrzeżenia meteorologiczne IMGW przypisuje do regionów na podstawie kodów TERYT, a hydrologiczne na podstawie jawnych pól województwa z API. Pozostałe aktywne komunikaty są wyświetlane niżej. Dashboard pokazuje dodatkowo kompaktowy panel aktywnych i nadchodzących ostrzeżeń meteo dla wybranego obszaru. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych. +Publiczne endpointy ostrzeżeń IMGW potrafią zwrócić HTTP `404` z treścią `{"status":false,"message":"No products were found"}`. Aplikacja traktuje taki wariant jako poprawną pustą listę ostrzeżeń, a nie awarię źródła danych. + ## Stack - Next.js z App Router i TypeScript diff --git a/app/api/imgw/[...path]/route.ts b/app/api/imgw/[...path]/route.ts index 9527147..2645ccd 100644 --- a/app/api/imgw/[...path]/route.ts +++ b/app/api/imgw/[...path]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response"; const ALLOWED_PATHS = new Set([ "synop", @@ -28,6 +29,12 @@ export async function GET(_: Request, context: { params: Promise<{ path: string[ headers: { Accept: "application/json", "User-Agent": USER_AGENT }, }); if (!response.ok) { + const body = await readImgwResponseBody(response); + if ((resource === "warningsmeteo" || resource === "warningshydro") && response.status === 404 && isImgwNoProductsResponse(body.json)) { + return NextResponse.json([], { + headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" }, + }); + } return NextResponse.json({ error: "IMGW API jest chwilowo niedostępne." }, { status: response.status }); } const data: unknown = await response.json(); diff --git a/lib/imgw-empty-response.ts b/lib/imgw-empty-response.ts new file mode 100644 index 0000000..c604412 --- /dev/null +++ b/lib/imgw-empty-response.ts @@ -0,0 +1,17 @@ +export function isImgwNoProductsResponse(value: unknown) { + if (!value || typeof value !== "object") return false; + const response = value as { status?: unknown; message?: unknown }; + return response.status === false + && typeof response.message === "string" + && response.message.toLocaleLowerCase("en-US").includes("no products were found"); +} + +export async function readImgwResponseBody(response: Response) { + const text = await response.text().catch(() => ""); + if (!text) return { text, json: null as unknown }; + try { + return { text, json: JSON.parse(text) as unknown }; + } catch { + return { text, json: null as unknown }; + } +} diff --git a/lib/server-warnings.ts b/lib/server-warnings.ts index de3548a..cff4653 100644 --- a/lib/server-warnings.ts +++ b/lib/server-warnings.ts @@ -1,4 +1,5 @@ import { normalizeWarning } from "@/lib/weather-utils"; +import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response"; import type { RawWarning, WeatherWarning } from "@/types/imgw"; const IMGW_WARNINGS_METEO_URL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo"; @@ -11,8 +12,9 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise ""); - throw new Error(`Unable to load IMGW meteorological warnings: ${response.status}${details ? ` ${details.slice(0, 240)}` : ""}`); + const details = await readImgwResponseBody(response); + if (response.status === 404 && isImgwNoProductsResponse(details.json)) return []; + throw new Error(`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`); } const rows = await response.json() as RawWarning[]; return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];