gloomberb 0.9.0 → 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 (51) hide show
  1. package/package.json +6 -2
  2. package/patches/electrobun@1.18.1.patch +85 -0
  3. package/src/app/global-shortcuts.test.tsx +12 -0
  4. package/src/app/global-shortcuts.ts +1 -1
  5. package/src/assets/gloomberb-logo-windows.ico +0 -0
  6. package/src/assets/gloomberb-logo-windows.png +0 -0
  7. package/src/assets/gloomberb-logo.ico +0 -0
  8. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  9. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  10. package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
  11. package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
  12. package/src/components/layout/detached-pane-shell.tsx +11 -4
  13. package/src/components/layout/header.tsx +25 -10
  14. package/src/components/layout/titlebar-overlay.ts +16 -0
  15. package/src/components/layout/window-controls.tsx +99 -0
  16. package/src/components/price-performance.tsx +175 -0
  17. package/src/market-data/performance.test.ts +54 -0
  18. package/src/market-data/performance.ts +109 -0
  19. package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
  20. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  21. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  22. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  23. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  24. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  25. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  26. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  27. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  28. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  29. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  30. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +51 -3
  31. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  32. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  33. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  34. package/src/renderers/electrobun/bun/desktop/window-controls.ts +73 -0
  35. package/src/renderers/electrobun/bun/desktop/window-style.ts +31 -0
  36. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  37. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +70 -0
  38. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  39. package/src/renderers/electrobun/bun/index.ts +36 -7
  40. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  41. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  42. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  43. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  44. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  45. package/src/renderers/electrobun/view/main.tsx +2 -1
  46. package/src/renderers/electrobun/view/styles.css +29 -8
  47. package/src/renderers/electrobun/view/ui-host.tsx +113 -74
  48. package/src/ui/host.tsx +2 -0
  49. package/src/updater.test.ts +47 -2
  50. package/src/updater.ts +67 -10
  51. package/src/version.ts +1 -1
@@ -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,
@@ -1,14 +1,12 @@
1
- import { useCallback, type RefObject } from "react";
1
+ import { useCallback } from "react";
2
2
  import { type DataTableKeyEvent } from "../../../components";
3
3
  import { useShortcut } from "../../../react/input";
4
- import { type ScrollBoxRenderable } from "../../../ui";
5
4
  import { isPlainKey } from "../../../utils/keyboard";
6
5
  import type { CongressTab, DetailMode } from "./model";
7
6
 
8
7
  export function useCongressTradesKeyboard({
9
8
  activeTab,
10
9
  detailMode,
11
- detailScrollRef,
12
10
  focused,
13
11
  load,
14
12
  openSelectedTicker,
@@ -18,7 +16,6 @@ export function useCongressTradesKeyboard({
18
16
  }: {
19
17
  activeTab: CongressTab;
20
18
  detailMode: DetailMode;
21
- detailScrollRef: RefObject<ScrollBoxRenderable | null>;
22
19
  focused: boolean;
23
20
  load: (refresh?: boolean) => void;
24
21
  openSelectedTicker: () => void;
@@ -26,25 +23,9 @@ export function useCongressTradesKeyboard({
26
23
  openSelectedTradeSource: () => void;
27
24
  selectTab: (tab: string) => void;
28
25
  }) {
29
- const scrollDetailBy = useCallback((delta: number) => {
30
- const scrollBox = detailScrollRef.current;
31
- if (!scrollBox?.viewport) return;
32
- const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
33
- scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
34
- }, [detailScrollRef]);
35
-
36
26
  const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
37
- if (isPlainKey(event, "j", "down")) {
38
- event.preventDefault?.();
39
- event.stopPropagation?.();
40
- scrollDetailBy(1);
41
- return true;
42
- }
43
- if (isPlainKey(event, "k", "up")) {
44
- event.preventDefault?.();
45
- event.stopPropagation?.();
46
- scrollDetailBy(-1);
47
- return true;
27
+ if (detailMode?.kind === "member") {
28
+ return false;
48
29
  }
49
30
  if (isPlainKey(event, "o")) {
50
31
  event.preventDefault?.();
@@ -65,7 +46,7 @@ export function useCongressTradesKeyboard({
65
46
  return true;
66
47
  }
67
48
  return false;
68
- }, [openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource, scrollDetailBy]);
49
+ }, [detailMode?.kind, openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource]);
69
50
 
70
51
  const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
71
52
  if (isPlainKey(event, "r")) {
@@ -8,6 +8,8 @@ import type {
8
8
  export const CONGRESS_TRADES_PANE_ID = "congress-trades";
9
9
  export const CONGRESS_TRADE_LIMIT = 200;
10
10
  export const CONGRESS_FILING_LIMIT = 20;
11
+ export const CONGRESS_MEMBER_TRADE_LIMIT = 2000;
12
+ export const CONGRESS_MEMBER_FILING_LIMIT = 500;
11
13
 
12
14
  export type CongressTab = "trades" | "members";
13
15
  export type LoadStatus = "idle" | "loading" | "loaded" | "error";
@@ -25,6 +27,7 @@ export type TradeColumnId =
25
27
  | "side"
26
28
  | "ticker"
27
29
  | "amount"
30
+ | "asset"
28
31
  | "owner";
29
32
  export type TradeColumn = DataTableColumn & { id: TradeColumnId };
30
33
  export type MemberColumnId =
@@ -104,6 +107,8 @@ function compareTrade(
104
107
  return compareText(left.ticker ?? "", right.ticker ?? "");
105
108
  case "amount":
106
109
  return (left.amountHigh ?? left.amountLow ?? 0) - (right.amountHigh ?? right.amountLow ?? 0);
110
+ case "asset":
111
+ return compareText(left.assetName, right.assetName);
107
112
  case "owner":
108
113
  return compareText(left.owner, right.owner);
109
114
  }
@@ -172,6 +177,30 @@ export function buildTradeColumns(width: number): TradeColumn[] {
172
177
  ];
173
178
  }
174
179
 
180
+ export function buildMemberTradeColumns(width: number): TradeColumn[] {
181
+ const filedWidth = 7;
182
+ const txWidth = 7;
183
+ const sideWidth = 8;
184
+ const tickerWidth = 12;
185
+ const amountWidth = 14;
186
+ const ownerWidth = 8;
187
+ const lagWidth = 5;
188
+ const assetWidth = Math.max(
189
+ 18,
190
+ width - filedWidth - txWidth - sideWidth - tickerWidth - amountWidth - ownerWidth - lagWidth - 9,
191
+ );
192
+ return [
193
+ { id: "filed", label: "FILED", width: filedWidth, align: "left" },
194
+ { id: "tx", label: "TX", width: txWidth, align: "left" },
195
+ { id: "side", label: "SIDE", width: sideWidth, align: "left" },
196
+ { id: "ticker", label: "TICKER", width: tickerWidth, align: "left" },
197
+ { id: "amount", label: "AMOUNT", width: amountWidth, align: "right" },
198
+ { id: "asset", label: "ASSET", width: assetWidth, align: "left" },
199
+ { id: "owner", label: "OWNER", width: ownerWidth, align: "left" },
200
+ { id: "lag", label: "LAG", width: lagWidth, align: "right" },
201
+ ];
202
+ }
203
+
175
204
  export function buildMemberColumns(width: number): MemberColumn[] {
176
205
  const districtWidth = 6;
177
206
  const tradesWidth = 7;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
2
+ import { Box, useRendererHost } from "../../../ui";
3
3
  import {
4
4
  DataTableStackView,
5
5
  EmptyState,
@@ -17,6 +17,7 @@ import {
17
17
  import type { PaneProps } from "../../../types/plugin";
18
18
  import {
19
19
  CONGRESS_FILING_LIMIT,
20
+ CONGRESS_MEMBER_FILING_LIMIT,
20
21
  CONGRESS_TRADE_LIMIT,
21
22
  CONGRESS_TRADES_PANE_ID,
22
23
  buildMemberColumns,
@@ -34,7 +35,7 @@ import {
34
35
  type TradeColumn,
35
36
  type TradeColumnId,
36
37
  } from "./model";
37
- import { MemberTradeList, TradeDetail } from "./detail";
38
+ import { MemberTradesDetail, TradeDetail } from "./detail";
38
39
  import { useCongressTradesFooter } from "./footer";
39
40
  import { useCongressTradesKeyboard } from "./keyboard";
40
41
  import {
@@ -61,7 +62,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
61
62
  columnId: "trades",
62
63
  direction: "desc",
63
64
  });
64
- const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
65
65
  const fetchGenRef = useRef(0);
66
66
 
67
67
  const load = useCallback((refresh = false) => {
@@ -142,12 +142,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
142
142
  if (detailMode?.kind === "member" && !detailMember) setDetailMode(null);
143
143
  }, [detailMember, detailMode, detailTrade]);
144
144
 
145
- useEffect(() => {
146
- if (!detailMode) return;
147
- const scrollBox = detailScrollRef.current;
148
- if (scrollBox) scrollBox.scrollTop = 0;
149
- }, [detailMode]);
150
-
151
145
  const selectTab = useCallback((tab: string) => {
152
146
  setActiveTab(tab === "members" ? "members" : "trades");
153
147
  setDetailMode(null);
@@ -176,7 +170,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
176
170
  const { handleDetailKeyDown, handleRootKeyDown } = useCongressTradesKeyboard({
177
171
  activeTab,
178
172
  detailMode,
179
- detailScrollRef,
180
173
  focused,
181
174
  load,
182
175
  openSelectedTicker,
@@ -187,6 +180,7 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
187
180
 
188
181
  useCongressTradesFooter({
189
182
  activeTab,
183
+ detailMode,
190
184
  detailTrade,
191
185
  error,
192
186
  load,
@@ -201,11 +195,12 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
201
195
  const detailContent = detailTrade ? (
202
196
  <TradeDetail trade={detailTrade} width={width} />
203
197
  ) : detailMember ? (
204
- <MemberTradeList
198
+ <MemberTradesDetail
199
+ focused={focused}
205
200
  member={detailMember}
206
- trades={detailMemberTrades}
201
+ initialTrades={detailMemberTrades}
207
202
  width={width}
208
- scrollRef={detailScrollRef}
203
+ filingLimit={payload?.filingCount ?? CONGRESS_MEMBER_FILING_LIMIT}
209
204
  />
210
205
  ) : null;
211
206
  const detailTitle = detailTrade
@@ -12,7 +12,14 @@ import {
12
12
  type MemberColumn,
13
13
  type TradeColumn,
14
14
  } from "./model";
15
- import { sideColor } from "./detail";
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
+ }
16
23
 
17
24
  export function renderCongressTradeCell(
18
25
  trade: CloudCongressTradePayload,
@@ -51,6 +58,8 @@ export function renderCongressTradeCell(
51
58
  text: formatAmountRange(trade.amountLow, trade.amountHigh, trade.amount),
52
59
  color: selectedColor ?? colors.textBright,
53
60
  };
61
+ case "asset":
62
+ return { text: trade.assetName, color: selectedColor ?? colors.text };
54
63
  case "owner":
55
64
  return { text: trade.owner, color: selectedColor ?? colors.textDim };
56
65
  }
@@ -90,20 +90,6 @@ export function buildOverviewStats({
90
90
  valueColor: priceColor(fundamentals.revenueGrowth),
91
91
  });
92
92
  }
93
- if (fundamentals?.return1Y != null) {
94
- stats.push({
95
- label: "1Y Return",
96
- value: formatPercent(fundamentals.return1Y),
97
- valueColor: priceColor(fundamentals.return1Y),
98
- });
99
- }
100
- if (fundamentals?.return3Y != null) {
101
- stats.push({
102
- label: "3Y Return",
103
- value: formatPercent(fundamentals.return3Y),
104
- valueColor: priceColor(fundamentals.return3Y),
105
- });
106
- }
107
93
  if (fundamentals?.enterpriseValue) {
108
94
  stats.push({ label: "EV", value: formatCompact(fundamentals.enterpriseValue) });
109
95
  }
@@ -15,6 +15,11 @@ import {
15
15
  import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
16
16
  import { EmptyState } from "../../../components";
17
17
  import { ResolvedStockChart } from "../../../components/chart/stock-chart";
18
+ import { PriceReturnStrip } from "../../../components/price-performance";
19
+ import {
20
+ appendQuoteToPriceReturnHistory,
21
+ buildPriceReturnFields,
22
+ } from "../../../market-data/performance";
18
23
  import type { TickerFinancials } from "../../../types/financials";
19
24
  import type { TickerRecord } from "../../../types/ticker";
20
25
  import {
@@ -96,6 +101,19 @@ export function OverviewTab({
96
101
  baseCurrency,
97
102
  toBase,
98
103
  });
104
+ const performanceFields = buildPriceReturnFields(
105
+ appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], quote),
106
+ ).map((field) => {
107
+ if (field.value != null) return field;
108
+ if (field.id === "1Y" && fundamentals?.return1Y != null) {
109
+ return { ...field, value: fundamentals.return1Y };
110
+ }
111
+ if (field.id === "3Y" && fundamentals?.return3Y != null) {
112
+ return { ...field, value: fundamentals.return3Y };
113
+ }
114
+ return field;
115
+ });
116
+ const hasPerformance = performanceFields.some((field) => field.value != null);
99
117
  const positionRows = buildPositionRows({
100
118
  ticker,
101
119
  quote,
@@ -210,6 +228,13 @@ export function OverviewTab({
210
228
  />
211
229
  )}
212
230
 
231
+ {hasPerformance && (
232
+ <Box flexDirection="column">
233
+ <SectionHeader title="Price Return" />
234
+ <PriceReturnStrip fields={performanceFields} width={contentWidth} />
235
+ </Box>
236
+ )}
237
+
213
238
  {stats.length > 0 && (
214
239
  <Box flexDirection="column">
215
240
  <SectionHeader title="Fundamentals" />
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildApplicationMenu, buildDesktopApplicationMenu } from "./index";
3
+
4
+ describe("desktop application menu", () => {
5
+ test("hides the application menu on Windows to preserve vertical space", () => {
6
+ expect(buildDesktopApplicationMenu("win32")).toEqual([]);
7
+ });
8
+
9
+ test("keeps the native application menu on macOS", () => {
10
+ expect(buildDesktopApplicationMenu("darwin")).toEqual(buildApplicationMenu());
11
+ });
12
+ });
@@ -121,3 +121,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
121
121
  },
122
122
  ];
123
123
  }
124
+
125
+ export function buildDesktopApplicationMenu(platform = process.platform): ApplicationMenuItemConfig[] {
126
+ return platform === "win32" ? [] : buildApplicationMenu();
127
+ }