feat: show local weather warnings on dashboard

This commit is contained in:
zv
2026-06-02 21:15:14 +02:00
parent 99282c5280
commit 7dcfc47375
7 changed files with 165 additions and 4 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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() {
<div className="space-y-10">
<LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<DashboardWarnings />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} />

View File

@@ -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<number | null>(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 (
<motion.section
aria-label={t("warnings.dashboard.title")}
className="rounded-[1.75rem] border border-amber-200/60 bg-amber-50/55 p-4 shadow-[0_18px_50px_-34px_rgba(146,64,14,0.45)] backdrop-blur-xl dark:border-amber-300/15 dark:bg-amber-950/15 sm:p-5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full border border-amber-300/60 bg-amber-100/70 p-2 text-amber-700 dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200">
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-amber-800/70 dark:text-amber-100/65">
IMGW · {formatProvinceName(province, language)}
</p>
<h2 className="mt-1 text-base font-semibold text-slate-900 dark:text-white">
{t("warnings.dashboard.title")}
</h2>
</div>
</div>
<Link
href="/warnings"
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 dark:text-amber-100 dark:hover:bg-amber-300/10"
>
{t("warnings.dashboard.viewAll")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-2">
{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 (
<article
key={warning.id}
className="rounded-2xl border border-amber-200/60 bg-white/45 px-3.5 py-3 dark:border-amber-200/10 dark:bg-slate-950/15"
>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-amber-800/70 dark:text-amber-100/65">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
</p>
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
{warning.title || t("warnings.genericMeteo")}
</p>
{validityLabel && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-slate-600 dark:text-slate-300">
<CalendarClock className="size-3.5" aria-hidden="true" />
{validityLabel}
</p>
)}
</article>
);
})}
</div>
{hiddenWarningsCount > 0 && (
<p className="mt-3 text-xs font-medium text-amber-800/75 dark:text-amber-100/70">
{t("warnings.dashboard.more", { count: hiddenWarningsCount })}
</p>
)}
</motion.section>
);
}

View File

@@ -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 <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
const province = normalizeProvinceName(selectedLocation?.province)
?? getProvinceForStation(selectedStationId ?? DEFAULT_STATION_ID);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language);

View File

@@ -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.",

View File

@@ -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];
}