diff --git a/AGENTS.md b/AGENTS.md index 8a89bcc..be8fb69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +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`. +- Hybrid wybieraj z lokalnego rekordu aktualnej godziny UTC, zgodnie z portalem `meteo.imgw.pl`; rekord może być 10-minutowy albo godzinowy. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`. - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. - Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - `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 890e5cd..f182eae 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. 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. +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`. diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx index 58a5106..4fa1428 100644 --- a/components/charts/day-forecast-charts.tsx +++ b/components/charts/day-forecast-charts.tsx @@ -6,6 +6,8 @@ import { useI18n } from "@/lib/i18n"; import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; import type { HourlyForecast } from "@/types/forecast"; +const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; + function formatHour(value: string) { return value.slice(11, 16); } @@ -25,8 +27,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.temperatureChart")}

{t("forecast.temperatureChartDescription")}

-
- +
+ @@ -46,8 +48,8 @@ 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 b487d3a..edc7e2c 100644 --- a/components/charts/snapshot-chart.tsx +++ b/components/charts/snapshot-chart.tsx @@ -5,6 +5,8 @@ import type { SynopStation } from "@/types/imgw"; import { Card } from "@/components/ui/card"; import { useI18n } from "@/lib/i18n"; +const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; + export function SnapshotChart({ station }: { station: SynopStation }) { const { t } = useI18n(); const rows = [ @@ -18,8 +20,8 @@ export function SnapshotChart({ station }: { station: SynopStation }) {

{t("snapshot.label")}

{t("snapshot.title")}

{t("snapshot.description")}

-
- +
+ diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 9643241..4c1b0c3 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -23,7 +23,7 @@ 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 hasFullHybridAnalysis = currentWeather?.coverage === "full"; + const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30; const displayedStation = currentWeather ? { diff --git a/lib/imgw-current-api.ts b/lib/imgw-current-api.ts index fbeeacb..544ad02 100644 --- a/lib/imgw-current-api.ts +++ b/lib/imgw-current-api.ts @@ -32,17 +32,22 @@ function getCondition(weatherCode: number | null, rainfall10m: number | null, sn return null; } +function getCurrentUtcHour() { + return new Date().toISOString().slice(0, 13); +} + export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; const rows = payload.data.Data .filter((candidate): candidate is RawImgwHybridWeatherRow => { if (!candidate || typeof candidate !== "object") return false; - return candidate.Type === "Type_Ten_Minutes" && normalizeDate(candidate.Date) !== null; + return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null; }) .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 currentUtcHour = getCurrentUtcHour(); + const fullRow = rows.find((candidate) => String(candidate.Date).startsWith(currentUtcHour) && candidate.Temperature !== undefined); + const precipitationRow = rows.find((candidate) => String(candidate.Date).startsWith(currentUtcHour) && candidate.Precipitation10m !== undefined); const row = fullRow ?? precipitationRow; if (!row) return null; @@ -53,7 +58,7 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons const weatherCode = getWeatherCode(row.Icon10); return { - coverage: fullRow ? "full" : "precipitation-only", + coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only", measuredAt, temperature: toCelsius(row.Temperature), feelsLike: toCelsius(row.Chill), diff --git a/types/imgw-current.ts b/types/imgw-current.ts index d4af477..91284f4 100644 --- a/types/imgw-current.ts +++ b/types/imgw-current.ts @@ -23,7 +23,7 @@ export interface RawImgwHybridWeatherResponse { } export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null; -export type ImgwCurrentWeatherCoverage = "full" | "precipitation-only"; +export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only"; export interface ImgwCurrentWeather { coverage: ImgwCurrentWeatherCoverage;