2026-04-22 02:59:12 +08:00
"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 ;
2026-04-22 03:25:40 +08:00
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" ;
2026-04-22 02:59:12 +08:00
function sortCities ( cities : CityListItem []) {
const riskOrder : Record < string , number > = {
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 ),
);
});
}
2026-04-22 03:25:40 +08:00
function getRiskRank ( level? : string | null ) {
const riskOrder : Record < string , number > = {
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 );
}
2026-04-22 04:04:51 +08:00
function isActiveMarketScan ( detail? : CityDetail | null ) {
const scan = detail ? . market_scan ;
if ( ! scan ? . available ) return false ;
const primary = scan . primary_market as
| ({
active? : boolean | null ;
closed? : boolean | null ;
accepting_orders? : boolean | null ;
tradable? : boolean | null ;
ended_at_utc? : string | null ;
} & Record < string , unknown >)
| null
| undefined ;
if ( ! primary ) return false ;
if ( primary . closed === true ) return false ;
if ( primary . active === false ) return false ;
if ( primary . accepting_orders === false ) return false ;
if ( primary . tradable === false ) return false ;
if ( primary . ended_at_utc ) {
const endedAt = new Date ( String ( primary . ended_at_utc )). getTime ();
if ( Number . isFinite ( endedAt ) && endedAt <= Date . now ()) {
return false ;
}
}
return true ;
}
2026-04-22 03:25:40 +08:00
function hasMarket ( detail? : CityDetail | null ) {
2026-04-22 04:04:51 +08:00
const scan = detail ? . market_scan ;
2026-04-22 03:25:40 +08:00
return Boolean (
2026-04-22 04:04:51 +08:00
isActiveMarketScan ( detail ) &&
scan &&
(( Array . isArray ( scan . all_buckets ) && scan . all_buckets . length > 0 ) ||
( Array . isArray ( scan . top_buckets ) && scan . top_buckets . length > 0 )),
2026-04-22 03:25:40 +08:00
);
}
2026-04-22 02:59:12 +08:00
function ProbabilityHubScreen() {
const { locale } = useI18n ();
const [ cities , setCities ] = useState < CityListItem [] >([]);
const [ detailsByName , setDetailsByName ] = useState < Record < string , CityDetail >>({});
const [ loading , setLoading ] = useState ( true );
const [ refreshing , setRefreshing ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const [ lastUpdatedAt , setLastUpdatedAt ] = useState < string | null >( null );
2026-04-22 03:25:40 +08:00
const [ lastMarketUpdatedAt , setLastMarketUpdatedAt ] = useState < string | null >( null );
const [ filterMode , setFilterMode ] = useState < FilterMode >( "all" );
const [ sortMode , setSortMode ] = useState < SortMode >( "risk" );
2026-04-22 02:59:12 +08:00
2026-04-22 03:25:40 +08:00
const fetchCityDetails = useCallback (
async ( cityList : CityListItem [], force : boolean ) => {
const fetched : Record < string , CityDetail > = {};
2026-04-22 02:59:12 +08:00
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 ,
}),
),
);
2026-04-22 03:25:40 +08:00
const patch : Record < string , CityDetail > = {};
2026-04-22 02:59:12 +08:00
results . forEach (( result , batchIndex ) => {
if ( result . status !== "fulfilled" ) return ;
2026-04-22 03:25:40 +08:00
const detail = result . value ;
fetched [ batch [ batchIndex ]. name ] = detail ;
patch [ batch [ batchIndex ]. name ] = detail ;
2026-04-22 02:59:12 +08:00
});
2026-04-22 03:25:40 +08:00
if ( Object . keys ( patch ). length ) {
setDetailsByName (( previous ) => ({
... previous ,
... patch ,
}));
}
2026-04-22 02:59:12 +08:00
}
2026-04-22 03:25:40 +08:00
return fetched ;
},
[],
);
2026-04-22 03:52:50 +08:00
const refreshMarketScans = useCallback ( async (
sourceDetails? : Record < string , CityDetail >,
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 , {
2026-04-22 04:04:51 +08:00
force : false ,
2026-04-22 03:52:50 +08:00
targetDate : detailMap [ city . name ] ? . local_date || null ,
marketSlug : detailMap [ city . name ] ? . market_scan ? . selected_slug || null ,
}),
),
);
const patch : Record < string , CityDetail > = {};
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 ]);
2026-04-22 03:25:40 +08:00
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 );
2026-04-22 03:52:50 +08:00
const fetched = await fetchCityDetails ( cityList , force );
2026-04-22 02:59:12 +08:00
setLastUpdatedAt ( new Date (). toISOString ());
2026-04-22 03:52:50 +08:00
await refreshMarketScans ( fetched , cityList );
2026-04-22 02:59:12 +08:00
} catch ( loadError ) {
2026-04-22 04:04:51 +08:00
if ( ! Object . keys ( detailsByName ). length && ! cities . length ) {
setError (
loadError instanceof Error
? loadError.message
: locale === "en-US"
? "Failed to load probability hub"
: "加载概率页失败" ,
);
} else {
console . warn ( "probability hub refresh failed" , loadError );
}
2026-04-22 02:59:12 +08:00
} finally {
setLoading ( false );
setRefreshing ( false );
}
2026-04-22 04:04:51 +08:00
}, [ cities . length , detailsByName , fetchCityDetails , locale , refreshMarketScans ]);
2026-04-22 03:25:40 +08:00
const retryMissingCities = useCallback ( async () => {
if ( ! cities . length ) return ;
const missingCities = cities . filter (( city ) => ! detailsByName [ city . name ]);
if ( ! missingCities . length ) return ;
try {
2026-04-22 03:52:50 +08:00
const fetched = await fetchCityDetails ( missingCities , true );
2026-04-22 03:25:40 +08:00
setLastUpdatedAt ( new Date (). toISOString ());
2026-04-22 03:52:50 +08:00
await refreshMarketScans ( fetched , missingCities );
2026-04-22 03:25:40 +08:00
} catch {
// keep silent; page-level retry should not override the main error banner
}
2026-04-22 03:52:50 +08:00
}, [ cities , detailsByName , fetchCityDetails , refreshMarketScans ]);
2026-04-22 02:59:12 +08:00
useEffect (() => {
void loadAll ( false );
}, [ loadAll ]);
2026-04-22 03:25:40 +08:00
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 ;
2026-04-22 04:04:51 +08:00
void loadAll ( false );
2026-04-22 03:25:40 +08:00
}, 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 ]);
2026-04-22 02:59:12 +08:00
const loadedCount = Object . keys ( detailsByName ). length ;
const cityCount = cities . length ;
const readyCards = useMemo (
() => cities . filter (( city ) => detailsByName [ city . name ]). length ,
[ cities , detailsByName ],
);
2026-04-22 03:25:40 +08:00
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 ]);
2026-04-22 02:59:12 +08:00
return (
< div className = { clsx ( dashboardStyles . root , styles . pageRoot )}>
< HeaderBar
refreshAction = {() => loadAll ( true )}
refreshSpinning = { refreshing }
/>
< main className = { styles . pageBody }>
< section className = { styles . hero }>
< div className = { styles . heroCard }>
< div className = { styles . heroTitle }>
{ locale === "en-US"
? "52-city probability hub"
: "52 城市概率判断总览" }
</ div >
< div className = { styles . heroText }>
{ 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 校准概率、市场合约桶聚合,以及价格对比。" }
</ div >
< div className = { styles . heroMeta }>
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Cities" : "城市数" } < strong >{ cityCount || "--" }</ strong >
</ span >
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Ready" : "已加载" } < strong >{ readyCards }</ strong >
</ span >
2026-04-22 03:25:40 +08:00
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Market" : "有市场" } < strong >{ marketReadyCount }</ strong >
</ span >
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Positive edge" : "有优势" } < strong >{ positiveEdgeCount }</ strong >
</ span >
2026-04-22 02:59:12 +08:00
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Updated" : "更新时间" }{ " " }
< strong >
{ lastUpdatedAt
? new Date ( lastUpdatedAt ). toLocaleTimeString (
locale === "en-US" ? "en-US" : "zh-CN" ,
{
hour : "2-digit" ,
minute : "2-digit" ,
second : "2-digit" ,
},
)
: "--" }
</ strong >
</ span >
2026-04-22 03:25:40 +08:00
< span className = { styles . heroPill }>
{ locale === "en-US" ? "Market tick" : "价格更新" }{ " " }
< strong >
{ lastMarketUpdatedAt
? new Date ( lastMarketUpdatedAt ). toLocaleTimeString (
locale === "en-US" ? "en-US" : "zh-CN" ,
{
hour : "2-digit" ,
minute : "2-digit" ,
second : "2-digit" ,
},
)
: "--" }
</ strong >
</ span >
2026-04-22 02:59:12 +08:00
</ div >
</ div >
{ error ? < div className = { styles . errorCard }>{ error }</ div > : null }
</ section >
2026-04-22 03:25:40 +08:00
< section className = { styles . toolbar }>
< div className = { styles . toolbarGroup }>
< span className = { styles . toolbarLabel }>
{ locale === "en-US" ? "Filter" : "筛选" }
</ span >
< button
type = "button"
className = { clsx ( styles . toolbarButton , filterMode === "all" && styles . active )}
onClick = {() => setFilterMode ( "all" )}
>
{ locale === "en-US" ? "All" : "全部" }
</ button >
< button
type = "button"
className = { clsx ( styles . toolbarButton , filterMode === "market" && styles . active )}
onClick = {() => setFilterMode ( "market" )}
>
{ locale === "en-US" ? "With market" : "仅看有市场" }
</ button >
< button
type = "button"
className = { clsx ( styles . toolbarButton , filterMode === "high-risk" && styles . active )}
onClick = {() => setFilterMode ( "high-risk" )}
>
{ locale === "en-US" ? "High risk" : "仅看高风险" }
</ button >
</ div >
< div className = { styles . toolbarGroup }>
< span className = { styles . toolbarLabel }>
{ locale === "en-US" ? "Sort" : "排序" }
</ span >
< button
type = "button"
className = { clsx ( styles . toolbarButton , sortMode === "risk" && styles . active )}
onClick = {() => setSortMode ( "risk" )}
>
{ locale === "en-US" ? "Risk" : "风险" }
</ button >
< button
type = "button"
className = { clsx ( styles . toolbarButton , sortMode === "edge" && styles . active )}
onClick = {() => setSortMode ( "edge" )}
>
{ locale === "en-US" ? "Edge" : "优势" }
</ button >
< button
type = "button"
className = { clsx ( styles . toolbarButton , sortMode === "probability" && styles . active )}
onClick = {() => setSortMode ( "probability" )}
>
{ locale === "en-US" ? "Probability" : "概率" }
</ button >
< button
type = "button"
className = { clsx ( styles . toolbarButton , sortMode === "updated" && styles . active )}
onClick = {() => setSortMode ( "updated" )}
>
{ locale === "en-US" ? "Updated" : "更新时间" }
</ button >
</ div >
< div className = { styles . toolbarSummary }>
{ locale === "en-US"
? ` ${ visibleCities . length } cards in view`
: `当前显示 ${ visibleCities . length } 张卡片` }
</ div >
</ section >
2026-04-22 02:59:12 +08:00
{ loading && loadedCount === 0 ? (
< div className = { styles . loadingGrid }>
{ Array . from ({ length : 6 }). map (( _ , index ) => (
< div key = { index } className = { styles . loadingCard } />
))}
</ div >
) : (
< div className = { styles . grid }>
2026-04-22 03:25:40 +08:00
{ visibleCities . map (( city ) => {
2026-04-22 02:59:12 +08:00
const detail = detailsByName [ city . name ];
if ( ! detail ) {
return (
< section key = { city . name } className = { styles . card }>
< div className = { styles . cardHead }>
< div className = { styles . cardTitleBlock }>
< div className = { styles . cardTitle }>{ city . display_name }</ div >
< div className = { styles . cardSubTitle }>
{ city . airport } ({ city . icao })
</ div >
</ div >
</ div >
< div className = { styles . cardSubTitle }>
{ locale === "en-US"
? "Probability block is syncing..."
: "概率板块同步中..." }
</ div >
</ section >
);
}
return (
< section key = { city . name } className = { styles . card }>
< div className = { styles . cardHead }>
< div className = { styles . cardTitleBlock }>
< div className = { styles . cardTitle }>{ detail . display_name }</ div >
< div className = { styles . cardSubTitle }>
{ detail . risk ? . airport || city . airport } ({ detail . risk ? . icao || city . icao })
</ div >
</ div >
</ div >
< div className = { styles . cardMeta }>
< span className = { styles . metaChip }>
{ locale === "en-US" ? "Current" : "当前" }{ " " }
< strong >
{ detail . current ? . temp != null
? ` ${ detail . current . temp }${ detail . temp_symbol } `
: "--" }
</ strong >
</ span >
< span className = { styles . metaChip }>
{ locale === "en-US" ? "Obs" : "观测" }{ " " }
< strong >{ detail . current ? . obs_time || "--" }</ strong >
</ span >
< span className = { styles . metaChip }>
{ locale === "en-US" ? "Updated" : "更新" }{ " " }
< strong >
{ detail . updated_at
? new Date ( detail . updated_at ). toLocaleTimeString (
locale === "en-US" ? "en-US" : "zh-CN" ,
{
hour : "2-digit" ,
minute : "2-digit" ,
},
)
: "--" }
</ strong >
</ span >
</ div >
2026-04-22 04:04:51 +08:00
{(() => {
const activeMarketScan = isActiveMarketScan ( detail )
? detail.market_scan
: null ;
return (
< ProbabilityDistribution
detail = { detail }
hideTitle
marketScan = { activeMarketScan }
/>
);
})()}
2026-04-22 02:59:12 +08:00
</ section >
);
})}
</ div >
)}
</ main >
</ div >
);
}
export function ProbabilityHubPage() {
return (
< I18nProvider >
< DashboardStoreProvider >
< ProbabilityHubScreen />
</ DashboardStoreProvider >
</ I18nProvider >
);
}