feat: show local weather warnings on dashboard
This commit is contained in:
@@ -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.
|
- `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.
|
- 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.
|
- 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.
|
- 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ą.
|
- 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.
|
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Stack
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
|||||||
import { useCurrentWeather } from "@/hooks/use-current-weather";
|
import { useCurrentWeather } from "@/hooks/use-current-weather";
|
||||||
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
||||||
import { locateSynopStations } from "@/lib/location-utils";
|
import { locateSynopStations } from "@/lib/location-utils";
|
||||||
|
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -41,6 +42,7 @@ export function DashboardPage() {
|
|||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<LocationSearch stations={stations} positions={positions} />
|
<LocationSearch stations={stations} positions={positions} />
|
||||||
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||||
|
<DashboardWarnings />
|
||||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||||
<FavoritesSection stations={stations} />
|
<FavoritesSection stations={stations} />
|
||||||
<FeaturedStationsSection stations={stations} />
|
<FeaturedStationsSection stations={stations} />
|
||||||
|
|||||||
141
components/warnings/dashboard-warnings.tsx
Normal file
141
components/warnings/dashboard-warnings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { EmptyState } from "@/components/states/empty-state";
|
|||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { formatProvinceName, getProvinceForStation, normalizeProvinceName } from "@/lib/provinces";
|
import { formatProvinceName, getProvinceForSelection } from "@/lib/provinces";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import type { WeatherWarning } from "@/types/imgw";
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
|
||||||
@@ -29,8 +29,7 @@ export function WarningsPanel() {
|
|||||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
||||||
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||||
|
|
||||||
const province = normalizeProvinceName(selectedLocation?.province)
|
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||||
?? getProvinceForStation(selectedStationId ?? DEFAULT_STATION_ID);
|
|
||||||
if (!province) return <WarningGrid warnings={warnings} />;
|
if (!province) return <WarningGrid warnings={warnings} />;
|
||||||
|
|
||||||
const provinceLabel = formatProvinceName(province, language);
|
const provinceLabel = formatProvinceName(province, language);
|
||||||
|
|||||||
14
lib/i18n.tsx
14
lib/i18n.tsx
@@ -167,6 +167,13 @@ const translations = {
|
|||||||
"warnings.probability": "Prawdopodobieństwo: {value}%",
|
"warnings.probability": "Prawdopodobieństwo: {value}%",
|
||||||
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
|
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
|
||||||
"warnings.genericMeteo": "Ostrzeżenie meteorologiczne",
|
"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.section": "Monitoring wód IMGW",
|
||||||
"hydro.title": "Hydro",
|
"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.",
|
"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.probability": "Probability: {value}%",
|
||||||
"warnings.genericHydro": "Hydrological warning",
|
"warnings.genericHydro": "Hydrological warning",
|
||||||
"warnings.genericMeteo": "Meteorological 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.section": "IMGW water monitoring",
|
||||||
"hydro.title": "Hydro",
|
"hydro.title": "Hydro",
|
||||||
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ export function normalizeProvinceName(value: string | null | undefined) {
|
|||||||
return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null;
|
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) {
|
export function formatProvinceName(province: Province, language: Language) {
|
||||||
return provinceLabels[province][language];
|
return provinceLabels[province][language];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user