diff --git a/AGENTS.md b/AGENTS.md index a470d29..929a3ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - `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. - Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. +- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - 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. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. diff --git a/README.md b/README.md index db81f5c..9b7483a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazw 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. -Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. 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. +Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z miejscowości lub stacji wybranej w pogodzie. 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 województwa. Panel automatycznie ukrywa komunikaty po upływie ich czasu obowiązywania i nie obejmuje ostrzeżeń hydrologicznych. ## Stack diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index d65642b..9981538 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -14,6 +14,7 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations"; import { useCurrentWeather } from "@/hooks/use-current-weather"; import { ForecastPanel } from "@/components/forecast/forecast-panel"; import { locateSynopStations } from "@/lib/location-utils"; +import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; export function DashboardPage() { const { t } = useI18n(); @@ -41,6 +42,7 @@ export function DashboardPage() {
+ diff --git a/components/warnings/dashboard-warnings.tsx b/components/warnings/dashboard-warnings.tsx new file mode 100644 index 0000000..5e10e24 --- /dev/null +++ b/components/warnings/dashboard-warnings.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { motion } from "framer-motion"; +import { AlertTriangle, ArrowRight, CalendarClock } from "lucide-react"; +import { useWarnings } from "@/hooks/use-warnings"; +import { DEFAULT_STATION_ID } from "@/lib/constants"; +import { useI18n } from "@/lib/i18n"; +import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; +import { useWeatherStore } from "@/lib/store"; +import { formatDateTime } from "@/lib/weather-utils"; +import type { WeatherWarning } from "@/types/imgw"; + +const MAX_VISIBLE_WARNINGS = 2; + +function getTimestamp(value: string | null) { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function isWarningActive(warning: WeatherWarning, now: number) { + const validFrom = getTimestamp(warning.validFrom); + return validFrom === null || validFrom <= now; +} + +function compareDashboardWarnings(a: WeatherWarning, b: WeatherWarning, now: number) { + const activeDifference = Number(isWarningActive(b, now)) - Number(isWarningActive(a, now)); + if (activeDifference) return activeDifference; + + const levelDifference = (b.level ?? 0) - (a.level ?? 0); + if (levelDifference) return levelDifference; + + return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0); +} + +export function DashboardWarnings() { + const { data: warnings } = useWarnings(); + const { language, t } = useI18n(); + const selectedStationId = useWeatherStore((state) => state.selectedStationId); + const selectedLocation = useWeatherStore((state) => state.selectedLocation); + const [now, setNow] = useState(null); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setNow(Date.now()), 0); + const intervalId = window.setInterval(() => setNow(Date.now()), 30_000); + return () => { + window.clearTimeout(timeoutId); + window.clearInterval(intervalId); + }; + }, []); + + const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const relevantWarnings = useMemo(() => { + if (!warnings || !province || now === null) return []; + + return warnings + .filter((warning) => { + const validTo = getTimestamp(warning.validTo); + return warning.kind === "meteo" + && warning.provinces.includes(province) + && (validTo === null || validTo > now); + }) + .sort((a, b) => compareDashboardWarnings(a, b, now)); + }, [now, province, warnings]); + + if (!province || !relevantWarnings.length || now === null) return null; + + const visibleWarnings = relevantWarnings.slice(0, MAX_VISIBLE_WARNINGS); + const hiddenWarningsCount = relevantWarnings.length - visibleWarnings.length; + + return ( + +
+
+
+
+
+

+ IMGW · {formatProvinceName(province, language)} +

+

+ {t("warnings.dashboard.title")} +

+
+
+ + {t("warnings.dashboard.viewAll")} +
+ +
+ {visibleWarnings.map((warning) => { + const active = isWarningActive(warning, now); + const validityLabel = active + ? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) }) + : warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) }); + + return ( +
+

+ {t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")} + {warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`} +

+

+ {warning.title || t("warnings.genericMeteo")} +

+ {validityLabel && ( +

+

+ )} +
+ ); + })} +
+ + {hiddenWarningsCount > 0 && ( +

+ {t("warnings.dashboard.more", { count: hiddenWarningsCount })} +

+ )} +
+ ); +} diff --git a/components/warnings/warnings-panel.tsx b/components/warnings/warnings-panel.tsx index d77b3fb..16d52ff 100644 --- a/components/warnings/warnings-panel.tsx +++ b/components/warnings/warnings-panel.tsx @@ -8,7 +8,7 @@ import { EmptyState } from "@/components/states/empty-state"; import { ErrorState } from "@/components/states/error-state"; import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; -import { formatProvinceName, getProvinceForStation, normalizeProvinceName } from "@/lib/provinces"; +import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; import type { WeatherWarning } from "@/types/imgw"; @@ -29,8 +29,7 @@ export function WarningsPanel() { if (isError) return refetch()} description={t("warnings.error")} />; if (!warnings?.length) return ; - const province = normalizeProvinceName(selectedLocation?.province) - ?? getProvinceForStation(selectedStationId ?? DEFAULT_STATION_ID); + const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); if (!province) return ; const provinceLabel = formatProvinceName(province, language); diff --git a/lib/i18n.tsx b/lib/i18n.tsx index f92cd56..269353a 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -167,6 +167,13 @@ const translations = { "warnings.probability": "Prawdopodobieństwo: {value}%", "warnings.genericHydro": "Ostrzeżenie hydrologiczne", "warnings.genericMeteo": "Ostrzeżenie meteorologiczne", + "warnings.dashboard.title": "Ostrzeżenia meteo dla Twojego regionu", + "warnings.dashboard.active": "Aktywne ostrzeżenie", + "warnings.dashboard.upcoming": "Nadchodzące ostrzeżenie", + "warnings.dashboard.validUntil": "Do {date}", + "warnings.dashboard.validFrom": "Od {date}", + "warnings.dashboard.more": "+{count} kolejne ostrzeżenia", + "warnings.dashboard.viewAll": "Zobacz wszystkie", "hydro.section": "Monitoring wód IMGW", "hydro.title": "Hydro", "hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.", @@ -346,6 +353,13 @@ const translations = { "warnings.probability": "Probability: {value}%", "warnings.genericHydro": "Hydrological warning", "warnings.genericMeteo": "Meteorological warning", + "warnings.dashboard.title": "Weather warnings for your region", + "warnings.dashboard.active": "Active warning", + "warnings.dashboard.upcoming": "Upcoming warning", + "warnings.dashboard.validUntil": "Until {date}", + "warnings.dashboard.validFrom": "From {date}", + "warnings.dashboard.more": "+{count} more warnings", + "warnings.dashboard.viewAll": "View all", "hydro.section": "IMGW water monitoring", "hydro.title": "Hydro", "hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.", diff --git a/lib/provinces.ts b/lib/provinces.ts index d7849dd..ba78964 100644 --- a/lib/provinces.ts +++ b/lib/provinces.ts @@ -128,6 +128,10 @@ export function normalizeProvinceName(value: string | null | undefined) { return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null; } +export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) { + return normalizeProvinceName(locationProvince) ?? getProvinceForStation(stationId); +} + export function formatProvinceName(province: Province, language: Language) { return provinceLabels[province][language]; }