"use client"; import { Megaphone, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; type AnnouncementText = { title: string; body: string; }; type StaticUpdateAnnouncement = { id: string; publishedAt: string; expiresAt: string; zh: AnnouncementText; en: AnnouncementText; }; type UpdateAnnouncementButtonProps = { isEn: boolean; }; const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1"; const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [ { id: "model-summary-table-local-time-2026-06-29", publishedAt: "2026-06-29T19:35:00+08:00", expiresAt: "2026-08-15T00:00:00+08:00", zh: { title: "更新公告:模型汇总表上线", body: "终端左侧新增「模型汇总」菜单,按今日最高温口径汇总全部城市:当地时间、DEB 预测,以及 ECMWF、ECMWF AIFS、GFS、ICON、ICON-EU、GEM、GDPS、JMA、AROME HD、HRRR、NAM 等模型高温预测集中到一张表里。\n\n" + "缺失模型会显示 —;模型中位数和分歧范围只基于已有模型值计算,方便快速发现 DEB 与多模型之间的偏离。\n\n" + "页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。", }, en: { title: "Update: Model Summary table is live", body: "The left terminal navigation now includes Model Summary, a city-by-city table for today's high-temperature view: local time, DEB forecast, plus ECMWF, ECMWF AIFS, GFS, ICON, ICON-EU, GEM, GDPS, JMA, AROME HD, HRRR, and NAM model highs in one workspace.\n\n" + "Missing models render as —. Model median and spread are calculated only from available model values, making it easier to spot where DEB diverges from the model cluster.\n\n" + "The page supports city or model search, plus Only DEB and Large spread filters. Mobile keeps the city column sticky with horizontal table scrolling. Refresh the terminal to use it.", }, }, ]; function isActiveAnnouncement(item: StaticUpdateAnnouncement, now = Date.now()) { const expiresAt = Date.parse(item.expiresAt); if (!Number.isFinite(expiresAt) || expiresAt <= now) return false; const publishedAt = Date.parse(item.publishedAt); return !Number.isFinite(publishedAt) || publishedAt <= now; } function pickAnnouncementText(payload: StaticUpdateAnnouncement, isEn: boolean) { const primary = isEn ? payload.en : payload.zh; const fallback = isEn ? payload.zh : payload.en; return { title: String(primary?.title || fallback?.title || "").trim(), body: String(primary?.body || fallback?.body || "").trim(), }; } function formatUpdatedAt(value: string | undefined, isEn: boolean) { if (!value) return ""; const date = new Date(value); if (!Number.isFinite(date.getTime())) return ""; return date.toLocaleString(isEn ? "en-US" : "zh-CN", { hour12: false, month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function loadSeenAnnouncementIds() { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(UPDATE_ANNOUNCEMENT_SEEN_KEY); const parsed = raw ? JSON.parse(raw) : []; return new Set(Array.isArray(parsed) ? parsed.map(String) : []); } catch { return new Set(); } } function saveSeenAnnouncementIds(ids: Set) { if (typeof window === "undefined") return; try { window.localStorage.setItem( UPDATE_ANNOUNCEMENT_SEEN_KEY, JSON.stringify(Array.from(ids).slice(-50)), ); } catch { // Ignore storage failures; the unread dot may reappear but the announcement remains usable. } } export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) { const [open, setOpen] = useState(false); const [seenIds, setSeenIds] = useState>(new Set()); const [seenLoaded, setSeenLoaded] = useState(false); const shellRef = useRef(null); useEffect(() => { setSeenIds(loadSeenAnnouncementIds()); setSeenLoaded(true); }, []); useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (!shellRef.current?.contains(event.target as Node)) { setOpen(false); } }; window.addEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown); }, [open]); const announcement = useMemo( () => STATIC_UPDATE_ANNOUNCEMENTS.find((item) => isActiveAnnouncement(item)) ?? null, [], ); const text = useMemo( () => (announcement ? pickAnnouncementText(announcement, isEn) : { title: "", body: "" }), [announcement, isEn], ); const updatedAt = useMemo( () => formatUpdatedAt(announcement?.publishedAt, isEn), [announcement?.publishedAt, isEn], ); const hasUnread = Boolean(seenLoaded && announcement?.id && !seenIds.has(announcement.id)); const markAnnouncementAsSeen = (announcementId: string) => { setSeenIds((current) => { if (current.has(announcementId)) return current; const next = new Set(current); next.add(announcementId); saveSeenAnnouncementIds(next); return next; }); }; if (!announcement || (!text.title && !text.body)) return null; return (
{open && (
{text.title || (isEn ? "PolyWeather update" : "PolyWeather 更新")}
{updatedAt && (
{isEn ? "Updated" : "更新"} {updatedAt}
)}
{text.body && (

{text.body}

)}
)}
); }