diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 9464ffca..85701622 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -10,6 +10,7 @@ import { BookOpen, Sparkles, BarChart3, + House, } from "lucide-react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; @@ -41,6 +42,8 @@ export function HeaderBar({ const isAuthenticated = store.proAccess.authenticated; const docsHref = "/docs/intro"; const docsActive = pathname?.startsWith("/docs"); + const homeHref = "/"; + const homeActive = pathname === "/"; const probabilityHubHref = "/probabilities"; const probabilityHubActive = pathname?.startsWith("/probabilities"); const trialPromoLabel = @@ -131,6 +134,16 @@ export function HeaderBar({ + + + {t("header.home")} + + = { @@ -31,6 +36,44 @@ function sortCities(cities: CityListItem[]) { }); } +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([]); @@ -39,19 +82,14 @@ function ProbabilityHubScreen() { 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 loadAll = useCallback(async (force = false) => { - setError(null); - setRefreshing(true); - if (!cities.length || force) { - setLoading(true); - } + const fetchCityDetails = useCallback( + async (cityList: CityListItem[], force: boolean) => { + const fetched: Record = {}; - try { - const cityList = sortCities(await dashboardClient.getCities()); - setCities(cityList); - - const nextDetails: 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( @@ -63,17 +101,37 @@ function ProbabilityHubScreen() { ), ); + const patch: Record = {}; results.forEach((result, batchIndex) => { if (result.status !== "fulfilled") return; - nextDetails[batch[batchIndex].name] = result.value; + const detail = result.value; + fetched[batch[batchIndex].name] = detail; + patch[batch[batchIndex].name] = detail; }); - setDetailsByName((previous) => ({ - ...previous, - ...nextDetails, - })); + if (Object.keys(patch).length) { + setDetailsByName((previous) => ({ + ...previous, + ...patch, + })); + } } + return fetched; + }, + [], + ); + + 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); + await fetchCityDetails(cityList, force); setLastUpdatedAt(new Date().toISOString()); } catch (loadError) { setError( @@ -87,18 +145,149 @@ function ProbabilityHubScreen() { setLoading(false); setRefreshing(false); } - }, [cities.length, locale]); + }, [cities.length, fetchCityDetails, locale]); + + const retryMissingCities = useCallback(async () => { + if (!cities.length) return; + const missingCities = cities.filter((city) => !detailsByName[city.name]); + if (!missingCities.length) return; + + try { + await fetchCityDetails(missingCities, true); + setLastUpdatedAt(new Date().toISOString()); + } catch { + // keep silent; page-level retry should not override the main error banner + } + }, [cities, detailsByName, fetchCityDetails]); + + const refreshMarketScans = useCallback(async () => { + const loadedCities = cities.filter((city) => detailsByName[city.name]); + if (!loadedCities.length) return; + + 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: detailsByName[city.name]?.local_date || null, + }), + ), + ); + + const patch: Record = {}; + 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",