import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, useRendererHost, type ScrollBoxRenderable } from "../../../ui"; import { DataTableStackView, PaneStatusBody, Tabs, usePaneNoticeFooter, usePaneFooter, usePaneHeaderTabs, usePaneMenuItems, ChoiceDialog, useTableLoadMore, } from "../../../components"; const CONGRESS_TABS = [ { label: "Trades", value: "trades" }, { label: "Members", value: "members" }, { label: "Tickers", value: "tickers" }, ]; import { useDebouncedPluginPaneState, usePluginPaneState } from "../../runtime"; import { useShortcut } from "../../../react/input"; import { isDetailBackNavigationKey } from "../../../utils/back-navigation"; import { useAutoRefresh } from "../shared/auto-refresh"; import { useInlineTickerOpener } from "../../../state/hooks/inline-tickers"; import { type CloudCongressHousePayload, type CloudCongressMemberPayload, type CloudCongressTradePayload, type CloudCongressTickerPayload, } from "../../../api-client"; import type { PaneProps } from "../../../types/plugin"; import { usePaneSettingValue } from "../../../state/app/context"; import { CONGRESS_FILING_LIMIT, CONGRESS_MEMBER_FILING_LIMIT, CONGRESS_TRADE_LIMIT, CONGRESS_TRADES_PANE_ID, canLoadMoreCongress, mergeCongressPages, nextCongressPage, buildMemberColumns, buildTradeColumns, congressScanNotice, nextSort, previousCongressYearPage, selectedIndexById, sortedMembers, sortedTrades, sortedTickers, buildTickerColumns, type TickerColumn, type TickerColumnId, type CongressTab, type DetailMode, type LoadStatus, type MemberColumn, type MemberColumnId, type SortDirection, type TradeColumn, type TradeColumnId, } from "./model"; import { MemberTradesDetail, TradeDetail } from "./detail"; import { useCongressTradesFooter } from "./footer"; import { CONGRESS_TAB_KEYS, useCongressTradesKeyboard } from "./keyboard"; import { renderCongressMemberCell, renderCongressTradeCell, renderCongressTickerCell, } from "./table"; import { loadCongressHouse } from "./client"; import { useMineTickers } from "../shared/mine-tickers"; import { aggregateLoadedCongress } from "./aggregates"; import { CongressFilterBar, type CongressFilters } from "./filters"; import type { SelectControl } from "../../../components/ui/select-button"; import { colors } from "../../../theme/colors"; import { useDialog, type PromptContext } from "../../../ui/dialog"; import { isPlainKey } from "../../../utils/keyboard"; import type { DataTableKeyEvent } from "../../../components"; export { CONGRESS_TRADES_PANE_ID } from "./model"; /** The Congress pane; `CG WMB` keeps its ticker in pane settings. */ export function CongressPane(props: PaneProps) { const [ticker] = usePaneSettingValue("ticker", ""); const symbol = typeof ticker === "string" ? ticker.trim().toUpperCase() : ""; return ; } export function CongressTradesPane({ focused, width, height, tickerFilter }: PaneProps & { tickerFilter?: string }) { const rendererHost = useRendererHost(); const dialog = useDialog(); const statePrefix = tickerFilter ? "congressTicker." : ""; const mineTickers = useMineTickers(); const [mine, setMine] = usePluginPaneState(`${statePrefix}mine`, false); const [filters, setFilters] = usePluginPaneState(`${statePrefix}filters`, {}); const chamberControl = useRef(null); const sideControl = useRef(null); const ownerControl = useRef(null); const assetControl = useRef(null); const amountControl = useRef(null); const filterKey = JSON.stringify(filters); const [payload, setPayload] = useState(null); const [status, setStatus] = useState("loading"); const [error, setError] = useState(null); const [lastLoadedAt, setLastLoadedAt] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const tradeScrollRef = useRef(null); const [savedTab, setActiveTab] = usePluginPaneState(`${statePrefix}activeTab`, "trades"); const activeTab = tickerFilter ? "trades" : savedTab; const [selectedTradeId, setSelectedTradeId] = useDebouncedPluginPaneState(`${statePrefix}selectedTradeId`, null); const [selectedMemberId, setSelectedMemberId] = useDebouncedPluginPaneState(`${statePrefix}selectedMemberId`, null); const [detailMode, setDetailMode] = usePluginPaneState(`${statePrefix}detailMode`, null); const [selectedTicker, setSelectedTicker] = usePluginPaneState(`${statePrefix}selectedTicker`, null); const [tickerSort, setTickerSort] = useState<{ columnId: TickerColumnId; direction: SortDirection }>({ columnId: "buyCount", direction: "desc" }); const [tradeSort, setTradeSort] = useState<{ columnId: TradeColumnId; direction: SortDirection }>({ columnId: "filed", direction: "desc", }); const [memberSort, setMemberSort] = useState<{ columnId: MemberColumnId; direction: SortDirection }>({ columnId: "trades", direction: "desc", }); const fetchGenRef = useRef(0); const openFiltersRef = useRef<() => Promise>(async () => {}); const pageBusy = useRef(false); const abortRef = useRef(null); const load = useCallback((refresh = false) => { abortRef.current?.abort(); abortRef.current = new AbortController(); fetchGenRef.current += 1; const gen = fetchGenRef.current; setStatus((current) => (current === "loaded" && !refresh ? "loaded" : "loading")); setError(null); setLoadingMore(false); pageBusy.current = false; loadCongressHouse({ ...filters, chamber: filters.chamber ?? "all", limit: CONGRESS_TRADE_LIMIT, filingLimit: CONGRESS_FILING_LIMIT, refresh, ticker: tickerFilter, }, undefined, abortRef.current.signal) .then((nextPayload) => { if (fetchGenRef.current !== gen) return; setPayload((current) => ( refresh && current ? { ...current, asOf: nextPayload.asOf, trades: mergeCongressPages(current, nextPayload).trades } : nextPayload )); setStatus("loaded"); setLastLoadedAt(Date.now()); }) .catch((loadError) => { if (fetchGenRef.current !== gen) return; setError(loadError instanceof Error ? loadError.message : String(loadError)); setStatus("error"); }); }, [tickerFilter, filterKey]); const loadPage = useCallback((request: ReturnType) => { if (!request || !payload || pageBusy.current) return; pageBusy.current = true; const gen = fetchGenRef.current; setLoadingMore(true); loadCongressHouse({ ...filters, chamber: filters.chamber ?? "all", ...request, limit: CONGRESS_TRADE_LIMIT, filingLimit: CONGRESS_FILING_LIMIT, ticker: tickerFilter, }, undefined, abortRef.current?.signal) .then((nextPayload) => { if (fetchGenRef.current !== gen) return; setPayload((current) => { if (!current) return nextPayload; return mergeCongressPages(current, nextPayload); }); }) .catch((loadError) => { if (fetchGenRef.current !== gen) return; setError(loadError instanceof Error ? loadError.message : String(loadError)); }) .finally(() => { if (fetchGenRef.current !== gen) return; setLoadingMore(false); pageBusy.current = false; }); }, [payload, tickerFilter, filterKey]); const loadMore = useCallback(() => { if (!payload || loadingMore || status !== "loaded") return; loadPage(nextCongressPage(payload)); }, [loadPage, loadingMore, payload, status]); // Each earlier year is a fresh set of documents to read, so it waits to be asked for. const previousYearRequest = payload && !canLoadMoreCongress(payload) ? previousCongressYearPage(payload) : null; const loadPreviousYear = useCallback(() => { if (loadingMore || status !== "loaded" || !previousYearRequest) return; loadPage(previousYearRequest); }, [loadPage, loadingMore, previousYearRequest, status]); const onTradeScroll = useTableLoadMore( tradeScrollRef, !!payload && status === "loaded" && !loadingMore && canLoadMoreCongress(payload), loadMore, ); useEffect(() => { setPayload(null); load(false); return () => { fetchGenRef.current += 1; abortRef.current?.abort(); }; }, [load]); // Filings would otherwise age indefinitely in an open pane. const refresh = useCallback(() => { load(true); }, [load]); useShortcut((event) => { if (!focused || event.targetEditable) return; if (tickerFilter && detailMode && isDetailBackNavigationKey(event)) { event.preventDefault?.(); event.stopPropagation?.(); setDetailMode(null); return; } if (!payload && isPlainKey(event, "r")) { event.preventDefault?.(); event.stopPropagation?.(); refresh(); } // Without a table there is no row handler, and a failing filter must stay changeable. if (!payload && isPlainKey(event, "f")) { event.preventDefault?.(); event.stopPropagation?.(); void openFiltersRef.current(); } }, { phase: "before" }); useAutoRefresh(lastLoadedAt, refresh); const trades = payload?.trades ?? []; const visibleTrades = useMemo(() => mine ? trades.filter((trade) => trade.ticker && mineTickers.has(trade.ticker)) : trades, [trades, mine, mineTickers]); const summaries = useMemo(() => aggregateLoadedCongress(visibleTrades, payload?.members), [visibleTrades, payload?.members]); const members = summaries.members; const tickerRows = useMemo(() => sortedTickers(summaries.tickers, tickerSort), [summaries.tickers, tickerSort]); const tickerColumns = useMemo(() => buildTickerColumns(width), [width]); const tradeRows = useMemo(() => sortedTrades(visibleTrades, tradeSort), [visibleTrades, tradeSort]); const memberRows = useMemo(() => sortedMembers(members, memberSort), [members, memberSort]); const tradeColumns = useMemo(() => buildTradeColumns(width, !!tickerFilter), [width, tickerFilter]); const memberColumns = useMemo(() => buildMemberColumns(width), [width]); const selectedTradeIndex = selectedIndexById(tradeRows, selectedTradeId); const selectedMemberIndex = selectedIndexById(memberRows, selectedMemberId); const selectedTrade = tradeRows[selectedTradeIndex] ?? null; const selectedMember = memberRows[selectedMemberIndex] ?? null; const detailTrade = detailMode?.kind === "trade" ? trades.find((trade) => trade.id === detailMode.tradeId) ?? null : null; const detailMember = detailMode?.kind === "member" ? members.find((member) => member.id === detailMode.memberId) ?? null : null; const detailMemberTrades = useMemo(() => ( detailMember ? sortedTrades( trades.filter((trade) => trade.memberName === detailMember.memberName && trade.stateDistrict === detailMember.stateDistrict), { columnId: "filed", direction: "desc" }, ) : [] ), [detailMember, trades]); const openTicker = useInlineTickerOpener(); useEffect(() => { if (tradeRows.length === 0) { if (selectedTradeId !== null) setSelectedTradeId(null); return; } if (!selectedTrade || !selectedTradeId) { setSelectedTradeId(tradeRows[0]!.id); } }, [selectedTrade, selectedTradeId, setSelectedTradeId, tradeRows]); useEffect(() => { if (memberRows.length === 0) { if (selectedMemberId !== null) setSelectedMemberId(null); return; } if (!selectedMember || !selectedMemberId) { setSelectedMemberId(memberRows[0]!.id); } }, [memberRows, selectedMember, selectedMemberId, setSelectedMemberId]); useEffect(() => { if (!payload || status !== "loaded") return; if (detailMode?.kind === "trade" && !detailTrade) setDetailMode(null); if (detailMode?.kind === "member" && !detailMember) setDetailMode(null); }, [detailMember, detailMode, detailTrade, payload, status, setDetailMode]); const selectTab = useCallback((tab: string) => { setActiveTab(tab === "members" || tab === "tickers" ? tab : "trades"); setDetailMode(null); }, [setActiveTab]); const openSelectedTradeSource = useCallback(() => { const trade = detailTrade ?? selectedTrade; if (!trade?.sourceUrl) return; void rendererHost.openExternal(trade.sourceUrl); }, [detailTrade, rendererHost, selectedTrade]); const openSelectedTicker = useCallback(() => { const ticker = activeTab === "tickers" ? selectedTicker ?? tickerRows[0]?.ticker : detailTrade?.ticker ?? selectedTrade?.ticker; if (ticker) openTicker(ticker); }, [activeTab, selectedTicker, tickerRows, detailTrade?.ticker, openTicker, selectedTrade?.ticker]); const openSelectedTradeMember = useCallback(() => { const trade = detailTrade ?? selectedTrade; if (!trade) return; const member = members.find((entry) => entry.memberName === trade.memberName && entry.stateDistrict === trade.stateDistrict); if (!member) return; setSelectedMemberId(member.id, { immediate: true }); setDetailMode({ kind: "member", memberId: member.id }); }, [detailTrade, members, selectedTrade, setSelectedMemberId]); const { handleDetailKeyDown, handleRootKeyDown } = useCongressTradesKeyboard({ activeTab, detailMode, focused, load, loadPreviousYear: previousYearRequest ? loadPreviousYear : null, loadMore: payload && canLoadMoreCongress(payload) ? loadMore : null, openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource, selectTab: tickerFilter ? () => {} : selectTab, }); usePaneMenuItems(`${CONGRESS_TRADES_PANE_ID}:tabs`, () => (tickerFilter || detailMode ? null : CONGRESS_TAB_KEYS.map((tab) => ({ id: `tab-${tab.value}`, label: `Show ${tab.label}`, accelerator: tab.key, checked: activeTab === tab.value, onSelect: () => selectTab(tab.value), }))), [activeTab, detailMode, selectTab, tickerFilter]); // Filings the scan has not read yet are a gap in the window on screen, so // they sit behind the footer's warning indicator rather than beside status. usePaneNoticeFooter({ registrationId: `${CONGRESS_TRADES_PANE_ID}:${tickerFilter ?? "all"}:scan`, notices: [payload ? congressScanNotice(payload) : null].filter((notice): notice is string => !!notice), focused, enabled: !detailMode, }); useCongressTradesFooter({ registrationId: `${CONGRESS_TRADES_PANE_ID}:${tickerFilter ?? "all"}`, activeTab, detailMode, detailTrade, error, loadPreviousYear: previousYearRequest ? loadPreviousYear : null, loadMore: payload && canLoadMoreCongress(payload) ? loadMore : null, loadingMore, openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource, payload, previousYear: previousYearRequest?.year ?? null, selectedTrade, status, }); const openFilters = useCallback(async () => { const choice = await dialog.prompt({ content: (ctx: PromptContext) => , }); const control = choice === "chamber" ? chamberControl : choice === "side" ? sideControl : choice === "owner" ? ownerControl : choice === "asset" ? assetControl : choice === "amount" ? amountControl : null; control?.current?.open(); }, [dialog]); openFiltersRef.current = openFilters; const handleFiltersKey = (event: DataTableKeyEvent) => { if (isPlainKey(event, "f") || isPlainKey(event, "i")) { event.preventDefault?.(); event.stopPropagation?.(); if (isPlainKey(event, "f")) void openFilters(); else setMine(!mine); return true; } return handleRootKeyDown(event); }; usePaneFooter(`${CONGRESS_TRADES_PANE_ID}:${tickerFilter ?? "all"}:filters`, () => ({ hints: !detailMode ? [ { id: "filters", key: "f", label: "ilters", onPress: () => { void openFilters(); } }, { id: "mine", key: "i", label: mine ? "all tickers" : "mine", title: mine ? "All Tickers" : "Mine Only", onPress: () => setMine(!mine) }, ] : [] }), [detailMode, mine, setMine, openFilters]); const filterBar = ; const filterHeight = 1; const detailContent = detailMode?.kind === "ticker" ? ( ) : detailTrade ? ( ) : detailMember ? ( ) : null; const detailTitle = detailMode?.kind === "ticker" ? detailMode.ticker : detailTrade ? `${detailTrade.memberName} ${detailTrade.ticker ?? "trade"}` : detailMember ? detailMember.memberName : undefined; const tabsInHeader = usePaneHeaderTabs(tickerFilter ? null : { tabs: CONGRESS_TABS, activeValue: activeTab, onSelect: selectTab, focused: focused && !detailMode, }); const tabRows = tickerFilter || tabsInHeader ? 0 : 1; const tabs = tickerFilter || tabsInHeader ? null : ( ); if (!payload && (status === "loading" || error)) { return ( {tabs} {filterBar} ); } return ( {tabs} {activeTab === "trades" ? ( focused={focused} detailOpen={detailMode !== null} onBack={() => setDetailMode(null)} detailTitle={detailTitle} detailContent={detailContent} selection={{ kind: "id", selectedId: selectedTradeId, getId: (trade) => trade.id, onChange: (id) => setSelectedTradeId(id), }} onActivate={(trade) => { setSelectedTradeId(trade.id, { immediate: true }); setDetailMode({ kind: "trade", tradeId: trade.id }); }} onRootKeyDown={handleFiltersKey} onDetailKeyDown={handleDetailKeyDown} rootWidth={width} rootBefore={filterBar} resetScrollKey={`${filterKey}:${mine}`} rootHeight={Math.max(1, height - tabRows - filterHeight)} columns={tradeColumns} items={tradeRows} sortColumnId={tradeSort.columnId} sortDirection={tradeSort.direction} onHeaderClick={(columnId) => setTradeSort((current) => nextSort(current, columnId as TradeColumnId, columnId === "member" || columnId === "ticker" || columnId === "asset" ? "asc" : "desc"))} getItemKey={(trade) => trade.id} renderCell={(trade, column, index, row) => { const cell = renderCongressTradeCell(trade, column, index, row); return !row.selected && trade.ticker && mineTickers.has(trade.ticker) && column.id === "member" ? { ...cell, color: colors.borderFocused } : cell; }} emptyStateTitle="No matching trades in this filing window." scrollRef={tradeScrollRef} onBodyScrollActivity={onTradeScroll} /> ) : activeTab === "tickers" ? ( focused={focused} detailOpen={detailMode !== null} onBack={() => setDetailMode(null)} detailTitle={detailTitle} detailContent={detailContent} selection={{ kind: "id", selectedId: selectedTicker, getId: (row) => row.ticker, onChange: setSelectedTicker }} onActivate={(row) => { setSelectedTicker(row.ticker); setDetailMode({ kind: "ticker", ticker: row.ticker }); }} rootWidth={width} rootHeight={Math.max(1, height - tabRows - filterHeight)} rootBefore={filterBar} onRootKeyDown={handleFiltersKey} columns={tickerColumns} items={tickerRows} getItemKey={(row) => row.ticker} sortColumnId={tickerSort.columnId} sortDirection={tickerSort.direction} onHeaderClick={(columnId) => setTickerSort((current) => nextSort(current, columnId as TickerColumnId, columnId === "ticker" ? "asc" : "desc"))} renderCell={(row, column, index, selected) => { const cell = renderCongressTickerCell(row, column, index, selected); return !selected.selected && mineTickers.has(row.ticker) && column.id === "ticker" ? { ...cell, color: colors.borderFocused } : cell; }} emptyStateTitle="No matching tickers." scrollRef={tradeScrollRef} onBodyScrollActivity={onTradeScroll} /> ) : ( focused={focused} detailOpen={detailMode !== null} onBack={() => setDetailMode(null)} detailTitle={detailTitle} detailContent={detailContent} selection={{ kind: "id", selectedId: selectedMemberId, getId: (member) => member.id, onChange: (id) => setSelectedMemberId(id), }} onActivate={(member) => { setSelectedMemberId(member.id, { immediate: true }); setDetailMode({ kind: "member", memberId: member.id }); }} onRootKeyDown={handleFiltersKey} onDetailKeyDown={handleDetailKeyDown} rootWidth={width} rootBefore={filterBar} resetScrollKey={`${filterKey}:${mine}`} rootHeight={Math.max(1, height - tabRows - filterHeight)} columns={memberColumns} items={memberRows} sortColumnId={memberSort.columnId} sortDirection={memberSort.direction} onHeaderClick={(columnId) => setMemberSort((current) => nextSort(current, columnId as MemberColumnId, columnId === "member" || columnId === "district" ? "asc" : "desc"))} getItemKey={(member) => member.id} renderCell={renderCongressMemberCell} emptyStateTitle="No matching members." scrollRef={tradeScrollRef} onBodyScrollActivity={onTradeScroll} /> )} ); }