feat: show local weather warnings on dashboard
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user