142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
"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-panel border border-warning/25 bg-warning/10 p-4 shadow-soft 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-control border border-warning/30 bg-warning/10 p-2 text-warning">
|
|
<AlertTriangle className="size-4" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
|
|
IMGW · {formatProvinceName(province, language)}
|
|
</p>
|
|
<h2 className="mt-1 text-base font-semibold text-foreground">
|
|
{t("warnings.dashboard.title")}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
<Link
|
|
href="/warnings"
|
|
className="inline-flex w-fit items-center gap-1.5 rounded-control px-2 py-1 text-xs font-semibold text-warning transition hover:bg-warning/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-warning"
|
|
>
|
|
{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-card border border-warning/20 bg-surface px-3.5 py-3"
|
|
>
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
|
|
{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-foreground">
|
|
{warning.title || t("warnings.genericMeteo")}
|
|
</p>
|
|
{validityLabel && (
|
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
|
<CalendarClock className="size-3.5" aria-hidden="true" />
|
|
{validityLabel}
|
|
</p>
|
|
)}
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{hiddenWarningsCount > 0 && (
|
|
<p className="mt-3 text-xs font-medium text-warning">
|
|
{t("warnings.dashboard.more", { count: hiddenWarningsCount })}
|
|
</p>
|
|
)}
|
|
</motion.section>
|
|
);
|
|
}
|