2026-06-07 00:04:08 +08:00
"use client" ;
import { Megaphone , X } from "lucide-react" ;
import { useEffect , useMemo , useRef , useState } from "react" ;
type AnnouncementText = {
2026-06-07 00:32:11 +08:00
title : string ;
body : string ;
2026-06-07 00:04:08 +08:00
};
2026-06-07 00:32:11 +08:00
type StaticUpdateAnnouncement = {
id : string ;
publishedAt : string ;
expiresAt : string ;
zh : AnnouncementText ;
en : AnnouncementText ;
2026-06-07 00:04:08 +08:00
};
type UpdateAnnouncementButtonProps = {
isEn : boolean ;
};
2026-06-17 02:18:49 +08:00
const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1" ;
2026-06-07 00:32:11 +08:00
const STATIC_UPDATE_ANNOUNCEMENTS : StaticUpdateAnnouncement [] = [
{
2026-06-29 19:30:35 +08:00
id : "model-summary-table-local-time-2026-06-29" ,
publishedAt : "2026-06-29T19:35:00+08:00" ,
2026-06-29 19:17:51 +08:00
expiresAt : "2026-08-15T00:00:00+08:00" ,
2026-06-07 00:32:11 +08:00
zh : {
2026-06-29 19:17:51 +08:00
title : "更新公告:模型汇总表上线" ,
2026-06-07 00:32:11 +08:00
body :
2026-06-29 19:30:35 +08:00
"终端左侧新增「模型汇总」菜单,按今日最高温口径汇总全部城市:当地时间、DEB 预测,以及 ECMWF、ECMWF AIFS、GFS、ICON、ICON-EU、GEM、GDPS、JMA、AROME HD、HRRR、NAM 等模型高温预测集中到一张表里。\n\n" +
2026-06-29 19:17:51 +08:00
"缺失模型会显示 —;模型中位数和分歧范围只基于已有模型值计算,方便快速发现 DEB 与多模型之间的偏离。\n\n" +
"页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。" ,
2026-06-07 00:32:11 +08:00
},
en : {
2026-06-29 19:17:51 +08:00
title : "Update: Model Summary table is live" ,
2026-06-07 00:32:11 +08:00
body :
2026-06-29 19:30:35 +08:00
"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" +
2026-06-29 19:17:51 +08:00
"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." ,
2026-06-07 00:32:11 +08:00
},
},
];
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 ) {
2026-06-07 00:04:08 +08:00
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" ,
});
}
2026-06-17 02:18:49 +08:00
function loadSeenAnnouncementIds() {
if ( typeof window === "undefined" ) return new Set < string >();
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 < string >();
}
}
function saveSeenAnnouncementIds ( ids : Set < string >) {
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.
}
}
2026-06-07 00:04:08 +08:00
export function UpdateAnnouncementButton ({ isEn } : UpdateAnnouncementButtonProps ) {
const [ open , setOpen ] = useState ( false );
2026-06-17 02:18:49 +08:00
const [ seenIds , setSeenIds ] = useState < Set < string >>( new Set ());
const [ seenLoaded , setSeenLoaded ] = useState ( false );
2026-06-07 00:04:08 +08:00
const shellRef = useRef < HTMLDivElement | null >( null );
2026-06-17 02:18:49 +08:00
useEffect (() => {
setSeenIds ( loadSeenAnnouncementIds ());
setSeenLoaded ( true );
}, []);
2026-06-07 00:04:08 +08:00
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 ]);
2026-06-07 00:32:11 +08:00
const announcement = useMemo (
() => STATIC_UPDATE_ANNOUNCEMENTS . find (( item ) => isActiveAnnouncement ( item )) ?? null ,
[],
);
2026-06-07 00:04:08 +08:00
const text = useMemo (
() => ( announcement ? pickAnnouncementText ( announcement , isEn ) : { title : "" , body : "" }),
[ announcement , isEn ],
);
const updatedAt = useMemo (
2026-06-07 00:32:11 +08:00
() => formatUpdatedAt ( announcement ? . publishedAt , isEn ),
[ announcement ? . publishedAt , isEn ],
2026-06-07 00:04:08 +08:00
);
2026-06-17 02:18:49 +08:00
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 ;
});
};
2026-06-07 00:04:08 +08:00
if ( ! announcement || ( ! text . title && ! text . body )) return null ;
return (
< div ref = { shellRef } className = "relative" >
< button
type = "button"
2026-06-17 02:18:49 +08:00
onClick = {() => {
setOpen (( value ) => {
const next = ! value ;
if ( next ) markAnnouncementAsSeen ( announcement . id );
return next ;
});
}}
className = "relative inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
2026-06-07 00:04:08 +08:00
title = { isEn ? "Update announcement" : "更新公告" }
2026-06-17 02:18:49 +08:00
aria-label = {
hasUnread
? isEn
? "Update announcement, unread"
: "更新公告,有未读内容"
: isEn
? "Update announcement"
: "更新公告"
}
2026-06-07 00:04:08 +08:00
aria-expanded = { open }
>
< Megaphone size = { 12 } />
{ isEn ? "Updates" : "更新公告" }
2026-06-17 02:18:49 +08:00
{ hasUnread && (
< span
className = "scan-update-announcement-unread absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-white"
aria-hidden = "true"
/>
)}
2026-06-07 00:04:08 +08:00
</ button >
{ open && (
< div className = "absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg" >
< div className = "flex items-start gap-3" >
< div className = "grid h-7 w-7 shrink-0 place-items-center rounded border border-blue-100 bg-blue-50 text-blue-600" >
< Megaphone size = { 14 } />
</ div >
< div className = "min-w-0 flex-1" >
< div className = "text-xs font-bold leading-5 text-slate-900" >
{ text . title || ( isEn ? "PolyWeather update" : "PolyWeather 更新" )}
</ div >
{ updatedAt && (
< div className = "mt-0.5 font-mono text-[10px] text-slate-400" >
{ isEn ? "Updated" : "更新" } { updatedAt }
</ div >
)}
</ div >
< button
type = "button"
onClick = {() => setOpen ( false )}
className = "grid h-6 w-6 shrink-0 place-items-center rounded border border-slate-200 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
title = { isEn ? "Close" : "关闭" }
>
< X size = { 12 } />
</ button >
</ div >
{ text . body && (
< p className = "mt-3 whitespace-pre-line text-[12px] font-medium leading-5 text-slate-600" >
{ text . body }
</ p >
)}
</ div >
)}
</ div >
);
}