"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import dashboardStyles from "@/components/dashboard/Dashboard.module.css"; import { HeaderBar } from "@/components/dashboard/HeaderBar"; import { ProbabilityDistribution } from "@/components/dashboard/PanelSections"; import { dashboardClient } from "@/lib/dashboard-client"; import type { CityDetail, CityListItem } from "@/lib/dashboard-types"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { DashboardStoreProvider } from "@/hooks/useDashboardStore"; import styles from "./ProbabilityHubPage.module.css"; const DETAIL_BATCH_SIZE = 6; const FULL_REFRESH_INTERVAL_MS = 60_000; const MARKET_REFRESH_INTERVAL_MS = 5_000; type FilterMode = "all" | "market" | "high-risk"; type SortMode = "risk" | "edge" | "probability" | "updated"; function sortCities(cities: CityListItem[]) { const riskOrder: Record = { high: 0, medium: 1, low: 2, }; return [...cities].sort((a, b) => { const riskDelta = (riskOrder[String(a.risk_level || "").toLowerCase()] ?? 9) - (riskOrder[String(b.risk_level || "").toLowerCase()] ?? 9); if (riskDelta !== 0) return riskDelta; return String(a.display_name || a.name).localeCompare( String(b.display_name || b.name), ); }); } function getRiskRank(level?: string | null) { const riskOrder: Record = { high: 0, medium: 1, low: 2, }; return riskOrder[String(level || "").toLowerCase()] ?? 9; } function getProbabilityPeak(detail?: CityDetail | null) { const buckets = Array.isArray(detail?.probabilities?.distribution_all) ? detail.probabilities?.distribution_all : Array.isArray(detail?.probabilities?.distribution) ? detail.probabilities?.distribution : []; return buckets.reduce((best, bucket) => { const next = Number(bucket?.probability ?? -1); return next > best ? next : best; }, -1); } function getPositiveEdge(detail?: CityDetail | null) { const analysis = detail?.market_scan?.price_analysis; const yesEdge = Number(analysis?.yes?.edge ?? Number.NEGATIVE_INFINITY); const noEdge = Number(analysis?.no?.edge ?? Number.NEGATIVE_INFINITY); return Math.max(yesEdge, noEdge, Number.NEGATIVE_INFINITY); } function hasMarket(detail?: CityDetail | null) { return Boolean( detail?.market_scan?.available && ((Array.isArray(detail.market_scan.all_buckets) && detail.market_scan.all_buckets.length > 0) || (Array.isArray(detail.market_scan.top_buckets) && detail.market_scan.top_buckets.length > 0)), ); } function ProbabilityHubScreen() { const { locale } = useI18n(); const [cities, setCities] = useState([]); const [detailsByName, setDetailsByName] = useState>({}); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const [lastMarketUpdatedAt, setLastMarketUpdatedAt] = useState(null); const [filterMode, setFilterMode] = useState("all"); const [sortMode, setSortMode] = useState("risk"); const fetchCityDetails = useCallback( async (cityList: CityListItem[], force: boolean) => { const fetched: Record = {}; for (let index = 0; index < cityList.length; index += DETAIL_BATCH_SIZE) { const batch = cityList.slice(index, index + DETAIL_BATCH_SIZE); const results = await Promise.allSettled( batch.map((city) => dashboardClient.getCityDetail(city.name, { depth: "market", force, }), ), ); const patch: Record = {}; results.forEach((result, batchIndex) => { if (result.status !== "fulfilled") return; const detail = result.value; fetched[batch[batchIndex].name] = detail; patch[batch[batchIndex].name] = detail; }); if (Object.keys(patch).length) { setDetailsByName((previous) => ({ ...previous, ...patch, })); } } return fetched; }, [], ); const refreshMarketScans = useCallback(async ( sourceDetails?: Record, sourceCities?: CityListItem[], ) => { const detailMap = sourceDetails || detailsByName; const cityList = sourceCities || cities; const loadedCities = cityList.filter((city) => detailMap[city.name]); if (!loadedCities.length) return; let touched = false; for (let index = 0; index < loadedCities.length; index += DETAIL_BATCH_SIZE) { const batch = loadedCities.slice(index, index + DETAIL_BATCH_SIZE); const results = await Promise.allSettled( batch.map((city) => dashboardClient.getCityMarketScan(city.name, { force: true, targetDate: detailMap[city.name]?.local_date || null, marketSlug: detailMap[city.name]?.market_scan?.selected_slug || null, }), ), ); const patch: Record = {}; results.forEach((result, batchIndex) => { if (result.status !== "fulfilled") return; const city = batch[batchIndex]; const previous = detailMap[city.name] || detailsByName[city.name]; if (!previous) return; const nextMarketScan = result.value.market_scan || previous.market_scan; if (!nextMarketScan) return; patch[city.name] = { ...previous, market_scan: nextMarketScan, }; }); if (Object.keys(patch).length) { touched = true; Object.assign(detailMap, patch); setDetailsByName((previous) => ({ ...previous, ...patch, })); } } if (touched) { setLastMarketUpdatedAt(new Date().toISOString()); } }, [cities, detailsByName]); const loadAll = useCallback(async (force = false) => { setError(null); setRefreshing(true); if (!cities.length || force) { setLoading(true); } try { const cityList = sortCities(await dashboardClient.getCities()); setCities(cityList); const fetched = await fetchCityDetails(cityList, force); setLastUpdatedAt(new Date().toISOString()); await refreshMarketScans(fetched, cityList); } catch (loadError) { setError( loadError instanceof Error ? loadError.message : locale === "en-US" ? "Failed to load probability hub" : "加载概率页失败", ); } finally { setLoading(false); setRefreshing(false); } }, [cities.length, fetchCityDetails, locale, refreshMarketScans]); const retryMissingCities = useCallback(async () => { if (!cities.length) return; const missingCities = cities.filter((city) => !detailsByName[city.name]); if (!missingCities.length) return; try { const fetched = await fetchCityDetails(missingCities, true); setLastUpdatedAt(new Date().toISOString()); await refreshMarketScans(fetched, missingCities); } catch { // keep silent; page-level retry should not override the main error banner } }, [cities, detailsByName, fetchCityDetails, refreshMarketScans]); 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 (
loadAll(true)} refreshSpinning={refreshing} />
{locale === "en-US" ? "52-city probability hub" : "52 城市概率判断总览"}
{locale === "en-US" ? "This page centralizes the intraday probability block for all monitored cities. The goal is fast scanning: see calibrated EMOS probabilities, market bucket alignment, and price comparison without opening each city modal one by one." : "这里把 52 个监控城市的概率判断板块集中到一个页面,方便直接横向扫一遍,不用逐个打开城市弹窗。重点看 EMOS 校准概率、市场合约桶聚合,以及价格对比。"}
{locale === "en-US" ? "Cities" : "城市数"} {cityCount || "--"} {locale === "en-US" ? "Ready" : "已加载"} {readyCards} {locale === "en-US" ? "Market" : "有市场"} {marketReadyCount} {locale === "en-US" ? "Positive edge" : "有优势"} {positiveEdgeCount} {locale === "en-US" ? "Updated" : "更新时间"}{" "} {lastUpdatedAt ? new Date(lastUpdatedAt).toLocaleTimeString( locale === "en-US" ? "en-US" : "zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", }, ) : "--"} {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) => (
))}
) : (
{visibleCities.map((city) => { const detail = detailsByName[city.name]; if (!detail) { return (
{city.display_name}
{city.airport} ({city.icao})
{locale === "en-US" ? "Probability block is syncing..." : "概率板块同步中..."}
); } return (
{detail.display_name}
{detail.risk?.airport || city.airport} ({detail.risk?.icao || city.icao})
{locale === "en-US" ? "Current" : "当前"}{" "} {detail.current?.temp != null ? `${detail.current.temp}${detail.temp_symbol}` : "--"} {locale === "en-US" ? "Obs" : "观测"}{" "} {detail.current?.obs_time || "--"} {locale === "en-US" ? "Updated" : "更新"}{" "} {detail.updated_at ? new Date(detail.updated_at).toLocaleTimeString( locale === "en-US" ? "en-US" : "zh-CN", { hour: "2-digit", minute: "2-digit", }, ) : "--"}
); })}
)}
); } export function ProbabilityHubPage() { return ( ); }