= {};
+ results.forEach((result, batchIndex) => {
+ if (result.status !== "fulfilled") return;
+ const city = batch[batchIndex];
+ const previous = detailsByName[city.name];
+ if (!previous) return;
+ patch[city.name] = {
+ ...previous,
+ market_scan: result.value.market_scan || previous.market_scan,
+ };
+ });
+
+ if (Object.keys(patch).length) {
+ setDetailsByName((previous) => ({
+ ...previous,
+ ...patch,
+ }));
+ }
+ }
+
+ setLastMarketUpdatedAt(new Date().toISOString());
+ }, [cities, detailsByName]);
useEffect(() => {
void loadAll(false);
}, [loadAll]);
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === "hidden") return;
+ void retryMissingCities();
+ }, 20_000);
+
+ return () => window.clearInterval(timer);
+ }, [retryMissingCities]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === "hidden") return;
+ void loadAll(true);
+ }, FULL_REFRESH_INTERVAL_MS);
+
+ return () => window.clearInterval(timer);
+ }, [loadAll]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === "hidden") return;
+ void refreshMarketScans();
+ }, MARKET_REFRESH_INTERVAL_MS);
+
+ return () => window.clearInterval(timer);
+ }, [refreshMarketScans]);
+
const loadedCount = Object.keys(detailsByName).length;
const cityCount = cities.length;
const readyCards = useMemo(
() => cities.filter((city) => detailsByName[city.name]).length,
[cities, detailsByName],
);
+ const marketReadyCount = useMemo(
+ () => cities.filter((city) => hasMarket(detailsByName[city.name])).length,
+ [cities, detailsByName],
+ );
+ const positiveEdgeCount = useMemo(
+ () =>
+ cities.filter((city) => {
+ const edge = getPositiveEdge(detailsByName[city.name]);
+ return Number.isFinite(edge) && edge > 0;
+ }).length,
+ [cities, detailsByName],
+ );
+ const visibleCities = useMemo(() => {
+ const filtered = cities.filter((city) => {
+ const detail = detailsByName[city.name];
+ if (filterMode === "market") return hasMarket(detail);
+ if (filterMode === "high-risk") {
+ return String(detail?.risk?.level || city.risk_level || "").toLowerCase() === "high";
+ }
+ return true;
+ });
+
+ return [...filtered].sort((a, b) => {
+ const detailA = detailsByName[a.name];
+ const detailB = detailsByName[b.name];
+
+ if (sortMode === "edge") {
+ const edgeDelta = getPositiveEdge(detailB) - getPositiveEdge(detailA);
+ if (Number.isFinite(edgeDelta) && edgeDelta !== 0) return edgeDelta;
+ }
+
+ if (sortMode === "probability") {
+ const probabilityDelta = getProbabilityPeak(detailB) - getProbabilityPeak(detailA);
+ if (Number.isFinite(probabilityDelta) && probabilityDelta !== 0) return probabilityDelta;
+ }
+
+ if (sortMode === "updated") {
+ const updatedDelta =
+ new Date(detailB?.updated_at || 0).getTime() -
+ new Date(detailA?.updated_at || 0).getTime();
+ if (updatedDelta !== 0) return updatedDelta;
+ }
+
+ const riskDelta =
+ getRiskRank(detailA?.risk?.level || a.risk_level) -
+ getRiskRank(detailB?.risk?.level || b.risk_level);
+ if (riskDelta !== 0) return riskDelta;
+
+ return String(a.display_name || a.name).localeCompare(
+ String(b.display_name || b.name),
+ );
+ });
+ }, [cities, detailsByName, filterMode, sortMode]);
return (
@@ -126,6 +315,12 @@ function ProbabilityHubScreen() {
{locale === "en-US" ? "Ready" : "已加载"} {readyCards}
+
+ {locale === "en-US" ? "Market" : "有市场"} {marketReadyCount}
+
+
+ {locale === "en-US" ? "Positive edge" : "有优势"} {positiveEdgeCount}
+
{locale === "en-US" ? "Updated" : "更新时间"}{" "}
@@ -141,12 +336,94 @@ function ProbabilityHubScreen() {
: "--"}
+
+ {locale === "en-US" ? "Market tick" : "价格更新"}{" "}
+
+ {lastMarketUpdatedAt
+ ? new Date(lastMarketUpdatedAt).toLocaleTimeString(
+ locale === "en-US" ? "en-US" : "zh-CN",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ },
+ )
+ : "--"}
+
+
{error ? {error}
: null}
+
+
+
+ {locale === "en-US" ? "Filter" : "筛选"}
+
+
+
+
+
+
+
+ {locale === "en-US" ? "Sort" : "排序"}
+
+
+
+
+
+
+
+ {locale === "en-US"
+ ? `${visibleCities.length} cards in view`
+ : `当前显示 ${visibleCities.length} 张卡片`}
+
+
+
{loading && loadedCount === 0 ? (
{Array.from({ length: 6 }).map((_, index) => (
@@ -155,7 +432,7 @@ function ProbabilityHubScreen() {
) : (
- {cities.map((city) => {
+ {visibleCities.map((city) => {
const detail = detailsByName[city.name];
if (!detail) {
return (
diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts
index 977219a9..6b32ee44 100644
--- a/frontend/lib/i18n.ts
+++ b/frontend/lib/i18n.ts
@@ -6,10 +6,12 @@ const DEFAULT_LOCALE: Locale = "zh-CN";
export const LOCALE_STORAGE_KEY = "polyweather.locale";
const MESSAGES: Record> = {
- "zh-CN": {
- "header.subtitle": "天气衍生品智能分析",
- "header.docs": "文档",
- "header.docsAria": "打开产品文档中心",
+ "zh-CN": {
+ "header.subtitle": "天气衍生品智能分析",
+ "header.home": "主页",
+ "header.homeAria": "返回主地图主页",
+ "header.docs": "文档",
+ "header.docsAria": "打开产品文档中心",
"header.probabilityHub": "概率页",
"header.probabilityHubAria": "打开 52 城市概率汇总页",
"header.info": "技术说明",
@@ -171,10 +173,12 @@ const MESSAGES: Record> = {
"common.na": "--",
},
- "en-US": {
- "header.subtitle": "Weather Derivatives Intelligence",
- "header.docs": "Docs",
- "header.docsAria": "Open product documentation",
+ "en-US": {
+ "header.subtitle": "Weather Derivatives Intelligence",
+ "header.home": "Home",
+ "header.homeAria": "Return to the main map homepage",
+ "header.docs": "Docs",
+ "header.docsAria": "Open product documentation",
"header.probabilityHub": "Probabilities",
"header.probabilityHubAria": "Open the 52-city probability hub",
"header.info": "Tech Notes",