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}
/>
)}
);
}