diff --git a/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx index 09940dab..2b0c5a67 100644 --- a/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx +++ b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx @@ -20,6 +20,8 @@ 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", @@ -71,10 +73,40 @@ function formatUpdatedAt(value: string | undefined, isEn: boolean) { }); } +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) => { @@ -98,6 +130,17 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps () => 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; @@ -105,13 +148,34 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
{open && (
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts index 06ef73e6..d787d93d 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts @@ -60,6 +60,15 @@ export function runTests() { !componentSource.includes("setInterval("), "announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling", ); + assert( + componentSource.includes("polyweather_update_announcement_seen_v1") && + componentSource.includes("loadSeenAnnouncementIds") && + componentSource.includes("saveSeenAnnouncementIds") && + componentSource.includes("markAnnouncementAsSeen") && + componentSource.includes("announcement.id") && + componentSource.includes("scan-update-announcement-unread"), + "announcement component must persist seen announcement ids and render an unread indicator for unseen updates", + ); assert( componentSource.includes("实时观测链路已和模型缓存拆分") && componentSource.includes("SSE") &&