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.
- package/package.json +1 -1
- package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
- package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
- package/src/components/layout/detached-pane-shell.tsx +5 -2
- package/src/components/layout/header.tsx +13 -5
- package/src/components/layout/window-controls.tsx +19 -10
- package/src/components/price-performance.tsx +175 -0
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +9 -2
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- 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
|
|
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("
|
|
347
|
-
expect(frame).toContain("
|
|
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).
|
|
357
|
-
expect(frame).
|
|
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).
|
|
410
|
-
expect(frame).
|
|
488
|
+
expect(frame).toMatch(/> AAPL\s+\+47\.06%/);
|
|
489
|
+
expect(frame).toMatch(/MSFT\s+\+23\.76%/);
|
|
411
490
|
});
|
|
412
491
|
|
|
413
|
-
test("updates comparison
|
|
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).
|
|
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
|
|
2
|
-
import { Box, ScrollBox, Text, TextAttributes,
|
|
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
|
-
|
|
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
|
|
99
|
+
export function MemberTradesDetail({
|
|
90
100
|
member,
|
|
91
|
-
|
|
101
|
+
initialTrades,
|
|
92
102
|
width,
|
|
93
|
-
|
|
103
|
+
focused,
|
|
104
|
+
filingLimit,
|
|
94
105
|
}: {
|
|
95
106
|
member: CloudCongressMemberPayload;
|
|
96
|
-
|
|
107
|
+
initialTrades: CloudCongressTradePayload[];
|
|
97
108
|
width: number;
|
|
98
|
-
|
|
109
|
+
focused: boolean;
|
|
110
|
+
filingLimit: number;
|
|
99
111
|
}) {
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,
|