gloomberb 0.3.7 → 0.4.0
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/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { AppConfig } from "../../../types/config";
|
|
2
|
+
import type { Portfolio, TickerPosition, TickerRecord } from "../../../types/ticker";
|
|
3
|
+
import { slugifyName } from "../../../utils/slugify";
|
|
4
|
+
|
|
5
|
+
export interface DeleteManualPortfolioResult {
|
|
6
|
+
config: AppConfig;
|
|
7
|
+
portfolio: Portfolio;
|
|
8
|
+
tickers: TickerRecord[];
|
|
9
|
+
cleanedTickerCount: number;
|
|
10
|
+
removedPositionCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RemoveTickerFromPortfolioResult {
|
|
14
|
+
changed: boolean;
|
|
15
|
+
ticker: TickerRecord;
|
|
16
|
+
removedPositionCount: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SetManualPortfolioPositionInput {
|
|
20
|
+
shares: number;
|
|
21
|
+
avgCost: number;
|
|
22
|
+
currency: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SetManualPortfolioPositionResult {
|
|
26
|
+
ticker: TickerRecord;
|
|
27
|
+
addedMembership: boolean;
|
|
28
|
+
replacedPositionCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clonePortfolio(portfolio: Portfolio): Portfolio {
|
|
32
|
+
return { ...portfolio };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizePortfolioName(rawName: string): string {
|
|
36
|
+
return rawName.trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replaceTickerMetadata(ticker: TickerRecord, metadata: TickerRecord["metadata"]): TickerRecord {
|
|
40
|
+
return {
|
|
41
|
+
...ticker,
|
|
42
|
+
metadata,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isManualPortfolio(portfolio: Portfolio): boolean {
|
|
47
|
+
return !portfolio.brokerId && !portfolio.brokerInstanceId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function findPortfolio(config: AppConfig, rawName: string): Portfolio | null {
|
|
51
|
+
const normalized = normalizePortfolioName(rawName);
|
|
52
|
+
if (!normalized) return null;
|
|
53
|
+
return config.portfolios.find((portfolio) =>
|
|
54
|
+
portfolio.id.toLowerCase() === normalized || portfolio.name.toLowerCase() === normalized
|
|
55
|
+
) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getManualPortfolioPosition(ticker: TickerRecord, portfolioId: string): TickerPosition | null {
|
|
59
|
+
return ticker.metadata.positions.find((position) =>
|
|
60
|
+
position.portfolio === portfolioId && position.broker === "manual"
|
|
61
|
+
) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveManualPositionCurrency(
|
|
65
|
+
rawCurrency: string | undefined,
|
|
66
|
+
ticker: TickerRecord,
|
|
67
|
+
portfolio: Portfolio,
|
|
68
|
+
baseCurrency: string | undefined,
|
|
69
|
+
): string {
|
|
70
|
+
return (rawCurrency?.trim() || ticker.metadata.currency || portfolio.currency || baseCurrency || "USD").toUpperCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createManualPortfolio(
|
|
74
|
+
config: AppConfig,
|
|
75
|
+
name: string,
|
|
76
|
+
baseCurrency: string,
|
|
77
|
+
): { config: AppConfig; portfolio: Portfolio } {
|
|
78
|
+
const trimmedName = name.trim();
|
|
79
|
+
if (!trimmedName) {
|
|
80
|
+
throw new Error("Portfolio name is required.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const id = slugifyName(trimmedName, "portfolio");
|
|
84
|
+
const normalizedName = trimmedName.toLowerCase();
|
|
85
|
+
const duplicate = config.portfolios.some((portfolio) =>
|
|
86
|
+
portfolio.id.toLowerCase() === id || portfolio.name.toLowerCase() === normalizedName
|
|
87
|
+
);
|
|
88
|
+
if (duplicate) {
|
|
89
|
+
throw new Error(`Portfolio "${trimmedName}" already exists.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const portfolio: Portfolio = {
|
|
93
|
+
id,
|
|
94
|
+
name: trimmedName,
|
|
95
|
+
currency: (baseCurrency || config.baseCurrency || "USD").toUpperCase(),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
config: {
|
|
100
|
+
...config,
|
|
101
|
+
portfolios: [...config.portfolios, portfolio],
|
|
102
|
+
},
|
|
103
|
+
portfolio,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function addTickerToPortfolio(
|
|
108
|
+
ticker: TickerRecord,
|
|
109
|
+
portfolioId: string,
|
|
110
|
+
): { changed: boolean; ticker: TickerRecord } {
|
|
111
|
+
if (ticker.metadata.portfolios.includes(portfolioId)) {
|
|
112
|
+
return { changed: false, ticker };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
changed: true,
|
|
117
|
+
ticker: replaceTickerMetadata(ticker, {
|
|
118
|
+
...ticker.metadata,
|
|
119
|
+
portfolios: [...ticker.metadata.portfolios, portfolioId],
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function removeTickerFromPortfolio(
|
|
125
|
+
ticker: TickerRecord,
|
|
126
|
+
portfolioId: string,
|
|
127
|
+
): RemoveTickerFromPortfolioResult {
|
|
128
|
+
const nextPortfolios = ticker.metadata.portfolios.filter((entry) => entry !== portfolioId);
|
|
129
|
+
const removedPositionCount = ticker.metadata.positions.filter((position) => position.portfolio === portfolioId).length;
|
|
130
|
+
const nextPositions = ticker.metadata.positions.filter((position) => position.portfolio !== portfolioId);
|
|
131
|
+
const changed =
|
|
132
|
+
nextPortfolios.length !== ticker.metadata.portfolios.length
|
|
133
|
+
|| removedPositionCount > 0;
|
|
134
|
+
|
|
135
|
+
if (!changed) {
|
|
136
|
+
return { changed: false, ticker, removedPositionCount: 0 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
changed: true,
|
|
141
|
+
removedPositionCount,
|
|
142
|
+
ticker: replaceTickerMetadata(ticker, {
|
|
143
|
+
...ticker.metadata,
|
|
144
|
+
portfolios: nextPortfolios,
|
|
145
|
+
positions: nextPositions,
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function setManualPortfolioPosition(
|
|
151
|
+
ticker: TickerRecord,
|
|
152
|
+
portfolioId: string,
|
|
153
|
+
input: SetManualPortfolioPositionInput,
|
|
154
|
+
): SetManualPortfolioPositionResult {
|
|
155
|
+
const nextCurrency = input.currency.trim().toUpperCase();
|
|
156
|
+
if (!Number.isFinite(input.shares) || input.shares <= 0) {
|
|
157
|
+
throw new Error("Shares must be greater than 0.");
|
|
158
|
+
}
|
|
159
|
+
if (!Number.isFinite(input.avgCost)) {
|
|
160
|
+
throw new Error("Average cost must be a valid number.");
|
|
161
|
+
}
|
|
162
|
+
if (!nextCurrency) {
|
|
163
|
+
throw new Error("Position currency is required.");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const replacedPositionCount = ticker.metadata.positions.filter((position) => position.portfolio === portfolioId).length;
|
|
167
|
+
const nextPositions = ticker.metadata.positions.filter((position) => position.portfolio !== portfolioId);
|
|
168
|
+
const addedMembership = !ticker.metadata.portfolios.includes(portfolioId);
|
|
169
|
+
|
|
170
|
+
nextPositions.push({
|
|
171
|
+
portfolio: portfolioId,
|
|
172
|
+
shares: input.shares,
|
|
173
|
+
avgCost: input.avgCost,
|
|
174
|
+
currency: nextCurrency,
|
|
175
|
+
broker: "manual",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
addedMembership,
|
|
180
|
+
replacedPositionCount,
|
|
181
|
+
ticker: replaceTickerMetadata(ticker, {
|
|
182
|
+
...ticker.metadata,
|
|
183
|
+
portfolios: addedMembership ? [...ticker.metadata.portfolios, portfolioId] : ticker.metadata.portfolios,
|
|
184
|
+
positions: nextPositions,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function deleteManualPortfolio(
|
|
190
|
+
config: AppConfig,
|
|
191
|
+
tickers: TickerRecord[],
|
|
192
|
+
portfolioId: string,
|
|
193
|
+
): DeleteManualPortfolioResult {
|
|
194
|
+
const portfolio = config.portfolios.find((entry) => entry.id === portfolioId);
|
|
195
|
+
if (!portfolio) {
|
|
196
|
+
throw new Error("Portfolio not found.");
|
|
197
|
+
}
|
|
198
|
+
if (!isManualPortfolio(portfolio)) {
|
|
199
|
+
throw new Error(`Portfolio "${portfolio.name}" is broker-managed and cannot be modified manually.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const nextConfig: AppConfig = {
|
|
203
|
+
...config,
|
|
204
|
+
portfolios: config.portfolios
|
|
205
|
+
.filter((entry) => entry.id !== portfolioId)
|
|
206
|
+
.map(clonePortfolio),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const changedTickers: TickerRecord[] = [];
|
|
210
|
+
let removedPositionCount = 0;
|
|
211
|
+
|
|
212
|
+
for (const ticker of tickers) {
|
|
213
|
+
const result = removeTickerFromPortfolio(ticker, portfolioId);
|
|
214
|
+
if (!result.changed) continue;
|
|
215
|
+
changedTickers.push(result.ticker);
|
|
216
|
+
removedPositionCount += result.removedPositionCount;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
config: nextConfig,
|
|
221
|
+
portfolio: clonePortfolio(portfolio),
|
|
222
|
+
tickers: changedTickers,
|
|
223
|
+
cleanedTickerCount: changedTickers.length,
|
|
224
|
+
removedPositionCount,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
resolveVisibleColumns,
|
|
40
40
|
} from "./settings";
|
|
41
41
|
import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
|
|
42
|
+
import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
|
|
42
43
|
|
|
43
44
|
const VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS = 5 * 60_000;
|
|
44
45
|
|
|
@@ -52,7 +53,6 @@ function needsVisibleFinancialWarmup(ticker: TickerRecord, financials: TickerFin
|
|
|
52
53
|
function selectStreamTickers(
|
|
53
54
|
tickers: TickerRecord[],
|
|
54
55
|
_visibleRange: { start: number; end: number },
|
|
55
|
-
_cursorSymbol: string | null,
|
|
56
56
|
) {
|
|
57
57
|
return tickers;
|
|
58
58
|
}
|
|
@@ -129,7 +129,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
129
129
|
const paneCollection = usePaneCollection();
|
|
130
130
|
|
|
131
131
|
const [currentCollectionId, setCurrentCollectionId] = usePaneStateValue<string>("collectionId", paneCollection.collectionId ?? "");
|
|
132
|
-
const [
|
|
132
|
+
const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
|
|
133
133
|
const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
|
|
134
134
|
const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
|
|
135
135
|
|
|
@@ -144,6 +144,12 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
144
144
|
const warmupAttemptRef = useRef(new Map<string, number>());
|
|
145
145
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
146
146
|
const headerScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
147
|
+
const {
|
|
148
|
+
cursorSymbol,
|
|
149
|
+
setCursorSymbol,
|
|
150
|
+
flushCursorSymbol,
|
|
151
|
+
cancelPendingCursorSymbol,
|
|
152
|
+
} = useThrottledCursorSymbol(committedCursorSymbol, setCommittedCursorSymbol);
|
|
147
153
|
|
|
148
154
|
const paneSettings = useMemo(
|
|
149
155
|
() => getPortfolioPaneSettings(paneInstance?.settings),
|
|
@@ -159,11 +165,16 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
159
165
|
);
|
|
160
166
|
const activeCollectionId = resolveActiveCollectionId(currentCollectionId, visibleCollections, paneSettings);
|
|
161
167
|
const isPortfolioTab = getCollectionType(state, activeCollectionId) === "portfolio";
|
|
162
|
-
const currentPortfolio =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
const currentPortfolio = useMemo(() => (
|
|
169
|
+
isPortfolioTab
|
|
170
|
+
? state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
|
|
171
|
+
: null
|
|
172
|
+
), [activeCollectionId, isPortfolioTab, state.config.portfolios]);
|
|
173
|
+
|
|
174
|
+
const tickers = useMemo(
|
|
175
|
+
() => getCollectionTickers(state, activeCollectionId),
|
|
176
|
+
[activeCollectionId, state.config.portfolios, state.config.watchlists, state.tickers],
|
|
177
|
+
);
|
|
167
178
|
const marketFinancialsMap = useTickerFinancialsMap(tickers);
|
|
168
179
|
const sharedCoordinator = getSharedMarketDataCoordinator();
|
|
169
180
|
const financialsMap = useMemo(() => {
|
|
@@ -240,6 +251,11 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
240
251
|
updateStreamWindow();
|
|
241
252
|
}, [syncHeaderScroll, updateStreamWindow]);
|
|
242
253
|
|
|
254
|
+
const handleCollectionSelect = useCallback((collectionId: string) => {
|
|
255
|
+
cancelPendingCursorSymbol();
|
|
256
|
+
setCurrentCollectionId(collectionId);
|
|
257
|
+
}, [cancelPendingCursorSymbol, setCurrentCollectionId]);
|
|
258
|
+
|
|
243
259
|
const setSortPreference = useCallback((preference: CollectionSortPreference) => {
|
|
244
260
|
if (!activeCollectionId) return;
|
|
245
261
|
setCollectionSorts({
|
|
@@ -293,18 +309,20 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
293
309
|
|
|
294
310
|
if (!paneSettings.hideTabs && (key === "h" || key === "left")) {
|
|
295
311
|
const previousCollection = visibleCollections[Math.max(currentTabIdx - 1, 0)];
|
|
296
|
-
if (previousCollection)
|
|
312
|
+
if (previousCollection) handleCollectionSelect(previousCollection.id);
|
|
297
313
|
return;
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
if (!paneSettings.hideTabs && (key === "l" || key === "right")) {
|
|
301
317
|
const nextCollection = visibleCollections[Math.min(currentTabIdx + 1, visibleCollections.length - 1)];
|
|
302
|
-
if (nextCollection)
|
|
318
|
+
if (nextCollection) handleCollectionSelect(nextCollection.id);
|
|
303
319
|
return;
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
if (!isEnter) return;
|
|
307
323
|
|
|
324
|
+
flushCursorSymbol(cursorSymbol);
|
|
325
|
+
|
|
308
326
|
const follower = state.config.layout.instances.find((instance) =>
|
|
309
327
|
instance.paneId === "ticker-detail"
|
|
310
328
|
&& instance.binding?.kind === "follow"
|
|
@@ -325,7 +343,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
325
343
|
safeSelectedIdx,
|
|
326
344
|
setCashDrawerExpanded,
|
|
327
345
|
setCursorSymbol,
|
|
328
|
-
|
|
346
|
+
flushCursorSymbol,
|
|
347
|
+
handleCollectionSelect,
|
|
329
348
|
showCashDrawer,
|
|
330
349
|
sortedTickers,
|
|
331
350
|
state.config.layout.instances,
|
|
@@ -336,9 +355,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
336
355
|
|
|
337
356
|
useEffect(() => {
|
|
338
357
|
if (activeCollectionId !== currentCollectionId) {
|
|
358
|
+
cancelPendingCursorSymbol();
|
|
339
359
|
setCurrentCollectionId(activeCollectionId);
|
|
340
360
|
}
|
|
341
|
-
}, [activeCollectionId, currentCollectionId, setCurrentCollectionId]);
|
|
361
|
+
}, [activeCollectionId, cancelPendingCursorSymbol, currentCollectionId, setCurrentCollectionId]);
|
|
342
362
|
|
|
343
363
|
useEffect(() => {
|
|
344
364
|
return () => {
|
|
@@ -402,19 +422,19 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
402
422
|
|
|
403
423
|
useEffect(() => {
|
|
404
424
|
if (sortedTickers.length === 0) {
|
|
405
|
-
if (cursorSymbol !== null) setCursorSymbol(null);
|
|
425
|
+
if (cursorSymbol !== null) setCursorSymbol(null, { immediate: true });
|
|
406
426
|
return;
|
|
407
427
|
}
|
|
408
428
|
|
|
409
429
|
const hasSelection = cursorSymbol && sortedTickers.some((ticker) => ticker.metadata.ticker === cursorSymbol);
|
|
410
430
|
if (!hasSelection) {
|
|
411
|
-
setCursorSymbol(sortedTickers[0]!.metadata.ticker);
|
|
431
|
+
setCursorSymbol(sortedTickers[0]!.metadata.ticker, { immediate: true });
|
|
412
432
|
}
|
|
413
433
|
}, [cursorSymbol, setCursorSymbol, sortedTickers]);
|
|
414
434
|
|
|
415
435
|
const streamTickers = useMemo(
|
|
416
|
-
() => selectStreamTickers(sortedTickers, streamWindow
|
|
417
|
-
[
|
|
436
|
+
() => selectStreamTickers(sortedTickers, streamWindow),
|
|
437
|
+
[sortedTickers, streamWindow],
|
|
418
438
|
);
|
|
419
439
|
const streamTargets = useMemo(() => (
|
|
420
440
|
streamTickers
|
|
@@ -477,7 +497,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
477
497
|
<TabBar
|
|
478
498
|
tabs={visibleCollections.map((collection) => ({ label: collection.name, value: collection.id }))}
|
|
479
499
|
activeValue={activeCollectionId}
|
|
480
|
-
onSelect={
|
|
500
|
+
onSelect={handleCollectionSelect}
|
|
481
501
|
compact
|
|
482
502
|
/>
|
|
483
503
|
</box>
|
|
@@ -488,7 +508,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
488
508
|
<PortfolioSummaryBar
|
|
489
509
|
tickers={sortedTickers}
|
|
490
510
|
financialsMap={financialsMap}
|
|
491
|
-
|
|
511
|
+
baseCurrency={state.config.baseCurrency}
|
|
512
|
+
exchangeRates={effectiveExchangeRates}
|
|
513
|
+
refreshingCount={state.refreshing.size}
|
|
492
514
|
isPortfolio={isPortfolioTab}
|
|
493
515
|
collectionId={activeCollectionId}
|
|
494
516
|
width={Math.max(0, width)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useCallback, type RefObject } from "react";
|
|
2
2
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
3
|
import { TickerListTable, type QuoteFlashDirection } from "../../../components/ticker-list-table";
|
|
4
4
|
import type { ColumnConfig } from "../../../types/config";
|
|
@@ -6,6 +6,8 @@ import type { TickerFinancials } from "../../../types/financials";
|
|
|
6
6
|
import type { TickerRecord } from "../../../types/ticker";
|
|
7
7
|
import { getColumnValue, type ColumnContext } from "./metrics";
|
|
8
8
|
|
|
9
|
+
export type { QuoteFlashDirection };
|
|
10
|
+
|
|
9
11
|
export function PortfolioTickerTable({
|
|
10
12
|
columns,
|
|
11
13
|
sortColumnId,
|
|
@@ -41,6 +43,13 @@ export function PortfolioTickerTable({
|
|
|
41
43
|
columnContext: ColumnContext;
|
|
42
44
|
flashSymbols: Map<string, QuoteFlashDirection>;
|
|
43
45
|
}) {
|
|
46
|
+
const resolveCell = useCallback(
|
|
47
|
+
(column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => (
|
|
48
|
+
getColumnValue(column, ticker, financials, columnContext)
|
|
49
|
+
),
|
|
50
|
+
[columnContext],
|
|
51
|
+
);
|
|
52
|
+
|
|
44
53
|
return (
|
|
45
54
|
<TickerListTable
|
|
46
55
|
columns={columns}
|
|
@@ -49,7 +58,7 @@ export function PortfolioTickerTable({
|
|
|
49
58
|
hoveredIdx={hoveredIdx}
|
|
50
59
|
setHoveredIdx={setHoveredIdx}
|
|
51
60
|
setCursorSymbol={setCursorSymbol}
|
|
52
|
-
resolveCell={
|
|
61
|
+
resolveCell={resolveCell}
|
|
53
62
|
financialsMap={financialsMap}
|
|
54
63
|
headerScrollRef={headerScrollRef}
|
|
55
64
|
scrollRef={scrollRef}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
|
|
5
|
+
|
|
6
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
7
|
+
let setHarnessCursorSymbol: ((symbol: string | null, options?: { immediate?: boolean }) => void) | null = null;
|
|
8
|
+
let flushHarnessCursorSymbol: ((symbol?: string | null) => void) | null = null;
|
|
9
|
+
let latestCursorSymbol: string | null = null;
|
|
10
|
+
let latestCommittedCursorSymbol: string | null = null;
|
|
11
|
+
|
|
12
|
+
const TEST_THROTTLE_MS = 80;
|
|
13
|
+
|
|
14
|
+
function ThrottledCursorHarness() {
|
|
15
|
+
const [committedCursorSymbol, setCommittedCursorSymbol] = useState<string | null>("AAPL");
|
|
16
|
+
const {
|
|
17
|
+
cursorSymbol,
|
|
18
|
+
setCursorSymbol,
|
|
19
|
+
flushCursorSymbol,
|
|
20
|
+
} = useThrottledCursorSymbol(committedCursorSymbol, setCommittedCursorSymbol, TEST_THROTTLE_MS);
|
|
21
|
+
|
|
22
|
+
latestCursorSymbol = cursorSymbol;
|
|
23
|
+
latestCommittedCursorSymbol = committedCursorSymbol;
|
|
24
|
+
setHarnessCursorSymbol = setCursorSymbol;
|
|
25
|
+
flushHarnessCursorSymbol = flushCursorSymbol;
|
|
26
|
+
|
|
27
|
+
return <text>{cursorSymbol ?? "none"}|{committedCursorSymbol ?? "none"}</text>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
if (testSetup) {
|
|
32
|
+
await act(async () => {
|
|
33
|
+
testSetup!.renderer.destroy();
|
|
34
|
+
});
|
|
35
|
+
testSetup = undefined;
|
|
36
|
+
}
|
|
37
|
+
setHarnessCursorSymbol = null;
|
|
38
|
+
flushHarnessCursorSymbol = null;
|
|
39
|
+
latestCursorSymbol = null;
|
|
40
|
+
latestCommittedCursorSymbol = null;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("useThrottledCursorSymbol", () => {
|
|
44
|
+
test("keeps the highlighted row immediate while settling the committed cursor", async () => {
|
|
45
|
+
testSetup = await testRender(<ThrottledCursorHarness />, {
|
|
46
|
+
width: 24,
|
|
47
|
+
height: 1,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await act(async () => {
|
|
51
|
+
await testSetup!.renderOnce();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await act(async () => {
|
|
55
|
+
setHarnessCursorSymbol?.("MSFT");
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
});
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await testSetup!.renderOnce();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(latestCursorSymbol).toBe("MSFT");
|
|
63
|
+
expect(latestCommittedCursorSymbol).toBe("AAPL");
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
setHarnessCursorSymbol?.("NVDA");
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
});
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await testSetup!.renderOnce();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(latestCursorSymbol).toBe("NVDA");
|
|
74
|
+
expect(latestCommittedCursorSymbol).toBe("AAPL");
|
|
75
|
+
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_THROTTLE_MS + 20));
|
|
78
|
+
});
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await testSetup!.renderOnce();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(latestCommittedCursorSymbol).toBe("NVDA");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("coalesces repeated cursor moves into the final committed symbol", async () => {
|
|
87
|
+
testSetup = await testRender(<ThrottledCursorHarness />, {
|
|
88
|
+
width: 24,
|
|
89
|
+
height: 1,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
await testSetup!.renderOnce();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
setHarnessCursorSymbol?.("MSFT");
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
});
|
|
100
|
+
await act(async () => {
|
|
101
|
+
setHarnessCursorSymbol?.("NVDA");
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
});
|
|
104
|
+
await act(async () => {
|
|
105
|
+
setHarnessCursorSymbol?.("AMD");
|
|
106
|
+
await Promise.resolve();
|
|
107
|
+
});
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await testSetup!.renderOnce();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(latestCursorSymbol).toBe("AMD");
|
|
113
|
+
expect(latestCommittedCursorSymbol).toBe("AAPL");
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_THROTTLE_MS + 20));
|
|
117
|
+
});
|
|
118
|
+
await act(async () => {
|
|
119
|
+
await testSetup!.renderOnce();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(latestCommittedCursorSymbol).toBe("AMD");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("can flush a pending cursor immediately", async () => {
|
|
126
|
+
testSetup = await testRender(<ThrottledCursorHarness />, {
|
|
127
|
+
width: 24,
|
|
128
|
+
height: 1,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await testSetup!.renderOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await act(async () => {
|
|
136
|
+
setHarnessCursorSymbol?.("MSFT");
|
|
137
|
+
await Promise.resolve();
|
|
138
|
+
});
|
|
139
|
+
await act(async () => {
|
|
140
|
+
await testSetup!.renderOnce();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
setHarnessCursorSymbol?.("NVDA");
|
|
145
|
+
await Promise.resolve();
|
|
146
|
+
});
|
|
147
|
+
await act(async () => {
|
|
148
|
+
await testSetup!.renderOnce();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(latestCommittedCursorSymbol).toBe("AAPL");
|
|
152
|
+
|
|
153
|
+
await act(async () => {
|
|
154
|
+
flushHarnessCursorSymbol?.("NVDA");
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
});
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await testSetup!.renderOnce();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(latestCommittedCursorSymbol).toBe("NVDA");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export const FOLLOW_CURSOR_THROTTLE_MS = 80;
|
|
4
|
+
|
|
5
|
+
export function useThrottledCursorSymbol(
|
|
6
|
+
committedCursorSymbol: string | null,
|
|
7
|
+
setCommittedCursorSymbol: (symbol: string | null) => void,
|
|
8
|
+
throttleMs = FOLLOW_CURSOR_THROTTLE_MS,
|
|
9
|
+
) {
|
|
10
|
+
const [cursorSymbol, setCursorSymbolState] = useState<string | null>(committedCursorSymbol);
|
|
11
|
+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
12
|
+
const hasPendingCommitRef = useRef(false);
|
|
13
|
+
const pendingCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
|
|
14
|
+
const appliedCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
|
|
15
|
+
|
|
16
|
+
const clearPendingCursorCommit = useCallback(() => {
|
|
17
|
+
if (commitTimerRef.current) {
|
|
18
|
+
clearTimeout(commitTimerRef.current);
|
|
19
|
+
commitTimerRef.current = null;
|
|
20
|
+
}
|
|
21
|
+
hasPendingCommitRef.current = false;
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const commitCursorSymbol = useCallback((nextCursorSymbol?: string | null) => {
|
|
25
|
+
const targetCursorSymbol = nextCursorSymbol ?? pendingCursorSymbolRef.current;
|
|
26
|
+
clearPendingCursorCommit();
|
|
27
|
+
pendingCursorSymbolRef.current = targetCursorSymbol;
|
|
28
|
+
|
|
29
|
+
if (Object.is(appliedCursorSymbolRef.current, targetCursorSymbol)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
appliedCursorSymbolRef.current = targetCursorSymbol;
|
|
34
|
+
setCommittedCursorSymbol(targetCursorSymbol);
|
|
35
|
+
}, [clearPendingCursorCommit, setCommittedCursorSymbol]);
|
|
36
|
+
|
|
37
|
+
const scheduleCursorSymbol = useCallback((nextCursorSymbol: string | null, options?: { immediate?: boolean }) => {
|
|
38
|
+
setCursorSymbolState((current) => (Object.is(current, nextCursorSymbol) ? current : nextCursorSymbol));
|
|
39
|
+
pendingCursorSymbolRef.current = nextCursorSymbol;
|
|
40
|
+
|
|
41
|
+
if (options?.immediate) {
|
|
42
|
+
commitCursorSymbol(nextCursorSymbol);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearPendingCursorCommit();
|
|
47
|
+
|
|
48
|
+
if (Object.is(appliedCursorSymbolRef.current, nextCursorSymbol)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
hasPendingCommitRef.current = true;
|
|
53
|
+
commitTimerRef.current = setTimeout(() => {
|
|
54
|
+
commitCursorSymbol();
|
|
55
|
+
}, throttleMs);
|
|
56
|
+
}, [clearPendingCursorCommit, commitCursorSymbol, throttleMs]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (hasPendingCommitRef.current && Object.is(committedCursorSymbol, pendingCursorSymbolRef.current)) {
|
|
60
|
+
clearPendingCursorCommit();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!hasPendingCommitRef.current) {
|
|
64
|
+
appliedCursorSymbolRef.current = committedCursorSymbol;
|
|
65
|
+
setCursorSymbolState((current) => (Object.is(current, committedCursorSymbol) ? current : committedCursorSymbol));
|
|
66
|
+
}
|
|
67
|
+
}, [clearPendingCursorCommit, committedCursorSymbol]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => () => {
|
|
70
|
+
if (commitTimerRef.current) {
|
|
71
|
+
clearTimeout(commitTimerRef.current);
|
|
72
|
+
}
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
cursorSymbol,
|
|
77
|
+
setCursorSymbol: scheduleCursorSymbol,
|
|
78
|
+
flushCursorSymbol: commitCursorSymbol,
|
|
79
|
+
cancelPendingCursorSymbol: clearPendingCursorCommit,
|
|
80
|
+
};
|
|
81
|
+
}
|