import { Box } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DataTableView, EmptyState, Tabs, usePaneFooter, usePaneHeaderTabs, type DataTableKeyEvent } from "../../../components"; import type { PaneProps } from "../../../types/plugin"; import type { PluginModule } from "../plugin-module"; import { TICKER_RESEARCH_PANE_ID } from "../../../types/config"; import { priceColor } from "../../../theme/colors"; import { publicTickerKey } from "../../../utils/exchanges"; import { formatPercentRaw } from "../../../utils/format"; import { usePaneSettingValue } from "../../../state/app/context"; import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime"; import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming"; import { useAutoRefresh } from "../shared/auto-refresh"; import { useQuoteBoard } from "../shared/use-quote-board"; import { attachMarketMoversPersistence, MARKET_SUMMARY_SYMBOLS, resetMarketMoversPersistence, type ScreenerQuote, type MarketSummaryQuote, } from "./screener"; import { DEFAULT_SORT_PREFERENCE, INDEX_SHORT, TABS, createRows, overlayMarketMoverQuotes, nextSortPreference, resolveSummarySymbols, resolveTabs, sortRows, summaryQuoteFromQuote, type MarketMoverColumn, type MarketMoverRow, type MarketMoverSortPreference, type TabId, } from "./model"; import { loadMarketMoverTab } from "./client"; import { marketMoversHeadless } from "./headless"; import { buildMarketMoverColumns, renderMarketMoverCell } from "./table"; import { LIVE_STREAMING_QUICK_SETTING, useLiveStreamingSetting, withLiveStreamingSetting, } from "../shared/live-streaming"; import { buildScreenerQuoteTargets, resolveScreenerQuoteFeedStatus, } from "../shared/screener-live-quotes"; export { marketMoversHeadless } from "./headless"; /** Stable identity: a fresh literal here would reload the board every render. */ const NO_SAVED_SELECTION: string[] = []; const NO_QUOTES: ScreenerQuote[] = []; const moverKey = (row: Pick) => publicTickerKey(row.symbol, row.exchange); function MarketMoversPane({ focused, width, height }: PaneProps) { const dataProvider = useAssetData(); const { pinTicker } = usePluginTickerActions(); const liveStreaming = useLiveStreamingSetting(); const [savedTabs] = usePaneSettingValue("tabs", NO_SAVED_SELECTION); const [savedSummarySymbols] = usePaneSettingValue("summarySymbols", NO_SAVED_SELECTION); const tabs = useMemo(() => resolveTabs(savedTabs), [savedTabs]); const summarySymbols = useMemo(() => resolveSummarySymbols(savedSummarySymbols), [savedSummarySymbols]); // Pane state rather than local state, so `--list` on the CLI and a restored // layout open on the same tab the user (or the screenshot) asked for. const [activeTab, setActiveTab] = usePluginPaneState("activeTab", tabs[0]!.id); const [quotes, setQuotes] = useState([]); const [loadedTab, setLoadedTab] = useState(null); const visibleQuotes = loadedTab === activeTab ? quotes : NO_QUOTES; // The first load starts before the effect runs; an empty board is not "no data". const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [selectedSymbol, setSelectedSymbol] = useState(null); const [sortPreference, setSortPreference] = useState(DEFAULT_SORT_PREFERENCE); const [moversStale, setMoversStale] = useState(false); const [lastLoadedAt, setLastLoadedAt] = useState(null); const fetchGenRef = useRef(0); const quoteTargets = useMemo( () => buildScreenerQuoteTargets(visibleQuotes, selectedSymbol), [visibleQuotes, selectedSymbol], ); const { entries: liveQuoteEntries, freshnessNow, subscriptionStartedAt, } = useLiveQuoteEntries(quoteTargets, { freshnessScopeKey: `market-movers:${activeTab}`, liveStreaming, }); const resolvedQuotes = useMemo( () => overlayMarketMoverQuotes(visibleQuotes, liveQuoteEntries), [liveQuoteEntries, visibleQuotes], ); const feedStatus = useMemo( () => resolveScreenerQuoteFeedStatus(quoteTargets, liveQuoteEntries, { now: freshnessNow, subscriptionStartedAt, }), [freshnessNow, liveQuoteEntries, quoteTargets, subscriptionStartedAt], ); const columns = useMemo(() => buildMarketMoverColumns(width), [width]); const rankedRows = useMemo(() => createRows(resolvedQuotes), [resolvedQuotes]); const rows = useMemo(() => sortRows(rankedRows, sortPreference), [rankedRows, sortPreference]); const selectedIdx = selectedSymbol ? rows.findIndex((row) => moverKey(row) === selectedSymbol) : -1; useEffect(() => { if (selectedSymbol && selectedIdx >= 0) return; const firstRow = rows[0]; if (firstRow) { setSelectedSymbol(moverKey(firstRow)); } else if (selectedSymbol !== null) { setSelectedSymbol(null); } }, [rows, selectedIdx, selectedSymbol]); // The index summary is a quote board like any other, so it runs on the shared // one instead of a third parallel pipeline against the same upstream. const { quotes: summaryBoard } = useQuoteBoard(summarySymbols, { liveStreaming }); const summaryQuotes = useMemo(() => ( summarySymbols .map((symbol) => { const quote = summaryBoard.get(symbol)?.quote; return quote ? summaryQuoteFromQuote(symbol, quote) : null; }) .filter((quote): quote is MarketSummaryQuote => !!quote) ), [summaryBoard, summarySymbols]); const loadTab = useCallback(async ( tab: TabId, options?: { forceRefresh?: boolean; background?: boolean }, ) => { fetchGenRef.current += 1; const gen = fetchGenRef.current; if (!options?.background) { setLoading(true); setLoadError(null); } try { const result = await loadMarketMoverTab(tab, dataProvider, { forceRefresh: options?.forceRefresh, }); if (fetchGenRef.current !== gen) return; setQuotes(result.quotes); setLoadedTab(tab); setMoversStale(result.stale); if (!options?.background) setSelectedSymbol(null); setLoadError(null); setLastLoadedAt(Date.now()); } catch { if (fetchGenRef.current !== gen) return; setMoversStale(true); setLoadError("Market movers temporarily unavailable"); } finally { if (fetchGenRef.current === gen && !options?.background) setLoading(false); } }, [dataProvider]); useEffect(() => { if (tabs.some((tab) => tab.id === activeTab)) return; setActiveTab(tabs[0]!.id); }, [activeTab, tabs]); useEffect(() => { void loadTab(activeTab); }, [activeTab, loadTab]); const backgroundRefresh = useCallback(() => { void loadTab(activeTab, { background: true }); }, [activeTab, loadTab]); useAutoRefresh(lastLoadedAt, backgroundRefresh); const openSymbol = useCallback((row: MarketMoverRow) => { pinTicker(moverKey(row), { floating: true, paneType: TICKER_RESEARCH_PANE_ID, instrument: null }); }, [pinTicker]); const handleHeaderClick = useCallback((columnId: string) => { setSortPreference((current) => nextSortPreference(current, columnId)); }, []); const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => { if (isPlainKey(event, "r")) { event.preventDefault?.(); event.stopPropagation?.(); loadTab(activeTab, { forceRefresh: true }); return true; } return false; }, [activeTab, loadTab]); usePaneFooter("market-movers", () => ({ info: [ ...(loadError ? [{ id: "load-error", parts: [{ text: loadError, tone: "warning" as const }] }] : []), ...summaryQuotes.map((idx) => { const short = INDEX_SHORT[idx.symbol] ?? idx.symbol; return { id: `summary:${idx.symbol}`, parts: [ { text: short, tone: "label" as const }, { text: formatPercentRaw(idx.changePercent), tone: "value" as const, color: priceColor(idx.changePercent), bold: true }, ], }; }), ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []), ...(feedStatus ? [{ id: "feed", parts: [{ text: feedStatus, tone: feedStatus === "live" ? "value" as const : "muted" as const }], }] : []), ...(moversStale && loadedTab === activeTab ? [{ id: "stale", parts: [{ text: "stale", tone: "muted" as const }], }] : []), ], }), [activeTab, feedStatus, loadedTab, loadError, loading, moversStale, summaryQuotes]); const tabItems = tabs.map((tab) => ({ label: tab.label, value: tab.id })); const selectTab = (value: string) => { setActiveTab(value as TabId); setSelectedSymbol(null); }; const tabsInHeader = usePaneHeaderTabs({ tabs: tabItems, activeValue: activeTab, onSelect: selectTab, focused }); return ( {!tabsInHeader && ( )} focused={focused} selection={{ kind: "id", selectedId: selectedSymbol, getId: moverKey, onChange: (symbol) => setSelectedSymbol(symbol), }} onRootKeyDown={handleTableKeyDown} resetScrollKey={activeTab} sortable columns={columns} items={rows} sortColumnId={sortPreference.columnId} sortDirection={sortPreference.direction} onHeaderClick={handleHeaderClick} getItemKey={moverKey} onActivate={openSymbol} renderCell={renderMarketMoverCell} emptyStateTitle={loading ? "Loading movers..." : loadError ?? "No movers returned."} emptyContent={loadError ? ( ) : undefined} /> ); } export const marketMoversModule: PluginModule = { setup(ctx) { attachMarketMoversPersistence(ctx.persistence); }, dispose() { resetMarketMoversPersistence(); }, panes: [ { id: "market-movers", name: "Market Movers", icon: "T", component: MarketMoversPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 36 }, tableExport: true, quickSettings: [LIVE_STREAMING_QUICK_SETTING], settings: (context) => withLiveStreamingSetting({ title: "Market Movers Settings", values: { tabs: resolveTabs(context.settings.tabs as string[] | undefined).map((tab) => tab.id), summarySymbols: resolveSummarySymbols(context.settings.summarySymbols as string[] | undefined), }, fields: [ { key: "tabs", label: "Lists", type: "ordered-multi-select", options: TABS.map((tab) => ({ value: tab.id, label: tab.label })), }, { key: "summarySymbols", label: "Index summary", type: "ordered-multi-select", options: MARKET_SUMMARY_SYMBOLS.map((symbol) => ({ value: symbol, label: INDEX_SHORT[symbol] ?? symbol, })), }, ], }, context.settings), }, ], paneTemplates: [ { id: "market-movers-pane", paneId: "market-movers", label: "Market Movers", description: "Top gainers, losers, most active, and trending tickers.", keywords: ["movers", "gainers", "losers", "active", "trending", "screener", "top"], shortcut: { prefix: "MOST" }, headless: marketMoversHeadless, }, ], };