gloomberb 0.7.2 → 0.7.4
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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Box, Input, Text, type InputRenderable } from "../../../ui";
|
|
3
|
+
import { useShortcut } from "../../../react/input";
|
|
4
|
+
import { useAppDispatch, useAppSelector } from "../../../state/app-context";
|
|
5
|
+
import { getSharedRegistry } from "../../registry";
|
|
6
|
+
import { usePluginAppActions } from "../../plugin-runtime";
|
|
7
|
+
import { colors, priceColor } from "../../../theme/colors";
|
|
8
|
+
import { formatPercentRaw } from "../../../utils/format";
|
|
9
|
+
import { formatMarketPrice } from "../../../utils/market-format";
|
|
10
|
+
import { resolveTickerSearch, upsertTickerFromSearchResult, type ResolvedTickerSearch } from "../../../utils/ticker-search";
|
|
11
|
+
import type { Quote } from "../../../types/financials";
|
|
12
|
+
import type { TickerRecord } from "../../../types/ticker";
|
|
13
|
+
import { addTickerToPortfolio, addTickerToWatchlist } from "./mutations";
|
|
14
|
+
|
|
15
|
+
const QUICK_ADD_DEBOUNCE_MS = 300;
|
|
16
|
+
const QUICK_ADD_MAX_QUERY_LENGTH = 32;
|
|
17
|
+
const QUICK_ADD_SYMBOL_RE = /^[A-Z0-9][A-Z0-9.\-\s]*$/;
|
|
18
|
+
|
|
19
|
+
export type QuickAddCollectionKind = "portfolio" | "watchlist";
|
|
20
|
+
|
|
21
|
+
interface ResolvedQuickAdd {
|
|
22
|
+
query: string;
|
|
23
|
+
symbol: string;
|
|
24
|
+
resolved: ResolvedTickerSearch;
|
|
25
|
+
ticker: TickerRecord | null;
|
|
26
|
+
quote: Quote | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type QuickAddValidation =
|
|
30
|
+
| { status: "idle"; query: "" }
|
|
31
|
+
| { status: "checking"; query: string }
|
|
32
|
+
| (ResolvedQuickAdd & { status: "ready" | "duplicate" })
|
|
33
|
+
| { status: "missing" | "error"; query: string; message: string };
|
|
34
|
+
|
|
35
|
+
const IDLE_VALIDATION: QuickAddValidation = { status: "idle", query: "" };
|
|
36
|
+
|
|
37
|
+
function normalizeQuickAddQuery(value: string): string {
|
|
38
|
+
return value.replace(/^\s*\$/, "").trim().toUpperCase().replace(/\s+/g, " ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPlausibleTickerQuery(query: string): boolean {
|
|
42
|
+
return query.length > 0
|
|
43
|
+
&& query.length <= QUICK_ADD_MAX_QUERY_LENGTH
|
|
44
|
+
&& QUICK_ADD_SYMBOL_RE.test(query);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tickerBelongsToCollection(
|
|
48
|
+
ticker: TickerRecord | null,
|
|
49
|
+
collectionKind: QuickAddCollectionKind,
|
|
50
|
+
collectionId: string,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (!ticker) return false;
|
|
53
|
+
return collectionKind === "portfolio"
|
|
54
|
+
? ticker.metadata.portfolios.includes(collectionId)
|
|
55
|
+
: ticker.metadata.watchlists.includes(collectionId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function quoteContextFromResolved(resolved: ResolvedTickerSearch) {
|
|
59
|
+
const instrument = resolved.kind === "provider"
|
|
60
|
+
? resolved.result.brokerContract
|
|
61
|
+
: resolved.ticker.metadata.broker_contracts?.[0];
|
|
62
|
+
return instrument
|
|
63
|
+
? {
|
|
64
|
+
brokerId: instrument.brokerId,
|
|
65
|
+
brokerInstanceId: instrument.brokerInstanceId,
|
|
66
|
+
instrument,
|
|
67
|
+
}
|
|
68
|
+
: undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function exchangeFromResolved(resolved: ResolvedTickerSearch): string | undefined {
|
|
72
|
+
return resolved.kind === "provider"
|
|
73
|
+
? resolved.result.exchange
|
|
74
|
+
: resolved.ticker.metadata.exchange;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tickerNameFromValidation(validation: Extract<QuickAddValidation, { status: "ready" | "duplicate" }>): string {
|
|
78
|
+
if (validation.ticker?.metadata.name) return validation.ticker.metadata.name;
|
|
79
|
+
return validation.resolved.kind === "provider" ? validation.resolved.result.name : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function QuickAddPreview({
|
|
83
|
+
validation,
|
|
84
|
+
collectionKind,
|
|
85
|
+
submitting,
|
|
86
|
+
}: {
|
|
87
|
+
validation: QuickAddValidation;
|
|
88
|
+
collectionKind: QuickAddCollectionKind;
|
|
89
|
+
submitting: boolean;
|
|
90
|
+
}) {
|
|
91
|
+
if (submitting) {
|
|
92
|
+
return <Text fg={colors.textDim}>adding...</Text>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (validation.status === "idle") {
|
|
96
|
+
return <Text fg={colors.textMuted}> </Text>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (validation.status === "checking") {
|
|
100
|
+
return <Text fg={colors.textDim}>{`${validation.query} checking...`}</Text>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (validation.status === "missing" || validation.status === "error") {
|
|
104
|
+
return <Text fg={colors.textMuted}>{validation.message}</Text>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (validation.status === "duplicate") {
|
|
108
|
+
return <Text fg={colors.textMuted}>{`${validation.symbol} already in ${collectionKind}`}</Text>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const quote = validation.quote;
|
|
112
|
+
const assetCategory = validation.ticker?.metadata.assetCategory
|
|
113
|
+
?? (validation.resolved.kind === "provider" ? validation.resolved.result.type : undefined);
|
|
114
|
+
const priceText = quote?.price != null
|
|
115
|
+
? formatMarketPrice(quote.price, { assetCategory, maxWidth: 12 })
|
|
116
|
+
: "-";
|
|
117
|
+
const changeValue = quote?.changePercent;
|
|
118
|
+
const name = tickerNameFromValidation(validation);
|
|
119
|
+
const showSymbol = normalizeQuickAddQuery(validation.symbol) !== validation.query;
|
|
120
|
+
const symbolPrefix = showSymbol ? `${validation.symbol} ` : "";
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Box flexDirection="row" overflow="hidden">
|
|
124
|
+
<Text fg={colors.textDim}>{`${symbolPrefix}${priceText} `}</Text>
|
|
125
|
+
<Text fg={changeValue == null ? colors.textDim : priceColor(changeValue)}>
|
|
126
|
+
{formatPercentRaw(changeValue)}
|
|
127
|
+
</Text>
|
|
128
|
+
{name ? <Text fg={colors.textMuted}>{` ${name}`}</Text> : null}
|
|
129
|
+
</Box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function QuickAddTickerInput({
|
|
134
|
+
collectionId,
|
|
135
|
+
collectionKind,
|
|
136
|
+
collectionName,
|
|
137
|
+
focused,
|
|
138
|
+
width,
|
|
139
|
+
onAdded,
|
|
140
|
+
onFocusChange,
|
|
141
|
+
}: {
|
|
142
|
+
collectionId: string;
|
|
143
|
+
collectionKind: QuickAddCollectionKind;
|
|
144
|
+
collectionName: string;
|
|
145
|
+
focused: boolean;
|
|
146
|
+
width: number;
|
|
147
|
+
onAdded: (symbol: string) => void;
|
|
148
|
+
onFocusChange?: (focused: boolean) => void;
|
|
149
|
+
}) {
|
|
150
|
+
const dispatch = useAppDispatch();
|
|
151
|
+
const { notify } = usePluginAppActions();
|
|
152
|
+
const tickers = useAppSelector((state) => state.tickers);
|
|
153
|
+
const financials = useAppSelector((state) => state.financials);
|
|
154
|
+
const inputRef = useRef<InputRenderable | null>(null);
|
|
155
|
+
const validationSeqRef = useRef(0);
|
|
156
|
+
const [inputFocused, setInputFocused] = useState(false);
|
|
157
|
+
const [inputValue, setInputValue] = useState("");
|
|
158
|
+
const [validation, setValidation] = useState<QuickAddValidation>(IDLE_VALIDATION);
|
|
159
|
+
const [submitting, setSubmitting] = useState(false);
|
|
160
|
+
|
|
161
|
+
const focusInput = useCallback(() => {
|
|
162
|
+
if (!focused) return;
|
|
163
|
+
setInputFocused(true);
|
|
164
|
+
queueMicrotask(() => inputRef.current?.focus?.());
|
|
165
|
+
}, [focused]);
|
|
166
|
+
|
|
167
|
+
const blurInput = useCallback(() => {
|
|
168
|
+
setInputFocused(false);
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
const resetInput = useCallback(() => {
|
|
172
|
+
setInputValue("");
|
|
173
|
+
setValidation(IDLE_VALIDATION);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
onFocusChange?.(inputFocused && focused);
|
|
178
|
+
}, [focused, inputFocused, onFocusChange]);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (inputFocused && focused) {
|
|
182
|
+
dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
|
|
183
|
+
return () => dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
|
|
184
|
+
}
|
|
185
|
+
}, [dispatch, focused, inputFocused]);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!focused && inputFocused) {
|
|
189
|
+
blurInput();
|
|
190
|
+
}
|
|
191
|
+
}, [blurInput, focused, inputFocused]);
|
|
192
|
+
|
|
193
|
+
const validateQuery = useCallback(async (query: string): Promise<QuickAddValidation> => {
|
|
194
|
+
if (!query) return IDLE_VALIDATION;
|
|
195
|
+
if (!isPlausibleTickerQuery(query)) {
|
|
196
|
+
return { status: "missing", query, message: "Use a ticker symbol" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const registry = getSharedRegistry();
|
|
200
|
+
if (!registry) {
|
|
201
|
+
return { status: "error", query, message: "Ticker lookup unavailable" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const resolved = await resolveTickerSearch({
|
|
206
|
+
query,
|
|
207
|
+
activeTicker: null,
|
|
208
|
+
tickers,
|
|
209
|
+
dataProvider: registry.marketData,
|
|
210
|
+
});
|
|
211
|
+
if (!resolved) {
|
|
212
|
+
return { status: "missing", query, message: "No exact ticker match" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const symbol = resolved.symbol;
|
|
216
|
+
const ticker = resolved.kind === "local" ? resolved.ticker : (tickers.get(symbol) ?? null);
|
|
217
|
+
const cachedQuote = financials.get(symbol)?.quote ?? null;
|
|
218
|
+
let quote = cachedQuote;
|
|
219
|
+
if (!quote) {
|
|
220
|
+
try {
|
|
221
|
+
quote = await registry.marketData.getQuote(
|
|
222
|
+
symbol,
|
|
223
|
+
exchangeFromResolved(resolved),
|
|
224
|
+
quoteContextFromResolved(resolved),
|
|
225
|
+
);
|
|
226
|
+
} catch {
|
|
227
|
+
quote = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
status: tickerBelongsToCollection(ticker, collectionKind, collectionId) ? "duplicate" : "ready",
|
|
233
|
+
query,
|
|
234
|
+
symbol,
|
|
235
|
+
resolved,
|
|
236
|
+
ticker,
|
|
237
|
+
quote,
|
|
238
|
+
};
|
|
239
|
+
} catch {
|
|
240
|
+
return { status: "error", query, message: "Ticker lookup failed" };
|
|
241
|
+
}
|
|
242
|
+
}, [collectionId, collectionKind, financials, tickers]);
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const query = normalizeQuickAddQuery(inputValue);
|
|
246
|
+
validationSeqRef.current += 1;
|
|
247
|
+
const seq = validationSeqRef.current;
|
|
248
|
+
|
|
249
|
+
if (!query) {
|
|
250
|
+
setValidation(IDLE_VALIDATION);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!isPlausibleTickerQuery(query)) {
|
|
255
|
+
setValidation({ status: "missing", query, message: "Use a ticker symbol" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
setValidation({ status: "checking", query });
|
|
260
|
+
const timeoutId = setTimeout(() => {
|
|
261
|
+
void validateQuery(query).then((result) => {
|
|
262
|
+
if (validationSeqRef.current === seq) {
|
|
263
|
+
setValidation(result);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}, QUICK_ADD_DEBOUNCE_MS);
|
|
267
|
+
|
|
268
|
+
return () => clearTimeout(timeoutId);
|
|
269
|
+
}, [inputValue, validateQuery]);
|
|
270
|
+
|
|
271
|
+
const submitInput = useCallback(async (submittedValue?: string) => {
|
|
272
|
+
const query = normalizeQuickAddQuery(submittedValue ?? inputValue);
|
|
273
|
+
if (!query || submitting) return;
|
|
274
|
+
|
|
275
|
+
setSubmitting(true);
|
|
276
|
+
try {
|
|
277
|
+
const currentValidation = (
|
|
278
|
+
validation.query === query
|
|
279
|
+
&& (validation.status === "ready" || validation.status === "duplicate")
|
|
280
|
+
)
|
|
281
|
+
? validation
|
|
282
|
+
: await validateQuery(query);
|
|
283
|
+
setValidation(currentValidation);
|
|
284
|
+
|
|
285
|
+
if (currentValidation.status === "duplicate") {
|
|
286
|
+
notify({ type: "info", body: `${currentValidation.symbol} is already in ${collectionName}.` });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (currentValidation.status !== "ready") {
|
|
290
|
+
notify({ type: "error", body: currentValidation.status === "idle" ? "Ticker symbol is required." : currentValidation.message });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const registry = getSharedRegistry();
|
|
295
|
+
if (!registry) {
|
|
296
|
+
notify({ type: "error", body: "Ticker lookup unavailable." });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let ticker: TickerRecord;
|
|
301
|
+
let created = false;
|
|
302
|
+
if (currentValidation.resolved.kind === "local") {
|
|
303
|
+
ticker = currentValidation.resolved.ticker;
|
|
304
|
+
} else {
|
|
305
|
+
const result = await upsertTickerFromSearchResult(registry.tickerRepository, currentValidation.resolved.result);
|
|
306
|
+
ticker = result.ticker;
|
|
307
|
+
created = result.created;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = collectionKind === "portfolio"
|
|
311
|
+
? addTickerToPortfolio(ticker, collectionId)
|
|
312
|
+
: addTickerToWatchlist(ticker, collectionId);
|
|
313
|
+
|
|
314
|
+
if (!result.changed) {
|
|
315
|
+
notify({ type: "info", body: `${ticker.metadata.ticker} is already in ${collectionName}.` });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await registry.tickerRepository.saveTicker(result.ticker);
|
|
320
|
+
dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
|
|
321
|
+
if (created) {
|
|
322
|
+
registry.events.emit("ticker:added", {
|
|
323
|
+
symbol: result.ticker.metadata.ticker,
|
|
324
|
+
ticker: result.ticker,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
onAdded(result.ticker.metadata.ticker);
|
|
329
|
+
notify({ type: "success", body: `Added ${result.ticker.metadata.ticker} to ${collectionName}.` });
|
|
330
|
+
resetInput();
|
|
331
|
+
queueMicrotask(() => inputRef.current?.focus?.());
|
|
332
|
+
} catch {
|
|
333
|
+
setValidation({ status: "error", query, message: "Ticker add failed" });
|
|
334
|
+
notify({ type: "error", body: `Failed to add ${query}.` });
|
|
335
|
+
} finally {
|
|
336
|
+
setSubmitting(false);
|
|
337
|
+
}
|
|
338
|
+
}, [
|
|
339
|
+
collectionId,
|
|
340
|
+
collectionKind,
|
|
341
|
+
collectionName,
|
|
342
|
+
dispatch,
|
|
343
|
+
inputValue,
|
|
344
|
+
notify,
|
|
345
|
+
onAdded,
|
|
346
|
+
resetInput,
|
|
347
|
+
submitting,
|
|
348
|
+
validateQuery,
|
|
349
|
+
validation,
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
useShortcut((event) => {
|
|
353
|
+
if (!focused) return;
|
|
354
|
+
|
|
355
|
+
if (inputFocused) {
|
|
356
|
+
if (event.name === "escape") {
|
|
357
|
+
event.preventDefault?.();
|
|
358
|
+
event.stopPropagation?.();
|
|
359
|
+
resetInput();
|
|
360
|
+
blurInput();
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if ((event.name === "a" || event.name === "n") && !event.ctrl && !event.meta && !event.super) {
|
|
366
|
+
event.preventDefault?.();
|
|
367
|
+
event.stopPropagation?.();
|
|
368
|
+
focusInput();
|
|
369
|
+
}
|
|
370
|
+
}, { phase: "before" });
|
|
371
|
+
|
|
372
|
+
const inputWidth = useMemo(() => {
|
|
373
|
+
const queryWidth = normalizeQuickAddQuery(inputValue).length;
|
|
374
|
+
if (queryWidth > 0) {
|
|
375
|
+
return Math.max(4, Math.min(18, queryWidth + 1));
|
|
376
|
+
}
|
|
377
|
+
return Math.max(6, Math.min(10, Math.floor(width * 0.18)));
|
|
378
|
+
}, [inputValue, width]);
|
|
379
|
+
const previewWidth = Math.max(8, width - inputWidth - 7);
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<Box
|
|
383
|
+
height={1}
|
|
384
|
+
width="100%"
|
|
385
|
+
flexDirection="row"
|
|
386
|
+
flexShrink={0}
|
|
387
|
+
paddingX={1}
|
|
388
|
+
backgroundColor={colors.panel}
|
|
389
|
+
onMouseDown={(event: { preventDefault?: () => void }) => {
|
|
390
|
+
event.preventDefault?.();
|
|
391
|
+
focusInput();
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<Text fg={inputFocused ? colors.text : colors.textDim}>+</Text>
|
|
395
|
+
<Box width={1} />
|
|
396
|
+
<Box width={inputWidth}>
|
|
397
|
+
<Input
|
|
398
|
+
ref={inputRef}
|
|
399
|
+
value={inputValue}
|
|
400
|
+
focused={inputFocused && focused}
|
|
401
|
+
placeholder="ticker"
|
|
402
|
+
placeholderColor={colors.textMuted}
|
|
403
|
+
textColor={colors.text}
|
|
404
|
+
backgroundColor={colors.panel}
|
|
405
|
+
onInput={(value: string) => setInputValue(value.toUpperCase())}
|
|
406
|
+
onChange={(value: string) => setInputValue(value.toUpperCase())}
|
|
407
|
+
onSubmit={(value: string) => void submitInput(value)}
|
|
408
|
+
onFocus={() => setInputFocused(true)}
|
|
409
|
+
onBlur={blurInput}
|
|
410
|
+
/>
|
|
411
|
+
</Box>
|
|
412
|
+
<Box width={1} />
|
|
413
|
+
<Box width={previewWidth} overflow="hidden">
|
|
414
|
+
<QuickAddPreview
|
|
415
|
+
validation={validation}
|
|
416
|
+
collectionKind={collectionKind}
|
|
417
|
+
submitting={submitting}
|
|
418
|
+
/>
|
|
419
|
+
</Box>
|
|
420
|
+
</Box>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PaneSettingOption, PaneSettingsDef, PaneTemplateContext } from "../../../types/plugin";
|
|
2
2
|
import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type ColumnConfig } from "../../../types/config";
|
|
3
|
+
import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline-view";
|
|
3
4
|
|
|
4
5
|
export type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
|
|
5
6
|
|
|
@@ -9,7 +10,6 @@ export interface PortfolioPaneSettings {
|
|
|
9
10
|
visibleCollectionIds: string[];
|
|
10
11
|
hideHeader: boolean;
|
|
11
12
|
hideCash: boolean;
|
|
12
|
-
showSparklines?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface CollectionEntry {
|
|
@@ -20,6 +20,7 @@ export interface CollectionEntry {
|
|
|
20
20
|
|
|
21
21
|
export const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
|
|
22
22
|
...DEFAULT_COLUMNS,
|
|
23
|
+
{ id: PRICE_SPARKLINE_COLUMN_ID, label: PRICE_SPARKLINE_PERIOD_LABEL, width: 6, align: "left" },
|
|
23
24
|
{ id: "bid", label: "BID", width: 10, align: "right", format: "currency" },
|
|
24
25
|
{ id: "ask", label: "ASK", width: 10, align: "right", format: "currency" },
|
|
25
26
|
{ id: "spread", label: "SPREAD", width: 10, align: "right", format: "currency" },
|
|
@@ -93,6 +94,13 @@ function resolveCollectionOptions(entries: CollectionEntry[]): PaneSettingOption
|
|
|
93
94
|
}));
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
function describeColumnOption(column: ColumnConfig): string {
|
|
98
|
+
if (column.id === PRICE_SPARKLINE_COLUMN_ID) return "One-month price sparkline.";
|
|
99
|
+
return PORTFOLIO_ONLY_COLUMN_IDS.has(column.id)
|
|
100
|
+
? "Visible only when this pane is showing a portfolio."
|
|
101
|
+
: "Visible for watchlists and portfolios.";
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
export function getPortfolioPaneSettings(settings: Record<string, unknown> | undefined): PortfolioPaneSettings {
|
|
97
105
|
const columnIds = Array.isArray(settings?.columnIds)
|
|
98
106
|
? settings.columnIds.filter((value): value is string => typeof value === "string")
|
|
@@ -107,7 +115,6 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
|
|
|
107
115
|
visibleCollectionIds,
|
|
108
116
|
hideHeader: settings?.hideHeader === true,
|
|
109
117
|
hideCash: settings?.hideCash === true,
|
|
110
|
-
showSparklines: settings?.showSparklines === true,
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
|
|
@@ -115,6 +122,7 @@ export function cleanPortfolioPaneSettings(settings: Record<string, unknown>): R
|
|
|
115
122
|
const nextSettings = { ...settings };
|
|
116
123
|
delete nextSettings.hideTabs;
|
|
117
124
|
delete nextSettings.lockedCollectionId;
|
|
125
|
+
delete nextSettings.showSparklines;
|
|
118
126
|
if (nextSettings.collectionScope !== "custom") {
|
|
119
127
|
delete nextSettings.visibleCollectionIds;
|
|
120
128
|
}
|
|
@@ -175,7 +183,10 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
|
|
|
175
183
|
return resolved;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
|
-
return
|
|
186
|
+
return DEFAULT_PORTFOLIO_COLUMN_IDS
|
|
187
|
+
.map((columnId) => PORTFOLIO_COLUMNS_BY_ID.get(columnId))
|
|
188
|
+
.filter((column): column is ColumnConfig => column != null)
|
|
189
|
+
.filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
|
|
179
190
|
}
|
|
180
191
|
|
|
181
192
|
export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: PortfolioPaneSettings): PaneSettingsDef {
|
|
@@ -190,9 +201,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
190
201
|
options: PORTFOLIO_COLUMN_DEFS.map((column) => ({
|
|
191
202
|
value: column.id,
|
|
192
203
|
label: column.label,
|
|
193
|
-
description:
|
|
194
|
-
? "Visible only when this pane is showing a portfolio."
|
|
195
|
-
: "Visible for watchlists and portfolios.",
|
|
204
|
+
description: describeColumnOption(column),
|
|
196
205
|
})),
|
|
197
206
|
},
|
|
198
207
|
{
|
|
@@ -222,14 +231,13 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
222
231
|
label: "Hide Cash Positions",
|
|
223
232
|
type: "toggle",
|
|
224
233
|
});
|
|
225
|
-
fields.push({
|
|
226
|
-
key: "showSparklines",
|
|
227
|
-
label: "Sparklines",
|
|
228
|
-
type: "toggle",
|
|
229
|
-
});
|
|
230
|
-
|
|
231
234
|
return {
|
|
232
235
|
title: "Portfolio Pane Settings",
|
|
236
|
+
values: {
|
|
237
|
+
...settings,
|
|
238
|
+
columnIds: [...settings.columnIds],
|
|
239
|
+
visibleCollectionIds: [...settings.visibleCollectionIds],
|
|
240
|
+
},
|
|
233
241
|
fields,
|
|
234
242
|
};
|
|
235
243
|
}
|
|
@@ -56,12 +56,12 @@ export function PortfolioTickerTable({
|
|
|
56
56
|
financialsMap,
|
|
57
57
|
columnContext,
|
|
58
58
|
flashSymbols,
|
|
59
|
-
showSparklines,
|
|
60
59
|
onRootKeyDown,
|
|
61
60
|
onVisibleRangeChange,
|
|
62
61
|
visibleRangeBuffer,
|
|
63
62
|
resetScrollKey,
|
|
64
63
|
onRowActivate,
|
|
64
|
+
rootHeight,
|
|
65
65
|
}: {
|
|
66
66
|
columns: ColumnConfig[];
|
|
67
67
|
focused?: boolean;
|
|
@@ -74,12 +74,12 @@ export function PortfolioTickerTable({
|
|
|
74
74
|
financialsMap: Map<string, TickerFinancials>;
|
|
75
75
|
columnContext: ColumnContext;
|
|
76
76
|
flashSymbols: Map<string, QuoteFlashDirection>;
|
|
77
|
-
showSparklines?: boolean;
|
|
78
77
|
onRootKeyDown?: (event: DataTableKeyEvent) => boolean | void;
|
|
79
78
|
onVisibleRangeChange?: (range: TickerListVisibleRange) => void;
|
|
80
79
|
visibleRangeBuffer?: number;
|
|
81
80
|
resetScrollKey?: unknown;
|
|
82
81
|
onRowActivate?: (ticker: TickerRecord) => void;
|
|
82
|
+
rootHeight?: number;
|
|
83
83
|
}) {
|
|
84
84
|
const cellCacheRef = useRef(createRowValueCache<string, ReturnType<typeof getColumnValue>>(5000));
|
|
85
85
|
const resolveCell = useCallback(
|
|
@@ -106,12 +106,12 @@ export function PortfolioTickerTable({
|
|
|
106
106
|
sortColumnId={sortColumnId}
|
|
107
107
|
sortDirection={sortDirection}
|
|
108
108
|
onHeaderClick={onHeaderClick}
|
|
109
|
-
showSparklines={showSparklines}
|
|
110
109
|
onRootKeyDown={onRootKeyDown}
|
|
111
110
|
onVisibleRangeChange={onVisibleRangeChange}
|
|
112
111
|
visibleRangeBuffer={visibleRangeBuffer}
|
|
113
112
|
resetScrollKey={resetScrollKey}
|
|
114
113
|
onRowActivate={onRowActivate}
|
|
114
|
+
rootHeight={rootHeight}
|
|
115
115
|
/>
|
|
116
116
|
);
|
|
117
117
|
}
|