fix: preserve partial IMGW Hybrid coverage

This commit is contained in:
zv
2026-06-02 18:06:02 +02:00
parent 93c9b40931
commit e832d4e63b
8 changed files with 33 additions and 23 deletions

View File

@@ -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`. - 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ę 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. - 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. - 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 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. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.

View File

@@ -65,7 +65,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i
## Ograniczenia API ## 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`. 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`.

View File

@@ -26,7 +26,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p>
<div className="mt-4 h-56 w-full"> <div className="mt-4 h-56 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0}>
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}> <ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
@@ -47,7 +47,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 w-full"> <div className="mt-4 h-56 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0}>
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}> <ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />

View File

@@ -19,7 +19,7 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p> <p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
<div className="mt-5 h-52 w-full"> <div className="mt-5 h-52 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0}>
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}> <BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} /> <XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} /> <YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />

View File

@@ -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 }) { 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 { language, t } = useI18n();
const displayedLocationName = locationName ?? station.name; 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 ? { const displayedStation = currentWeather ? {
...station, ...station,
measuredAt: currentWeather.measuredAt, measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature, temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed, windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection, windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity, humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure, pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m, rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station; } : station;
const mood = getWeatherMoodFromData(displayedStation); const mood = getWeatherMoodFromData(displayedStation);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [ const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, 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) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
]; ];
@@ -59,12 +61,14 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="mt-1.5 space-y-1 text-xs text-white/65"> <div className="mt-1.5 space-y-1 text-xs text-white/65">
<p>{currentWeatherLoading <p>{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name }) ? t("location.heroHybridLoading", { station: station.name })
: currentWeather : hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName }) ? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName : locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 }) ? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: t("location.heroStationFallback", { station: station.name })}</p> : t("location.heroStationFallback", { station: station.name })}</p>
{currentWeather && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>} {hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
{hasDistantFallback && <p className="flex items-start gap-1.5 text-amber-100"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>} {hasDistantFallback && <p className="flex items-start gap-1.5 text-amber-100"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
</div> </div>
</div> </div>

View File

@@ -33,9 +33,10 @@ const translations = {
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…", "location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.", "location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
"location.nearest": "Najbliższa stacja IMGW", "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.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "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.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}", "location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km", "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.preparing": "Preparing the nearest IMGW stations…",
"location.empty": "No matching place was found in Poland.", "location.empty": "No matching place was found in Poland.",
"location.nearest": "Nearest IMGW station", "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.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "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.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
"location.heroStationFallback": "Fallback data from IMGW station: {station}", "location.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away", "location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",

View File

@@ -35,15 +35,15 @@ function getCondition(weatherCode: number | null, rainfall10m: number | null, sn
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return 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 => { .filter((candidate): candidate is RawImgwHybridWeatherRow => {
if (!candidate || typeof candidate !== "object") return false; if (!candidate || typeof candidate !== "object") return false;
return candidate.Type === "Type_Ten_Minutes" return candidate.Type === "Type_Ten_Minutes" && normalizeDate(candidate.Date) !== null;
&& typeof candidate.MODEL === "string"
&& candidate.MODEL.includes("AROME")
&& 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; if (!row) return null;
const measuredAt = normalizeDate(row.Date); const measuredAt = normalizeDate(row.Date);
@@ -53,6 +53,7 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
const weatherCode = getWeatherCode(row.Icon10); const weatherCode = getWeatherCode(row.Icon10);
return { return {
coverage: fullRow ? "full" : "precipitation-only",
measuredAt, measuredAt,
temperature: toCelsius(row.Temperature), temperature: toCelsius(row.Temperature),
feelsLike: toCelsius(row.Chill), feelsLike: toCelsius(row.Chill),

View File

@@ -23,8 +23,10 @@ export interface RawImgwHybridWeatherResponse {
} }
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
export type ImgwCurrentWeatherCoverage = "full" | "precipitation-only";
export interface ImgwCurrentWeather { export interface ImgwCurrentWeather {
coverage: ImgwCurrentWeatherCoverage;
measuredAt: string; measuredAt: string;
temperature: number | null; temperature: number | null;
feelsLike: number | null; feelsLike: number | null;