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