gloomberb 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  3. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  4. package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
  5. package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
  6. package/src/components/layout/detached-pane-shell.tsx +5 -2
  7. package/src/components/layout/header.tsx +13 -5
  8. package/src/components/layout/window-controls.tsx +19 -10
  9. package/src/components/price-performance.tsx +175 -0
  10. package/src/market-data/performance.test.ts +54 -0
  11. package/src/market-data/performance.ts +109 -0
  12. package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
  13. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  14. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  15. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  16. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  17. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  18. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  19. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  20. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  21. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  22. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  23. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  24. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  25. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  26. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  27. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  28. package/src/renderers/electrobun/bun/index.ts +9 -2
  29. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  30. package/src/renderers/electrobun/view/main.tsx +2 -1
  31. package/src/renderers/electrobun/view/styles.css +9 -8
  32. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  33. package/src/version.ts +1 -1
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appendQuoteToPriceReturnHistory,
4
+ buildPriceReturnFields,
5
+ computePriceReturnForHorizon,
6
+ PRICE_RETURN_HORIZONS,
7
+ } from "./performance";
8
+ import type { PricePoint } from "../types/financials";
9
+
10
+ function point(date: string, close: number): PricePoint {
11
+ return { date: new Date(`${date}T00:00:00Z`), close };
12
+ }
13
+
14
+ describe("price performance", () => {
15
+ test("computes horizon return from the closest prior baseline", () => {
16
+ const history = [
17
+ point("2025-12-31", 100),
18
+ point("2026-01-02", 102),
19
+ point("2026-01-31", 110),
20
+ point("2026-02-01", 120),
21
+ ];
22
+
23
+ const oneMonth = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1M")!;
24
+
25
+ expect(computePriceReturnForHorizon(history, oneMonth)).toBeCloseTo(20 / 100);
26
+ });
27
+
28
+ test("leaves fixed horizons empty when history does not reach the baseline date", () => {
29
+ const history = [
30
+ point("2026-01-15", 100),
31
+ point("2026-02-01", 110),
32
+ ];
33
+
34
+ const fields = buildPriceReturnFields(history);
35
+
36
+ expect(fields.find((field) => field.id === "1M")?.value).toBeNull();
37
+ expect(fields.find((field) => field.id === "1Y")?.value).toBeNull();
38
+ });
39
+
40
+ test("can include a newer live quote as the latest return point", () => {
41
+ const history = [
42
+ point("2025-01-31", 100),
43
+ point("2026-01-31", 120),
44
+ ];
45
+ const withQuote = appendQuoteToPriceReturnHistory(history, {
46
+ price: 150,
47
+ lastUpdated: Date.parse("2026-02-01T15:30:00Z"),
48
+ });
49
+ const oneYear = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1Y")!;
50
+
51
+ expect(withQuote).toHaveLength(3);
52
+ expect(computePriceReturnForHorizon(withQuote, oneYear)).toBeCloseTo(50 / 100);
53
+ });
54
+ });
@@ -0,0 +1,109 @@
1
+ import type { PricePoint, Quote } from "../types/financials";
2
+
3
+ export interface PriceReturnHorizon {
4
+ id: string;
5
+ label: string;
6
+ amount: number;
7
+ unit: "month" | "year";
8
+ }
9
+
10
+ export interface PriceReturnField {
11
+ id: string;
12
+ label: string;
13
+ value: number | null;
14
+ }
15
+
16
+ export const PRICE_RETURN_HORIZONS: PriceReturnHorizon[] = [
17
+ { id: "1M", label: "1M", amount: 1, unit: "month" },
18
+ { id: "3M", label: "3M", amount: 3, unit: "month" },
19
+ { id: "6M", label: "6M", amount: 6, unit: "month" },
20
+ { id: "1Y", label: "1Y", amount: 1, unit: "year" },
21
+ { id: "3Y", label: "3Y", amount: 3, unit: "year" },
22
+ { id: "5Y", label: "5Y", amount: 5, unit: "year" },
23
+ ];
24
+
25
+ function coercePointDate(value: Date | string | number): Date | null {
26
+ const date = value instanceof Date ? value : new Date(value);
27
+ return Number.isFinite(date.getTime()) ? date : null;
28
+ }
29
+
30
+ export function normalizePriceReturnHistory(points: readonly PricePoint[]): PricePoint[] {
31
+ const byTimestamp = new Map<number, PricePoint>();
32
+ for (const point of points) {
33
+ const date = coercePointDate(point.date as Date | string | number);
34
+ if (!date || !Number.isFinite(point.close)) continue;
35
+ byTimestamp.set(date.getTime(), { ...point, date });
36
+ }
37
+ return [...byTimestamp.entries()]
38
+ .sort((left, right) => left[0] - right[0])
39
+ .map(([, point]) => point);
40
+ }
41
+
42
+ export function appendQuoteToPriceReturnHistory(
43
+ points: readonly PricePoint[],
44
+ quote: Pick<Quote, "price" | "lastUpdated"> | null | undefined,
45
+ ): PricePoint[] {
46
+ const history = normalizePriceReturnHistory(points);
47
+ if (!quote || !Number.isFinite(quote.price) || !Number.isFinite(quote.lastUpdated)) {
48
+ return history;
49
+ }
50
+
51
+ const quoteDate = new Date(quote.lastUpdated);
52
+ if (!Number.isFinite(quoteDate.getTime())) return history;
53
+
54
+ const latestHistoryTime = history.at(-1)?.date.getTime() ?? Number.NEGATIVE_INFINITY;
55
+ if (quoteDate.getTime() <= latestHistoryTime) return history;
56
+
57
+ return [
58
+ ...history,
59
+ {
60
+ date: quoteDate,
61
+ close: quote.price,
62
+ },
63
+ ];
64
+ }
65
+
66
+ function subtractHorizon(date: Date, horizon: PriceReturnHorizon): Date {
67
+ const target = new Date(date);
68
+ if (horizon.unit === "month") {
69
+ target.setMonth(target.getMonth() - horizon.amount);
70
+ } else {
71
+ target.setFullYear(target.getFullYear() - horizon.amount);
72
+ }
73
+ return target;
74
+ }
75
+
76
+ export function computePriceReturnForHorizon(
77
+ points: readonly PricePoint[],
78
+ horizon: PriceReturnHorizon,
79
+ ): number | null {
80
+ const history = normalizePriceReturnHistory(points);
81
+ if (history.length < 2) return null;
82
+
83
+ const latest = history.at(-1)!;
84
+ const cutoff = subtractHorizon(latest.date, horizon);
85
+ let baseline: PricePoint | null = null;
86
+
87
+ for (const point of history) {
88
+ if (point.date.getTime() <= cutoff.getTime()) {
89
+ baseline = point;
90
+ } else {
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (!baseline || !Number.isFinite(baseline.close) || baseline.close === 0) return null;
96
+ if (!Number.isFinite(latest.close)) return null;
97
+ return (latest.close - baseline.close) / baseline.close;
98
+ }
99
+
100
+ export function buildPriceReturnFields(
101
+ points: readonly PricePoint[],
102
+ horizons: readonly PriceReturnHorizon[] = PRICE_RETURN_HORIZONS,
103
+ ): PriceReturnField[] {
104
+ return horizons.map((horizon) => ({
105
+ id: horizon.id,
106
+ label: horizon.label,
107
+ value: computePriceReturnForHorizon(points, horizon),
108
+ }));
109
+ }
@@ -68,6 +68,28 @@ function makeFinancials(symbol: string, currency: string, closes: number[]): Tic
68
68
  };
69
69
  }
70
70
 
71
+ function makeDatedFinancials(symbol: string, currency: string, closes: Array<[string, number]>): TickerFinancials {
72
+ const latest = closes.at(-1);
73
+ const latestClose = latest?.[1] ?? 0;
74
+ const latestDate = latest ? Date.parse(`${latest[0]}T00:00:00Z`) : Date.now();
75
+ return {
76
+ annualStatements: [],
77
+ quarterlyStatements: [],
78
+ quote: {
79
+ symbol,
80
+ price: latestClose,
81
+ currency,
82
+ change: latestClose - (closes[0]?.[1] ?? latestClose),
83
+ changePercent: closes[0]?.[1] ? ((latestClose - closes[0]![1]) / closes[0]![1]) * 100 : 0,
84
+ lastUpdated: latestDate,
85
+ },
86
+ priceHistory: closes.map(([date, close]) => ({
87
+ date: new Date(`${date}T00:00:00Z`),
88
+ close,
89
+ })),
90
+ };
91
+ }
92
+
71
93
  function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
72
94
  return {
73
95
  id: "test-provider",
@@ -101,6 +123,22 @@ function createProvider(historyBySymbol: Record<string, number[]>, currencyBySym
101
123
  };
102
124
  }
103
125
 
126
+ function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials>): DataProvider {
127
+ const provider = createProvider({}, {});
128
+ provider.getTickerFinancials = async (symbol) => financialsBySymbol[symbol] ?? makeDatedFinancials(symbol, "USD", []);
129
+ provider.getQuote = async (symbol) => financialsBySymbol[symbol]?.quote ?? {
130
+ symbol,
131
+ price: 0,
132
+ currency: "USD",
133
+ change: 0,
134
+ changePercent: 0,
135
+ lastUpdated: Date.now(),
136
+ };
137
+ provider.getPriceHistory = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
138
+ provider.getPriceHistoryForResolution = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
139
+ return provider;
140
+ }
141
+
104
142
  function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
105
143
  return {
106
144
  selectTicker: (symbol: string) => { spy.selected.push(symbol); },
@@ -314,7 +352,7 @@ describe("comparisonChartPlugin", () => {
314
352
  expect(frame).not.toContain("side by side");
315
353
  });
316
354
 
317
- test("updates every legend value from the crosshair position", async () => {
355
+ test("updates every return summary range value from the crosshair position", async () => {
318
356
  const provider = createProvider({
319
357
  AAPL: [100, 102, 104],
320
358
  MSFT: [200, 202, 204],
@@ -343,8 +381,12 @@ describe("comparisonChartPlugin", () => {
343
381
 
344
382
  let frame = testSetup!.captureCharFrame();
345
383
  expect(frame).not.toContain("AAPL - 1D");
346
- expect(frame).toContain("AAPL $104.00 +4.00%");
347
- expect(frame).toContain("MSFT $204.00 +2.00%");
384
+ expect(frame).toContain("Sym");
385
+ expect(frame).toContain("Rng");
386
+ expect(frame).toContain("1Y");
387
+ expect(frame).toContain("5Y");
388
+ expect(frame).toMatch(/> AAPL\s+\+4\.00%/);
389
+ expect(frame).toMatch(/MSFT\s+\+2\.00%/);
348
390
 
349
391
  await act(async () => {
350
392
  await testSetup!.mockMouse.moveTo(2, 4);
@@ -353,8 +395,45 @@ describe("comparisonChartPlugin", () => {
353
395
  await flushFrames();
354
396
 
355
397
  frame = testSetup!.captureCharFrame();
356
- expect(frame).toContain("AAPL $100.00 0.00%");
357
- expect(frame).toContain("MSFT $200.00 0.00%");
398
+ expect(frame).toMatch(/> AAPL\s+0\.00%/);
399
+ expect(frame).toMatch(/MSFT\s+0\.00%/);
400
+ });
401
+
402
+ test("sorts comparison summary rows by one-year return", async () => {
403
+ const rows = {
404
+ SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
405
+ FAST: makeDatedFinancials("FAST", "USD", [["2025-01-02", 100], ["2026-01-02", 150]]),
406
+ MID: makeDatedFinancials("MID", "USD", [["2025-01-02", 100], ["2026-01-02", 125]]),
407
+ };
408
+ const provider = createDatedProvider(rows);
409
+ setSharedMarketDataForTests(provider);
410
+ sharedCoordinator = new MarketDataCoordinator(provider);
411
+ setSharedMarketDataCoordinator(sharedCoordinator);
412
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
413
+
414
+ await mountComparisonHarness({
415
+ axisMode: "percent",
416
+ symbols: ["SLOW", "FAST", "MID"],
417
+ symbolsText: "SLOW, FAST, MID",
418
+ }, [
419
+ makeTicker("SLOW", "USD"),
420
+ makeTicker("FAST", "USD"),
421
+ makeTicker("MID", "USD"),
422
+ ], Object.entries(rows));
423
+
424
+ await flushFrames();
425
+
426
+ const frame = testSetup!.captureCharFrame();
427
+ const table = frame.slice(frame.indexOf("Sym"));
428
+ const fastIndex = table.indexOf("FAST");
429
+ const midIndex = table.indexOf("MID");
430
+ const slowIndex = table.indexOf("SLOW");
431
+
432
+ expect(fastIndex).toBeGreaterThanOrEqual(0);
433
+ expect(midIndex).toBeGreaterThanOrEqual(0);
434
+ expect(slowIndex).toBeGreaterThanOrEqual(0);
435
+ expect(fastIndex).toBeLessThan(midIndex);
436
+ expect(midIndex).toBeLessThan(slowIndex);
358
437
  });
359
438
 
360
439
  test("extends lagging comparison histories with the latest quote", async () => {
@@ -406,11 +485,11 @@ describe("comparisonChartPlugin", () => {
406
485
  await flushFrames();
407
486
 
408
487
  const frame = testSetup!.captureCharFrame();
409
- expect(frame).toContain("AAPL $150.00 +");
410
- expect(frame).toContain("MSFT $250.00 +");
488
+ expect(frame).toMatch(/> AAPL\s+\+47\.06%/);
489
+ expect(frame).toMatch(/MSFT\s+\+23\.76%/);
411
490
  });
412
491
 
413
- test("updates comparison legends from chart-owned quote streams", async () => {
492
+ test("updates comparison return summary from chart-owned quote streams", async () => {
414
493
  let emitQuote: ((symbol: string, price: number) => void) | null = null;
415
494
  const provider = createProvider({
416
495
  AAPL: [100, 102],
@@ -464,7 +543,7 @@ describe("comparisonChartPlugin", () => {
464
543
  await flushFrames();
465
544
 
466
545
  const frame = testSetup!.captureCharFrame();
467
- expect(frame).toContain("AAPL $155.00 +");
546
+ expect(frame).toMatch(/> AAPL\s+\+51\.96%/);
468
547
  });
469
548
 
470
549
  test("does not refetch resolution support when only comparison prices update", async () => {
@@ -1,25 +1,35 @@
1
- import type { RefObject } from "react";
2
- import { Box, ScrollBox, Text, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, useRendererHost } from "../../../ui";
3
+ import {
4
+ DataTableView,
5
+ usePaneFooter,
6
+ type DataTableKeyEvent,
7
+ } from "../../../components";
8
+ import { useInlineTickerOpener } from "../../../state/hooks/inline-tickers";
3
9
  import { colors } from "../../../theme/colors";
4
10
  import { padTo } from "../../../utils/format";
11
+ import { isPlainKey } from "../../../utils/keyboard";
5
12
  import type {
13
+ CloudCongressHousePayload,
6
14
  CloudCongressMemberPayload,
7
15
  CloudCongressTradePayload,
8
16
  } from "../../../api-client";
17
+ import { apiClient } from "../../../api-client";
9
18
  import {
19
+ CONGRESS_MEMBER_FILING_LIMIT,
20
+ CONGRESS_MEMBER_TRADE_LIMIT,
21
+ CONGRESS_TRADES_PANE_ID,
22
+ buildMemberTradeColumns,
10
23
  formatAmountRange,
11
24
  formatLag,
12
- formatShortDate,
25
+ nextSort,
26
+ sortedTrades,
13
27
  truncate,
28
+ type LoadStatus,
29
+ type TradeColumn,
30
+ type TradeColumnId,
14
31
  } from "./model";
15
-
16
- export function sideColor(side: CloudCongressTradePayload["side"], selected: boolean): string {
17
- if (selected) return colors.selectedText;
18
- if (side === "BUY") return colors.positive;
19
- if (side === "SELL") return colors.negative;
20
- if (side === "EXCHANGE") return colors.warning;
21
- return colors.textDim;
22
- }
32
+ import { renderCongressTradeCell } from "./table";
23
33
 
24
34
  function DetailLine({
25
35
  label,
@@ -86,69 +96,195 @@ export function TradeDetail({
86
96
  );
87
97
  }
88
98
 
89
- export function MemberTradeList({
99
+ export function MemberTradesDetail({
90
100
  member,
91
- trades,
101
+ initialTrades,
92
102
  width,
93
- scrollRef,
103
+ focused,
104
+ filingLimit,
94
105
  }: {
95
106
  member: CloudCongressMemberPayload;
96
- trades: CloudCongressTradePayload[];
107
+ initialTrades: CloudCongressTradePayload[];
97
108
  width: number;
98
- scrollRef: RefObject<ScrollBoxRenderable | null>;
109
+ focused: boolean;
110
+ filingLimit: number;
99
111
  }) {
100
- const lineWidth = Math.max(1, width - 2);
101
- const filedWidth = 7;
102
- const txWidth = 7;
103
- const sideWidth = 5;
104
- const tickerWidth = 10;
105
- const amountWidth = 14;
106
- const assetWidth = Math.max(16, lineWidth - filedWidth - txWidth - sideWidth - tickerWidth - amountWidth - 6);
107
- return (
108
- <ScrollBox ref={scrollRef} scrollY focusable={false} flexGrow={1} paddingX={1}>
109
- <Box flexDirection="column" width={lineWidth}>
110
- <Box height={1} flexDirection="row">
111
- <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>
112
- {truncate(`${member.memberName} ${member.stateDistrict}`, lineWidth)}
113
- </Text>
114
- </Box>
115
- <Box height={1} flexDirection="row">
116
- <Text fg={colors.textDim}>
117
- {`${member.tradeCount} trades ${member.buyCount} buys ${member.sellCount} sells ${formatAmountRange(member.estimatedLow, member.estimatedHigh)}`}
118
- </Text>
119
- </Box>
120
- <Text>{" "}</Text>
121
- <Box height={1} flexDirection="row">
122
- <Text fg={colors.textDim}>{padTo("FILED", filedWidth)}</Text>
123
- <Text fg={colors.textDim}> </Text>
124
- <Text fg={colors.textDim}>{padTo("TX", txWidth)}</Text>
125
- <Text fg={colors.textDim}> </Text>
126
- <Text fg={colors.textDim}>{padTo("SIDE", sideWidth)}</Text>
127
- <Text fg={colors.textDim}> </Text>
128
- <Text fg={colors.textDim}>{padTo("TICKER", tickerWidth)}</Text>
129
- <Text fg={colors.textDim}> </Text>
130
- <Text fg={colors.textDim}>{padTo("AMOUNT", amountWidth, "right")}</Text>
131
- <Text fg={colors.textDim}> </Text>
132
- <Text fg={colors.textDim}>{padTo("ASSET", assetWidth)}</Text>
133
- </Box>
134
- {trades.map((trade) => (
135
- <Box key={trade.id} height={1} flexDirection="row">
136
- <Text fg={colors.textDim}>{padTo(formatShortDate(trade.filingDate), filedWidth)}</Text>
137
- <Text fg={colors.textDim}> </Text>
138
- <Text fg={colors.textDim}>{padTo(formatShortDate(trade.transactionDate), txWidth)}</Text>
139
- <Text fg={colors.textDim}> </Text>
140
- <Text fg={sideColor(trade.side, false)}>{padTo(trade.side, sideWidth)}</Text>
141
- <Text fg={colors.textDim}> </Text>
142
- <Text fg={trade.ticker ? colors.positive : colors.textDim} attributes={trade.ticker ? TextAttributes.BOLD : 0}>
143
- {padTo(trade.ticker ?? "--", tickerWidth)}
144
- </Text>
145
- <Text fg={colors.textDim}> </Text>
146
- <Text fg={colors.textBright}>{padTo(formatAmountRange(trade.amountLow, trade.amountHigh, trade.amount), amountWidth, "right")}</Text>
147
- <Text fg={colors.textDim}> </Text>
148
- <Text fg={colors.text}>{truncate(trade.assetName, assetWidth)}</Text>
149
- </Box>
150
- ))}
112
+ const rendererHost = useRendererHost();
113
+ const openTicker = useInlineTickerOpener();
114
+ const [trades, setTrades] = useState<CloudCongressTradePayload[]>(initialTrades);
115
+ const [detailPayload, setDetailPayload] = useState<CloudCongressHousePayload | null>(null);
116
+ const [status, setStatus] = useState<LoadStatus>("loading");
117
+ const [error, setError] = useState<string | null>(null);
118
+ const [selectedTradeId, setSelectedTradeId] = useState<string | null>(initialTrades[0]?.id ?? null);
119
+ const [sortPreference, setSortPreference] = useState<{ columnId: TradeColumnId; direction: "asc" | "desc" }>({
120
+ columnId: "filed",
121
+ direction: "desc",
122
+ });
123
+ const fetchGenRef = useRef(0);
124
+
125
+ const load = useCallback((refresh = false) => {
126
+ fetchGenRef.current += 1;
127
+ const gen = fetchGenRef.current;
128
+ setStatus("loading");
129
+ setError(null);
130
+ apiClient.getCloudCongressHouse({
131
+ member: member.memberName,
132
+ limit: CONGRESS_MEMBER_TRADE_LIMIT,
133
+ filingLimit: Math.max(CONGRESS_MEMBER_FILING_LIMIT, filingLimit),
134
+ refresh,
135
+ })
136
+ .then((payload) => {
137
+ if (fetchGenRef.current !== gen) return;
138
+ const exactMemberTrades = payload.trades.filter((trade) => (
139
+ trade.memberName === member.memberName
140
+ && trade.stateDistrict === member.stateDistrict
141
+ ));
142
+ setDetailPayload(payload);
143
+ setTrades(exactMemberTrades.length > 0 ? exactMemberTrades : payload.trades);
144
+ setStatus("loaded");
145
+ })
146
+ .catch((loadError) => {
147
+ if (fetchGenRef.current !== gen) return;
148
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
149
+ setStatus("error");
150
+ });
151
+ }, [filingLimit, member.memberName, member.stateDistrict]);
152
+
153
+ useEffect(() => {
154
+ fetchGenRef.current += 1;
155
+ setTrades(initialTrades);
156
+ setDetailPayload(null);
157
+ setStatus("loading");
158
+ setError(null);
159
+ setSelectedTradeId(initialTrades[0]?.id ?? null);
160
+ }, [member.id]);
161
+
162
+ useEffect(() => {
163
+ load(false);
164
+ }, [load]);
165
+
166
+ const sortedRows = useMemo(() => sortedTrades(trades, sortPreference), [sortPreference, trades]);
167
+ const columns = useMemo(() => buildMemberTradeColumns(width), [width]);
168
+ const selectedTrade = useMemo(() => (
169
+ sortedRows.find((trade) => trade.id === selectedTradeId) ?? sortedRows[0] ?? null
170
+ ), [selectedTradeId, sortedRows]);
171
+ const summaryMember = useMemo(() => (
172
+ detailPayload?.members.find((entry) => entry.id === member.id)
173
+ ?? detailPayload?.members.find((entry) => (
174
+ entry.memberName === member.memberName
175
+ && entry.stateDistrict === member.stateDistrict
176
+ ))
177
+ ?? member
178
+ ), [detailPayload?.members, member]);
179
+ const maybeTruncated = status === "loaded" && trades.length >= CONGRESS_MEMBER_TRADE_LIMIT;
180
+
181
+ useEffect(() => {
182
+ if (selectedTradeId && sortedRows.some((trade) => trade.id === selectedTradeId)) return;
183
+ setSelectedTradeId(sortedRows[0]?.id ?? null);
184
+ }, [selectedTradeId, sortedRows]);
185
+
186
+ const refresh = useCallback(() => {
187
+ load(true);
188
+ }, [load]);
189
+
190
+ const openSelectedTicker = useCallback(() => {
191
+ if (selectedTrade?.ticker) openTicker(selectedTrade.ticker);
192
+ }, [openTicker, selectedTrade?.ticker]);
193
+
194
+ const openSelectedSource = useCallback(() => {
195
+ if (selectedTrade?.sourceUrl) void rendererHost.openExternal(selectedTrade.sourceUrl);
196
+ }, [rendererHost, selectedTrade?.sourceUrl]);
197
+
198
+ const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
199
+ if (isPlainKey(event, "r")) {
200
+ event.preventDefault?.();
201
+ event.stopPropagation?.();
202
+ refresh();
203
+ return true;
204
+ }
205
+ if (isPlainKey(event, "t") && selectedTrade?.ticker) {
206
+ event.preventDefault?.();
207
+ event.stopPropagation?.();
208
+ openSelectedTicker();
209
+ return true;
210
+ }
211
+ if (isPlainKey(event, "o") && selectedTrade?.sourceUrl) {
212
+ event.preventDefault?.();
213
+ event.stopPropagation?.();
214
+ openSelectedSource();
215
+ return true;
216
+ }
217
+ return false;
218
+ }, [openSelectedSource, openSelectedTicker, refresh, selectedTrade?.sourceUrl, selectedTrade?.ticker]);
219
+
220
+ usePaneFooter(`${CONGRESS_TRADES_PANE_ID}:member-detail`, () => ({
221
+ info: [
222
+ ...(status === "loading" ? [{ id: "member-loading", parts: [{ text: "loading member trades", tone: "muted" as const }] }] : []),
223
+ ...(error ? [{ id: "member-error", parts: [{ text: error, tone: "warning" as const }] }] : []),
224
+ ...(maybeTruncated ? [{ id: "member-truncated", parts: [{ text: `limited to ${CONGRESS_MEMBER_TRADE_LIMIT} trades`, tone: "warning" as const }] }] : []),
225
+ ],
226
+ hints: [
227
+ { id: "member-refresh", key: "r", label: "efresh", onPress: refresh },
228
+ { id: "member-ticker", key: "t", label: "icker", onPress: openSelectedTicker, disabled: !selectedTrade?.ticker },
229
+ { id: "member-open", key: "o", label: "pen", onPress: openSelectedSource, disabled: !selectedTrade?.sourceUrl },
230
+ ],
231
+ }), [
232
+ error,
233
+ maybeTruncated,
234
+ openSelectedSource,
235
+ openSelectedTicker,
236
+ refresh,
237
+ selectedTrade?.sourceUrl,
238
+ selectedTrade?.ticker,
239
+ status,
240
+ ]);
241
+
242
+ const summary = (
243
+ <Box flexDirection="column" paddingX={1} paddingTop={1} paddingBottom={1}>
244
+ <Box height={1} flexDirection="row">
245
+ <Text fg={colors.textDim}>
246
+ {`${summaryMember.stateDistrict || "--"} ${summaryMember.tradeCount} trades ${summaryMember.buyCount} buys ${summaryMember.sellCount} sells ${formatAmountRange(summaryMember.estimatedLow, summaryMember.estimatedHigh)}`}
247
+ </Text>
151
248
  </Box>
152
- </ScrollBox>
249
+ </Box>
250
+ );
251
+ const emptyTitle = status === "loading"
252
+ ? "Loading member trades..."
253
+ : error ?? "No trades for this member.";
254
+
255
+ return (
256
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
257
+ <DataTableView<CloudCongressTradePayload, TradeColumn>
258
+ focused={focused}
259
+ selection={{
260
+ kind: "id",
261
+ selectedId: selectedTradeId,
262
+ getId: (trade) => trade.id,
263
+ onChange: (id) => setSelectedTradeId(id),
264
+ }}
265
+ onActivate={(trade) => {
266
+ if (trade.ticker) openTicker(trade.ticker);
267
+ }}
268
+ onRootKeyDown={handleKeyDown}
269
+ rootWidth={width}
270
+ rootBefore={summary}
271
+ resetScrollKey={member.id}
272
+ columns={columns}
273
+ items={sortedRows}
274
+ sortColumnId={sortPreference.columnId}
275
+ sortDirection={sortPreference.direction}
276
+ onHeaderClick={(columnId) => {
277
+ setSortPreference((current) => nextSort(
278
+ current,
279
+ columnId as TradeColumnId,
280
+ columnId === "ticker" || columnId === "asset" || columnId === "side" || columnId === "owner" ? "asc" : "desc",
281
+ ));
282
+ }}
283
+ getItemKey={(trade) => trade.id}
284
+ renderCell={renderCongressTradeCell}
285
+ emptyStateTitle={emptyTitle}
286
+ showHorizontalScrollbar={false}
287
+ />
288
+ </Box>
153
289
  );
154
290
  }
@@ -7,11 +7,13 @@ import type {
7
7
  import {
8
8
  CONGRESS_TRADES_PANE_ID,
9
9
  type CongressTab,
10
+ type DetailMode,
10
11
  type LoadStatus,
11
12
  } from "./model";
12
13
 
13
14
  export function useCongressTradesFooter({
14
15
  activeTab,
16
+ detailMode,
15
17
  detailTrade,
16
18
  error,
17
19
  load,
@@ -23,6 +25,7 @@ export function useCongressTradesFooter({
23
25
  status,
24
26
  }: {
25
27
  activeTab: CongressTab;
28
+ detailMode: DetailMode;
26
29
  detailTrade: CloudCongressTradePayload | null;
27
30
  error: string | null;
28
31
  load: (refresh?: boolean) => void;
@@ -44,16 +47,19 @@ export function useCongressTradesFooter({
44
47
  ...(status === "loading" ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
45
48
  ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
46
49
  ],
47
- hints: [
48
- { id: "refresh", key: "r", label: "efresh", onPress: () => load(true) },
49
- ...(activeTab === "trades" ? [
50
- { id: "member", key: "m", label: "ember", onPress: openSelectedTradeMember, disabled: !selectedTrade },
51
- { id: "ticker", key: "t", label: "icker", onPress: openSelectedTicker, disabled: !(detailTrade?.ticker ?? selectedTrade?.ticker) },
52
- { id: "open", key: "o", label: "pen", onPress: openSelectedTradeSource, disabled: !(detailTrade ?? selectedTrade)?.sourceUrl },
53
- ] : []),
54
- ],
50
+ hints: detailMode?.kind === "member"
51
+ ? []
52
+ : [
53
+ { id: "refresh", key: "r", label: "efresh", onPress: () => load(true) },
54
+ ...(activeTab === "trades" ? [
55
+ { id: "member", key: "m", label: "ember", onPress: openSelectedTradeMember, disabled: !selectedTrade },
56
+ { id: "ticker", key: "t", label: "icker", onPress: openSelectedTicker, disabled: !(detailTrade?.ticker ?? selectedTrade?.ticker) },
57
+ { id: "open", key: "o", label: "pen", onPress: openSelectedTradeSource, disabled: !(detailTrade ?? selectedTrade)?.sourceUrl },
58
+ ] : []),
59
+ ],
55
60
  }), [
56
61
  activeTab,
62
+ detailMode,
57
63
  detailTrade,
58
64
  error,
59
65
  load,