From e832d4e63b8e483c7619d97a024a56dc61d1a29d Mon Sep 17 00:00:00 2001 From: zv Date: Tue, 2 Jun 2026 18:06:02 +0200 Subject: [PATCH] fix: preserve partial IMGW Hybrid coverage --- AGENTS.md | 1 + README.md | 2 +- components/charts/day-forecast-charts.tsx | 4 ++-- components/charts/snapshot-chart.tsx | 2 +- components/weather/weather-hero.tsx | 26 +++++++++++++---------- lib/i18n.tsx | 6 ++++-- lib/imgw-current-api.ts | 13 ++++++------ types/imgw-current.ts | 2 ++ 8 files changed, 33 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6cdf843..8a89bcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. - Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji. +- Hybrid może zwrócić wyłącznie lokalny opad MERGE bez parametrów AROME. Zachowuj taki opad 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. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. diff --git a/README.md b/README.md index c7a9bf7..890e5cd 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i ## Ograniczenia API -Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Dzięki temu bieżące warunki są analizowane dla współrzędnych wybranej miejscowości, aktualizowane częściej niż godzinowe dane synoptyczne i mogą pokazać rzeczywisty opad z ostatnich 10 minut. Interfejs oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Jeśli usługa Hybrid nie odpowiada, hero zachowuje pomiar `synop` jako jawnie oznaczony fallback i ostrzega, gdy stacja jest oddalona od miejscowości o co najmniej 30 km. 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`. Dzięki temu bieżące warunki są analizowane dla współrzędnych wybranej miejscowości, aktualizowane częściej niż godzinowe dane synoptyczne i mogą 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 parametrów AROME, 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`. diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx index 83c0b49..58a5106 100644 --- a/components/charts/day-forecast-charts.tsx +++ b/components/charts/day-forecast-charts.tsx @@ -26,7 +26,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.temperatureChart")}

{t("forecast.temperatureChartDescription")}

- + @@ -47,7 +47,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.rainfallChart")}

{t("forecast.rainfallChartDescription")}

- + diff --git a/components/charts/snapshot-chart.tsx b/components/charts/snapshot-chart.tsx index 3190782..b487d3a 100644 --- a/components/charts/snapshot-chart.tsx +++ b/components/charts/snapshot-chart.tsx @@ -19,7 +19,7 @@ export function SnapshotChart({ station }: { station: SynopStation }) {

{t("snapshot.title")}

{t("snapshot.description")}

- + diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 2a78867..9643241 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -23,23 +23,25 @@ import { useI18n } from "@/lib/i18n"; export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) { const { language, t } = useI18n(); const displayedLocationName = locationName ?? station.name; - const hasDistantFallback = !currentWeather && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; + const hasFullHybridAnalysis = currentWeather?.coverage === "full"; + const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; + const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; const displayedStation = currentWeather ? { ...station, - measuredAt: currentWeather.measuredAt, - temperature: currentWeather.temperature, - windSpeed: currentWeather.windSpeed, - windDirection: currentWeather.windDirection, - humidity: currentWeather.humidity, - pressure: currentWeather.pressure, - rainfall: currentWeather.precipitation10m, + measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt, + temperature: currentWeather.temperature ?? station.temperature, + windSpeed: currentWeather.windSpeed ?? station.windSpeed, + windDirection: currentWeather.windDirection ?? station.windDirection, + humidity: currentWeather.humidity ?? station.humidity, + pressure: currentWeather.pressure ?? station.pressure, + rainfall: currentWeather.precipitation10m ?? station.rainfall, } : station; const mood = getWeatherMoodFromData(displayedStation); const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const metrics = [ { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) }, - { icon: Umbrella, label: currentWeather ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, + { icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, ]; @@ -59,12 +61,14 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f

{currentWeatherLoading ? t("location.heroHybridLoading", { station: station.name }) - : currentWeather + : hasFullHybridAnalysis ? t("location.heroHybridSource", { location: displayedLocationName }) + : hasPartialHybridAnalysis + ? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 }) : locationName ? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 }) : t("location.heroStationFallback", { station: station.name })}

- {currentWeather && locationName &&

{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}

} + {hasFullHybridAnalysis && locationName &&

{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}

} {hasDistantFallback &&

{t("location.heroDistantFallback")}

}
diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 294ff5e..25c639a 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -33,9 +33,10 @@ const translations = { "location.preparing": "Przygotowuję listę najbliższych stacji IMGW…", "location.empty": "Nie znaleziono pasującej miejscowości w Polsce.", "location.nearest": "Najbliższa stacja IMGW", - "location.currentSource": "{location}: bieżąca pogoda jest analizowana lokalnie dla współrzędnych miejscowości. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", + "location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", "location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", + "location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km", "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km", "location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}", "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km", @@ -209,9 +210,10 @@ const translations = { "location.preparing": "Preparing the nearest IMGW stations…", "location.empty": "No matching place was found in Poland.", "location.nearest": "Nearest IMGW station", - "location.currentSource": "{location}: current weather is analysed locally for the place coordinates. Nearest IMGW measurement station: {station} · approximately {distance} km away.", + "location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.", "location.heroHybridSource": "IMGW Hybrid analysis for: {location}", "location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", + "location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away", "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away", "location.heroStationFallback": "Fallback data from IMGW station: {station}", "location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away", diff --git a/lib/imgw-current-api.ts b/lib/imgw-current-api.ts index cb7af2c..fbeeacb 100644 --- a/lib/imgw-current-api.ts +++ b/lib/imgw-current-api.ts @@ -35,15 +35,15 @@ function getCondition(weatherCode: number | null, rainfall10m: number | null, sn export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; - const row = payload.data.Data + const rows = payload.data.Data .filter((candidate): candidate is RawImgwHybridWeatherRow => { if (!candidate || typeof candidate !== "object") return false; - return candidate.Type === "Type_Ten_Minutes" - && typeof candidate.MODEL === "string" - && candidate.MODEL.includes("AROME") - && normalizeDate(candidate.Date) !== null; + return candidate.Type === "Type_Ten_Minutes" && normalizeDate(candidate.Date) !== null; }) - .sort((left, right) => String(right.Date).localeCompare(String(left.Date)))[0]; + .sort((left, right) => String(left.Date).localeCompare(String(right.Date))); + const fullRow = rows.find((candidate) => typeof candidate.MODEL === "string" && candidate.MODEL.includes("AROME")); + const precipitationRow = rows.find((candidate) => candidate.Precipitation10m !== undefined); + const row = fullRow ?? precipitationRow; if (!row) return null; const measuredAt = normalizeDate(row.Date); @@ -53,6 +53,7 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons const weatherCode = getWeatherCode(row.Icon10); return { + coverage: fullRow ? "full" : "precipitation-only", measuredAt, temperature: toCelsius(row.Temperature), feelsLike: toCelsius(row.Chill), diff --git a/types/imgw-current.ts b/types/imgw-current.ts index 29af490..d4af477 100644 --- a/types/imgw-current.ts +++ b/types/imgw-current.ts @@ -23,8 +23,10 @@ export interface RawImgwHybridWeatherResponse { } export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; +export type ImgwCurrentWeatherCoverage = "full" | "precipitation-only"; export interface ImgwCurrentWeather { + coverage: ImgwCurrentWeatherCoverage; measuredAt: string; temperature: number | null; feelsLike: number | null;