import { aggregateLoadedCongress } from "./aggregates"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, ScrollBox, Text, TextAttributes, useRendererHost, type ScrollBoxRenderable } from "../../../ui"; import { DataTableView, KeyValueRow, StatGrid, usePaneFooter, usePaneNoticeFooter, useTableLoadMore, type DataTableKeyEvent, type StatItem, } from "../../../components"; import { useInlineTickerOpener } from "../../../state/hooks/inline-tickers"; import { colors } from "../../../theme/colors"; import { isPlainKey } from "../../../utils/keyboard"; import type { CloudCongressHousePayload, CloudCongressMemberPayload, CloudCongressTradePayload, } from "../../../api-client"; import { CONGRESS_MEMBER_FILING_LIMIT, CONGRESS_MEMBER_TRADE_LIMIT, CONGRESS_TRADES_PANE_ID, canLoadMoreCongress, congressScanNotice, mergeCongressPages, nextCongressPage, previousCongressYearPage, buildMemberTradeColumns, formatCongressReturn, formatLag, nextSort, sortedTrades, truncate, type LoadStatus, type TradeColumn, type TradeColumnId, } from "./model"; import { renderCongressTradeCell } from "./table"; import { loadCongressHouse } from "./client"; function DetailLine({ label, value, tone, bold = false, }: { label: string; value: string; tone?: "muted" | "value" | "positive" | "negative" | "warning"; bold?: boolean; }) { const color = tone === "positive" ? colors.positive : tone === "negative" ? colors.negative : tone === "warning" ? colors.warning : tone === "value" ? colors.textBright : tone === "muted" ? colors.textDim : colors.text; return ; } export function TradeDetail({ trade, width, }: { trade: CloudCongressTradePayload; width: number; }) { const lineWidth = Math.max(1, width - 2); return ( {/* The detail title already carries the member and ticker. */} 45 ? " (past 45 days)" : ""}`} tone={(trade.lagDays ?? 0) > 45 ? "warning" : undefined} /> {trade.filingStatus ? : null} {trade.subholdingOf ? : null} {trade.description ? ( <> {" "} description {truncate(trade.description, lineWidth)} ) : null} ); } export function MemberTradesDetail({ member, initialTrades, width, focused, filingLimit, }: { member: CloudCongressMemberPayload; initialTrades: CloudCongressTradePayload[]; width: number; focused: boolean; filingLimit: number; }) { const rendererHost = useRendererHost(); const openTicker = useInlineTickerOpener(); const [trades, setTrades] = useState(initialTrades); // A member sits in one chamber, so their history comes from that feed alone. const chamber = initialTrades[0]?.chamber ?? "all"; const [detailPayload, setDetailPayload] = useState(null); const [status, setStatus] = useState("loading"); const [error, setError] = useState(null); const [selectedTradeId, setSelectedTradeId] = useState(initialTrades[0]?.id ?? null); const [sortPreference, setSortPreference] = useState<{ columnId: TradeColumnId; direction: "asc" | "desc" }>({ columnId: "filed", direction: "desc", }); const fetchGenRef = useRef(0); const [loadingMore, setLoadingMore] = useState(false); const tradeScrollRef = useRef(null); const load = useCallback((refresh = false) => { fetchGenRef.current += 1; const gen = fetchGenRef.current; setStatus("loading"); setError(null); loadCongressHouse({ chamber, member: member.memberName, limit: CONGRESS_MEMBER_TRADE_LIMIT, filingLimit: Math.max(CONGRESS_MEMBER_FILING_LIMIT, filingLimit), refresh, }) .then((payload) => { if (fetchGenRef.current !== gen) return; const exactMemberTrades = payload.trades.filter((trade) => ( trade.memberName === member.memberName && trade.stateDistrict === member.stateDistrict )); setDetailPayload(payload); setTrades(exactMemberTrades.length > 0 ? exactMemberTrades : payload.trades); setStatus("loaded"); }) .catch((loadError) => { if (fetchGenRef.current !== gen) return; setError(loadError instanceof Error ? loadError.message : String(loadError)); setStatus("error"); }); }, [chamber, filingLimit, member.memberName, member.stateDistrict]); const loadPage = useCallback((nextRequest: ReturnType) => { if (!detailPayload || !nextRequest) return; const gen = fetchGenRef.current; setLoadingMore(true); loadCongressHouse({ ...nextRequest, chamber, member: member.memberName, limit: CONGRESS_MEMBER_TRADE_LIMIT, filingLimit: Math.max(CONGRESS_MEMBER_FILING_LIMIT, filingLimit), }) .then((payload) => { if (fetchGenRef.current !== gen) return; const merged = detailPayload ? mergeCongressPages(detailPayload, payload) : payload; const exactMemberTrades = merged.trades.filter((trade) => ( trade.memberName === member.memberName && trade.stateDistrict === member.stateDistrict )); setDetailPayload(merged); setTrades(exactMemberTrades.length > 0 ? exactMemberTrades : merged.trades); }) .catch((loadError) => { if (fetchGenRef.current !== gen) return; setError(loadError instanceof Error ? loadError.message : String(loadError)); }) .finally(() => { if (fetchGenRef.current !== gen) return; setLoadingMore(false); }); }, [chamber, detailPayload, filingLimit, member.memberName, member.stateDistrict]); const loadMore = useCallback(() => { if (!detailPayload || loadingMore || status !== "loaded") return; loadPage(nextCongressPage(detailPayload)); }, [detailPayload, loadPage, loadingMore, status]); // Reading an earlier year means reading its documents, so it stays a deliberate step. const previousYearRequest = detailPayload && !canLoadMoreCongress(detailPayload) ? previousCongressYearPage(detailPayload) : null; const loadPreviousYear = useCallback(() => { if (loadingMore || status !== "loaded" || !previousYearRequest) return; loadPage(previousYearRequest); }, [loadPage, loadingMore, previousYearRequest, status]); const onTradeScroll = useTableLoadMore( tradeScrollRef, !!detailPayload && status === "loaded" && !loadingMore && canLoadMoreCongress(detailPayload), loadMore, ); useEffect(() => { fetchGenRef.current += 1; setTrades(initialTrades); setDetailPayload(null); setStatus("loading"); setError(null); setSelectedTradeId(initialTrades[0]?.id ?? null); }, [member.id]); useEffect(() => { load(false); return () => { fetchGenRef.current += 1; }; }, [load]); const sortedRows = useMemo(() => sortedTrades(trades, sortPreference), [sortPreference, trades]); const columns = useMemo(() => buildMemberTradeColumns(width), [width]); const selectedTrade = useMemo(() => ( sortedRows.find((trade) => trade.id === selectedTradeId) ?? sortedRows[0] ?? null ), [selectedTradeId, sortedRows]); const summaryMember = useMemo(() => ( aggregateLoadedCongress(trades, detailPayload?.members ?? [member]).members .find((entry) => entry.id === member.id) ?? member ), [detailPayload?.members, member, trades]); const maybeTruncated = status === "loaded" && trades.length >= CONGRESS_MEMBER_TRADE_LIMIT; const scanNotice = detailPayload ? congressScanNotice(detailPayload) : null; useEffect(() => { if (selectedTradeId && sortedRows.some((trade) => trade.id === selectedTradeId)) return; setSelectedTradeId(sortedRows[0]?.id ?? null); }, [selectedTradeId, sortedRows]); const refresh = useCallback(() => { load(true); }, [load]); const openSelectedTicker = useCallback(() => { if (selectedTrade?.ticker) openTicker(selectedTrade.ticker); }, [openTicker, selectedTrade?.ticker]); const openSelectedSource = useCallback(() => { if (selectedTrade?.sourceUrl) void rendererHost.openExternal(selectedTrade.sourceUrl); }, [rendererHost, selectedTrade?.sourceUrl]); const handleKeyDown = useCallback((event: DataTableKeyEvent) => { if (isPlainKey(event, "n") && detailPayload && canLoadMoreCongress(detailPayload)) { event.preventDefault?.(); event.stopPropagation?.(); loadMore(); return true; } if (isPlainKey(event, "r")) { event.preventDefault?.(); event.stopPropagation?.(); refresh(); return true; } if (isPlainKey(event, "t") && selectedTrade?.ticker) { event.preventDefault?.(); event.stopPropagation?.(); openSelectedTicker(); return true; } if (isPlainKey(event, "o") && selectedTrade?.sourceUrl) { event.preventDefault?.(); event.stopPropagation?.(); openSelectedSource(); return true; } if (isPlainKey(event, "p") && previousYearRequest) { event.preventDefault?.(); event.stopPropagation?.(); loadPreviousYear(); return true; } return false; }, [detailPayload, loadMore, loadPreviousYear, openSelectedSource, openSelectedTicker, previousYearRequest, refresh, selectedTrade?.sourceUrl, selectedTrade?.ticker]); usePaneNoticeFooter({ registrationId: `${CONGRESS_TRADES_PANE_ID}:member-notices`, notices: [ scanNotice, maybeTruncated ? `Only the most recent ${CONGRESS_MEMBER_TRADE_LIMIT} trades for this member are shown.` : null, ].filter((notice): notice is string => !!notice), focused, }); usePaneFooter(`${CONGRESS_TRADES_PANE_ID}:member-detail`, () => ({ info: [ ...(status === "loading" ? [{ id: "member-loading", parts: [{ text: "loading member trades", tone: "muted" as const }] }] : []), ...(loadingMore ? [{ id: "member-more", parts: [{ text: "loading more", tone: "muted" as const }] }] : []), ...(error ? [{ id: "member-error", parts: [{ text: error, tone: "warning" as const }] }] : []), ], hints: [ ...(detailPayload && canLoadMoreCongress(detailPayload) ? [{ id: "member-next", key: "n", label: "ext filings", onPress: loadMore, disabled: loadingMore }] : []), { id: "member-ticker", key: "t", label: "icker", onPress: openSelectedTicker, disabled: !selectedTrade?.ticker }, { id: "member-open", key: "o", label: "pen", onPress: openSelectedSource, disabled: !selectedTrade?.sourceUrl }, ...(previousYearRequest ? [{ id: "member-prev-year", key: "p", label: `rev year ${previousYearRequest.year}`, onPress: loadPreviousYear }] : []), ], }), [ error, detailPayload, loadMore, loadingMore, loadPreviousYear, maybeTruncated, openSelectedSource, openSelectedTicker, previousYearRequest, scanNotice, selectedTrade?.sourceUrl, selectedTrade?.ticker, status, ]); // The member row already shows party, district, counts and the range. The // detail adds what the row lacks: how many priced trades stand behind the // return figures, and the committees. const summaryItems: StatItem[] = [ { id: "median", label: "Median return", value: formatCongressReturn(summaryMember.medianReturn), detail: `${summaryMember.pricedTradeCount ?? 0} priced` }, { id: "hit", label: "Buy hit rate", value: summaryMember.buyHitRate == null ? "--" : `${summaryMember.buyHitRate.toFixed(0)}%`, detail: `${summaryMember.pricedBuyCount ?? 0} priced buys` }, ...(summaryMember.committees ?? []).map((committee): StatItem => ({ id: `committee:${committee}`, label: "Committee", value: committee, wide: true })), ]; const summary = ; const emptyTitle = status === "loading" ? "Loading member trades..." : error ?? "No trades for this member."; return ( focused={focused} selection={{ kind: "id", selectedId: selectedTradeId, getId: (trade) => trade.id, onChange: (id) => setSelectedTradeId(id), }} onActivate={(trade) => { if (trade.ticker) openTicker(trade.ticker); }} onRootKeyDown={handleKeyDown} rootWidth={width} rootBefore={summary} resetScrollKey={member.id} columns={columns} items={sortedRows} sortColumnId={sortPreference.columnId} sortDirection={sortPreference.direction} onHeaderClick={(columnId) => { setSortPreference((current) => nextSort( current, columnId as TradeColumnId, columnId === "ticker" || columnId === "asset" || columnId === "side" || columnId === "owner" ? "asc" : "desc", )); }} getItemKey={(trade) => trade.id} renderCell={renderCongressTradeCell} emptyStateTitle={emptyTitle} showHorizontalScrollbar={false} scrollRef={tradeScrollRef} onBodyScrollActivity={onTradeScroll} /> ); }