"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: "live-observation-chart-2026-06", publishedAt: "2026-06-17T00:00:00+08:00", expiresAt: "2026-07-31T00:00:00+08:00", zh: { title: "更新公告:实时观测和图表稳定性升级", body: "实时观测链路已和模型缓存拆分:SSE 到达会立即更新图表;如果 SSE 断线,终端会每 3 分钟拉取一次轻量观测兜底。DEB、模型曲线、概率和历史数据继续走独立缓存,不再跟着实时温度强刷。\n\n" + "这次也补齐了更多官方观测覆盖,包括东京 JMA、韩国 AMOS、土耳其 MGM、台北 CWA 等,并修正 NOAA MADIS 只应用于美国城市,避免非美国城市串台。\n\n" + "图表侧同步修复了预测曲线缺段、首屏 loading 不明显、旧缓存覆盖最新观测等问题。刷新终端后即可使用新逻辑。", }, en: { title: "Update: live observations and chart stability", body: "Live observations are now separated from cached model detail. SSE patches update charts immediately; if SSE is unavailable, the terminal falls back to a lightweight observation fetch every 3-minute interval. DEB, model curves, probabilities, and historical data stay on their own cache path instead of refreshing with every live temperature update.\n\n" + "Official observation coverage has also expanded, including Tokyo JMA, Korea AMOS, Turkey MGM, and Taipei CWA. NOAA MADIS is now restricted to US cities to avoid cross-region source leakage.\n\n" + "Charts also include fixes for truncated forecast curves, first-load chart loading visibility, and stale cache overriding newer observations. Refresh the terminal to use the new flow.", }, }, ]; 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}

)}
)}
); }