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
package/src/types/financials.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
export type MarketState = "PRE" | "REGULAR" | "POST" | "PREPRE" | "POSTPOST" | "CLOSED";
|
|
2
|
+
export type SessionConfidence = "explicit" | "derived" | "unknown";
|
|
3
|
+
export type QuoteDataSource = "live" | "delayed" | "yahoo";
|
|
4
|
+
|
|
5
|
+
export interface QuoteFieldProvenance {
|
|
6
|
+
providerId: string;
|
|
7
|
+
dataSource?: QuoteDataSource;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QuoteProvenance {
|
|
11
|
+
price?: QuoteFieldProvenance;
|
|
12
|
+
session?: QuoteFieldProvenance;
|
|
13
|
+
listing?: QuoteFieldProvenance;
|
|
14
|
+
routing?: QuoteFieldProvenance;
|
|
15
|
+
descriptive?: QuoteFieldProvenance;
|
|
16
|
+
fields?: Record<string, QuoteFieldProvenance>;
|
|
17
|
+
rejectedPriceProviders?: string[];
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
export interface Quote {
|
|
4
21
|
symbol: string;
|
|
@@ -16,7 +33,12 @@ export interface Quote {
|
|
|
16
33
|
lastUpdated: number; // timestamp ms
|
|
17
34
|
exchangeName?: string;
|
|
18
35
|
fullExchangeName?: string;
|
|
36
|
+
listingExchangeName?: string;
|
|
37
|
+
listingExchangeFullName?: string;
|
|
38
|
+
routingExchangeName?: string;
|
|
39
|
+
routingExchangeFullName?: string;
|
|
19
40
|
marketState?: MarketState;
|
|
41
|
+
sessionConfidence?: SessionConfidence;
|
|
20
42
|
preMarketPrice?: number;
|
|
21
43
|
preMarketChange?: number;
|
|
22
44
|
preMarketChangePercent?: number;
|
|
@@ -31,10 +53,17 @@ export interface Quote {
|
|
|
31
53
|
high?: number;
|
|
32
54
|
low?: number;
|
|
33
55
|
mark?: number;
|
|
56
|
+
provenance?: QuoteProvenance;
|
|
34
57
|
/** Where the quote data came from: live broker feed, delayed broker feed, or a fallback like Yahoo. */
|
|
35
|
-
dataSource?:
|
|
58
|
+
dataSource?: QuoteDataSource;
|
|
36
59
|
}
|
|
37
60
|
|
|
61
|
+
export interface QuoteContribution extends Quote {
|
|
62
|
+
providerId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type QuoteContributionMap = Record<string, QuoteContribution>;
|
|
66
|
+
|
|
38
67
|
export interface Fundamentals {
|
|
39
68
|
trailingPE?: number;
|
|
40
69
|
forwardPE?: number;
|
|
@@ -110,6 +139,7 @@ export interface PricePoint {
|
|
|
110
139
|
|
|
111
140
|
export interface TickerFinancials {
|
|
112
141
|
quote?: Quote;
|
|
142
|
+
quoteContributions?: QuoteContributionMap;
|
|
113
143
|
fundamentals?: Fundamentals;
|
|
114
144
|
profile?: CompanyProfile;
|
|
115
145
|
annualStatements: FinancialStatement[];
|
package/src/types/plugin.ts
CHANGED
|
@@ -169,6 +169,75 @@ export interface WizardStep {
|
|
|
169
169
|
body?: string[];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
export interface CliHelpColumn {
|
|
173
|
+
header: string;
|
|
174
|
+
align?: "left" | "right" | "center";
|
|
175
|
+
width?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface CliCommandHelpSection {
|
|
179
|
+
title: string;
|
|
180
|
+
columns?: CliHelpColumn[];
|
|
181
|
+
rows?: string[][];
|
|
182
|
+
lines?: string[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface CliCommandHelp {
|
|
186
|
+
usage?: string[];
|
|
187
|
+
sections?: CliCommandHelpSection[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CliLaunchEnvironment {
|
|
191
|
+
terminalWidth: number;
|
|
192
|
+
terminalHeight: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface CliLaunchConfigResult<TLaunchState = unknown> {
|
|
196
|
+
config: AppConfig;
|
|
197
|
+
launchState?: TLaunchState;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface CliLaunchRequest<TLaunchState = unknown> {
|
|
201
|
+
applyConfig(config: AppConfig, env: CliLaunchEnvironment): CliLaunchConfigResult<TLaunchState>;
|
|
202
|
+
applySessionSnapshot?(
|
|
203
|
+
config: AppConfig,
|
|
204
|
+
snapshot: import("../state/session-persistence").AppSessionSnapshot | null,
|
|
205
|
+
launchState: TLaunchState | undefined,
|
|
206
|
+
): import("../state/session-persistence").AppSessionSnapshot;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type CliDispatchResult =
|
|
210
|
+
| { kind: "handled" }
|
|
211
|
+
| { kind: "launch-ui"; request: CliLaunchRequest }
|
|
212
|
+
| { kind: "unhandled" };
|
|
213
|
+
|
|
214
|
+
export interface CliCommandContext {
|
|
215
|
+
initConfigData(): Promise<import("../cli/types").ConfigContext>;
|
|
216
|
+
initMarketData(): Promise<import("../cli/types").MarketContext>;
|
|
217
|
+
fail(message: string, details?: string): never;
|
|
218
|
+
closeAndFail(
|
|
219
|
+
persistence: import("../data/app-persistence").AppPersistence,
|
|
220
|
+
message: string,
|
|
221
|
+
details?: string,
|
|
222
|
+
): never;
|
|
223
|
+
output: {
|
|
224
|
+
cliStyles: typeof import("../utils/cli-output").cliStyles;
|
|
225
|
+
colorBySign: typeof import("../utils/cli-output").colorBySign;
|
|
226
|
+
renderSection: typeof import("../utils/cli-output").renderSection;
|
|
227
|
+
renderStat: typeof import("../utils/cli-output").renderStat;
|
|
228
|
+
renderTable: typeof import("../utils/cli-output").renderTable;
|
|
229
|
+
};
|
|
230
|
+
log: PluginLogger;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface CliCommandDef {
|
|
234
|
+
name: string;
|
|
235
|
+
aliases?: string[];
|
|
236
|
+
description: string;
|
|
237
|
+
help?: CliCommandHelp;
|
|
238
|
+
execute(args: string[], ctx: CliCommandContext): void | CliDispatchResult | Promise<void | CliDispatchResult>;
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
export interface CommandDef {
|
|
173
242
|
id: string;
|
|
174
243
|
label: string;
|
|
@@ -340,6 +409,7 @@ export interface GloomPlugin {
|
|
|
340
409
|
description?: string;
|
|
341
410
|
toggleable?: boolean;
|
|
342
411
|
order?: number;
|
|
412
|
+
cliCommands?: CliCommandDef[];
|
|
343
413
|
|
|
344
414
|
setup?(ctx: GloomPluginContext): void | Promise<void>;
|
|
345
415
|
dispose?(): void;
|
|
@@ -84,3 +84,60 @@ describe("apiClient auth cookies", () => {
|
|
|
84
84
|
]);
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("apiClient chat timestamps", () => {
|
|
89
|
+
test("normalizes transcript and send-response timestamps to UTC ISO strings", async () => {
|
|
90
|
+
const responses = [
|
|
91
|
+
createResponse([{
|
|
92
|
+
id: "m1",
|
|
93
|
+
channelId: "everyone",
|
|
94
|
+
content: "older",
|
|
95
|
+
replyToId: null,
|
|
96
|
+
createdAt: "2026-04-08 07:28:27.625",
|
|
97
|
+
user: { id: "u1", username: "alice", displayName: "Alice" },
|
|
98
|
+
replyTo: null,
|
|
99
|
+
}]),
|
|
100
|
+
createResponse({
|
|
101
|
+
id: "m2",
|
|
102
|
+
channelId: "everyone",
|
|
103
|
+
content: "hello",
|
|
104
|
+
replyToId: null,
|
|
105
|
+
createdAt: "2026-04-08T07:29:27.625",
|
|
106
|
+
user: { id: "u1", username: "alice", displayName: "Alice" },
|
|
107
|
+
replyTo: null,
|
|
108
|
+
}),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
globalThis.fetch = (async () => responses.shift() as Response) as typeof fetch;
|
|
112
|
+
|
|
113
|
+
const messages = await apiClient.getMessages("everyone", { limit: 1 });
|
|
114
|
+
const sentMessage = await apiClient.sendMessage("everyone", "hello");
|
|
115
|
+
|
|
116
|
+
expect(messages[0]?.createdAt).toBe("2026-04-08T07:28:27.625Z");
|
|
117
|
+
expect(sentMessage.createdAt).toBe("2026-04-08T07:29:27.625Z");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("normalizes websocket chat timestamps before notifying listeners", async () => {
|
|
121
|
+
const seenCreatedAts: string[] = [];
|
|
122
|
+
const channel = apiClient.connectChannel("everyone", (message) => {
|
|
123
|
+
seenCreatedAts.push(message.createdAt);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
127
|
+
type: "chat.message",
|
|
128
|
+
channelId: "everyone",
|
|
129
|
+
data: {
|
|
130
|
+
id: "m1",
|
|
131
|
+
channelId: "everyone",
|
|
132
|
+
content: "hello",
|
|
133
|
+
replyToId: null,
|
|
134
|
+
createdAt: "2026-04-08 07:28:27.625",
|
|
135
|
+
user: { id: "u1", username: "alice", displayName: "Alice" },
|
|
136
|
+
replyTo: null,
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
|
|
141
|
+
channel.close();
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/utils/api-client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CompanyProfile, Fundamentals, PricePoint, Quote, TickerFinancials } from "../types/financials";
|
|
2
2
|
import type { InstrumentSearchResult } from "../types/instrument";
|
|
3
3
|
import { debugLog } from "./debug-log";
|
|
4
|
+
import { normalizeTimestamp } from "./timestamp";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_API_URL = "https://api.gloom.sh";
|
|
6
7
|
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
@@ -14,6 +15,8 @@ export interface ChatMessage {
|
|
|
14
15
|
createdAt: string;
|
|
15
16
|
user: { id: string; username: string; displayName: string };
|
|
16
17
|
replyTo?: { content: string; user: { username: string } } | null;
|
|
18
|
+
clientStatus?: "sending" | "failed";
|
|
19
|
+
clientError?: string | null;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export interface ChatChannel {
|
|
@@ -106,6 +109,17 @@ function marketKey(symbol: string, exchange?: string): string {
|
|
|
106
109
|
return normalizedExchangeValue ? `${normalizedSymbolValue}:${normalizedExchangeValue}` : normalizedSymbolValue;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
function normalizeChatMessage(message: ChatMessage): ChatMessage {
|
|
113
|
+
return {
|
|
114
|
+
...message,
|
|
115
|
+
createdAt: normalizeTimestamp(message.createdAt),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
120
|
+
return messages.map((message) => normalizeChatMessage(message));
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
type ChannelListener = (message: ChatMessage) => void;
|
|
110
124
|
type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
|
|
111
125
|
type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
|
|
@@ -404,8 +418,9 @@ class GloomApiClient {
|
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
if (parsed?.type === "chat.message" && typeof parsed.channelId === "string" && parsed.data) {
|
|
421
|
+
const message = normalizeChatMessage(parsed.data as ChatMessage);
|
|
407
422
|
for (const listener of this.channelListeners.get(parsed.channelId) ?? []) {
|
|
408
|
-
listener(
|
|
423
|
+
listener(message);
|
|
409
424
|
}
|
|
410
425
|
return;
|
|
411
426
|
}
|
|
@@ -491,23 +506,30 @@ class GloomApiClient {
|
|
|
491
506
|
if (opts?.before) params.set("before", opts.before);
|
|
492
507
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
493
508
|
const qs = params.toString();
|
|
494
|
-
|
|
509
|
+
const messages = await this.request<ChatMessage[]>(`/chat/channels/${channelId}/messages${qs ? `?${qs}` : ""}`);
|
|
510
|
+
return normalizeChatMessages(messages);
|
|
495
511
|
}
|
|
496
512
|
|
|
497
513
|
async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
|
|
498
|
-
|
|
514
|
+
const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
|
|
499
515
|
method: "POST",
|
|
500
516
|
body: JSON.stringify({ content, replyToId }),
|
|
501
517
|
});
|
|
518
|
+
return normalizeChatMessage(message);
|
|
502
519
|
}
|
|
503
520
|
|
|
504
521
|
connectChannel(
|
|
505
522
|
channelId: string,
|
|
506
523
|
onMessage: (msg: ChatMessage) => void,
|
|
507
524
|
onError?: (err: string) => void,
|
|
508
|
-
): { send: (content: string, replyToId?: string) =>
|
|
525
|
+
): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
|
|
509
526
|
if (!channelId) {
|
|
510
|
-
return {
|
|
527
|
+
return {
|
|
528
|
+
send: async () => {
|
|
529
|
+
throw new Error("Channel id is required");
|
|
530
|
+
},
|
|
531
|
+
close: () => {},
|
|
532
|
+
};
|
|
511
533
|
}
|
|
512
534
|
|
|
513
535
|
const listeners = this.channelListeners.get(channelId) ?? new Set<ChannelListener>();
|
|
@@ -520,14 +542,15 @@ class GloomApiClient {
|
|
|
520
542
|
}
|
|
521
543
|
|
|
522
544
|
return {
|
|
523
|
-
send: (content: string, replyToId?: string) => {
|
|
524
|
-
|
|
525
|
-
this.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
545
|
+
send: async (content: string, replyToId?: string) => {
|
|
546
|
+
try {
|
|
547
|
+
const message = await this.sendMessage(channelId, content, replyToId);
|
|
548
|
+
onMessage(message);
|
|
549
|
+
return message;
|
|
550
|
+
} catch (error) {
|
|
529
551
|
onError?.(error instanceof Error ? error.message : String(error));
|
|
530
|
-
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
531
554
|
},
|
|
532
555
|
close: () => {
|
|
533
556
|
const current = this.channelListeners.get(channelId);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BackNavigationEventLike {
|
|
2
|
+
name?: string;
|
|
3
|
+
ctrl?: boolean;
|
|
4
|
+
meta?: boolean;
|
|
5
|
+
option?: boolean;
|
|
6
|
+
shift?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isPlainBackspace(event: BackNavigationEventLike): boolean {
|
|
10
|
+
return event.name === "backspace"
|
|
11
|
+
&& !event.ctrl
|
|
12
|
+
&& !event.meta
|
|
13
|
+
&& !event.option
|
|
14
|
+
&& !event.shift;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isPlainEscape(event: BackNavigationEventLike): boolean {
|
|
18
|
+
return (event.name === "escape" || event.name === "esc")
|
|
19
|
+
&& !event.ctrl
|
|
20
|
+
&& !event.meta
|
|
21
|
+
&& !event.option
|
|
22
|
+
&& !event.shift;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isBackNavigationKey(event: BackNavigationEventLike): boolean {
|
|
26
|
+
return isPlainBackspace(event) || isPlainEscape(event);
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatTimeAgo } from "./format";
|
|
3
|
+
|
|
4
|
+
describe("formatTimeAgo", () => {
|
|
5
|
+
test("handles UTC ISO timestamps with explicit offsets", () => {
|
|
6
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString().replace("Z", "+00:00");
|
|
7
|
+
expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("treats space-separated chat timestamps without a timezone as UTC", () => {
|
|
11
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString().replace("T", " ").replace("Z", "");
|
|
12
|
+
expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toTimestampMillis } from "./timestamp";
|
|
2
|
+
|
|
1
3
|
const currencyFormatters = new Map<string, Intl.NumberFormat>();
|
|
2
4
|
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
|
3
5
|
|
|
@@ -143,9 +145,7 @@ export function convertCurrency(
|
|
|
143
145
|
|
|
144
146
|
/** Format a date/timestamp as relative time (e.g., "5m ago", "2h ago") */
|
|
145
147
|
export function formatTimeAgo(date: Date | string): string {
|
|
146
|
-
const ts =
|
|
147
|
-
? new Date(date.endsWith("Z") ? date : date + "Z").getTime()
|
|
148
|
-
: date.getTime();
|
|
148
|
+
const ts = toTimestampMillis(date);
|
|
149
149
|
const seconds = Math.floor((Date.now() - ts) / 1000);
|
|
150
150
|
if (seconds < 60) return "just now";
|
|
151
151
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MarketState, Quote } from "../types/financials";
|
|
1
|
+
import type { MarketState, Quote, QuoteFieldProvenance } from "../types/financials";
|
|
2
2
|
import { colors, priceColor } from "../theme/colors";
|
|
3
3
|
import { formatPercentRaw } from "./format";
|
|
4
4
|
|
|
@@ -30,6 +30,22 @@ export function marketStateColor(state: MarketState): string {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function marketStateDot(state?: MarketState): string {
|
|
34
|
+
switch (state) {
|
|
35
|
+
case "REGULAR":
|
|
36
|
+
return "\u25CF";
|
|
37
|
+
case "PRE":
|
|
38
|
+
case "POST":
|
|
39
|
+
return "\u25D0";
|
|
40
|
+
case "CLOSED":
|
|
41
|
+
case "PREPRE":
|
|
42
|
+
case "POSTPOST":
|
|
43
|
+
return "\u25CB";
|
|
44
|
+
default:
|
|
45
|
+
return "\u25CC";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
/** Short exchange display name */
|
|
34
50
|
export function exchangeShortName(exchangeName?: string, fullExchangeName?: string): string {
|
|
35
51
|
if (!exchangeName && !fullExchangeName) return "";
|
|
@@ -57,6 +73,35 @@ export function exchangeShortName(exchangeName?: string, fullExchangeName?: stri
|
|
|
57
73
|
return map[name] || name;
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
export function quoteProviderLabel(providerId?: string): string {
|
|
77
|
+
switch (providerId) {
|
|
78
|
+
case "ibkr":
|
|
79
|
+
return "Broker";
|
|
80
|
+
case "gloomberb-cloud":
|
|
81
|
+
return "Cloud";
|
|
82
|
+
case "yahoo":
|
|
83
|
+
return "Yahoo";
|
|
84
|
+
default:
|
|
85
|
+
return providerId || "Unknown";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function quoteSourceLabel(
|
|
90
|
+
provenance?: QuoteFieldProvenance,
|
|
91
|
+
kind: "price" | "session" = "price",
|
|
92
|
+
): string {
|
|
93
|
+
if (!provenance?.providerId) return "Unknown";
|
|
94
|
+
if (provenance.providerId === "ibkr") {
|
|
95
|
+
if (kind === "session") return "Broker";
|
|
96
|
+
if (provenance.dataSource === "live") return "IBKR live";
|
|
97
|
+
if (provenance.dataSource === "delayed") return "IBKR delayed";
|
|
98
|
+
return "IBKR";
|
|
99
|
+
}
|
|
100
|
+
if (provenance.providerId === "gloomberb-cloud") return "Cloud";
|
|
101
|
+
if (provenance.providerId === "yahoo") return "Yahoo";
|
|
102
|
+
return provenance.providerId;
|
|
103
|
+
}
|
|
104
|
+
|
|
60
105
|
/** Get extended hours price info (pre-market or after-hours) for display */
|
|
61
106
|
export function getExtendedHoursInfo(quote: Quote | null | undefined): { text: string; color: string } | null {
|
|
62
107
|
if (!quote) return null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { MarketState, Quote } from "../types/financials";
|
|
2
|
+
|
|
3
|
+
const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
|
|
4
|
+
const EXCHANGE_TIME_ZONES: Record<string, string> = {
|
|
5
|
+
NASDAQ: "America/New_York",
|
|
6
|
+
NYSE: "America/New_York",
|
|
7
|
+
ARCA: "America/New_York",
|
|
8
|
+
AMEX: "America/New_York",
|
|
9
|
+
BATS: "America/New_York",
|
|
10
|
+
FWB: "Europe/Berlin",
|
|
11
|
+
FWB2: "Europe/Berlin",
|
|
12
|
+
XETRA: "Europe/Berlin",
|
|
13
|
+
SWX: "Europe/Zurich",
|
|
14
|
+
SFB: "Europe/Stockholm",
|
|
15
|
+
HKEX: "Asia/Hong_Kong",
|
|
16
|
+
JPX: "Asia/Tokyo",
|
|
17
|
+
TPEX: "Asia/Taipei",
|
|
18
|
+
NSE: "Asia/Kolkata",
|
|
19
|
+
BSE: "Asia/Kolkata",
|
|
20
|
+
LSE: "Europe/London",
|
|
21
|
+
};
|
|
22
|
+
const EXCHANGE_ALIASES: Record<string, string> = {
|
|
23
|
+
NMS: "NASDAQ",
|
|
24
|
+
NGM: "NASDAQ",
|
|
25
|
+
NCM: "NASDAQ",
|
|
26
|
+
NAS: "NASDAQ",
|
|
27
|
+
NYQ: "NYSE",
|
|
28
|
+
NYS: "NYSE",
|
|
29
|
+
PCX: "AMEX",
|
|
30
|
+
ASE: "AMEX",
|
|
31
|
+
HKG: "HKEX",
|
|
32
|
+
GER: "XETRA",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type UsSessionState = Exclude<MarketState, never>;
|
|
36
|
+
|
|
37
|
+
function normalizeExchange(exchange?: string): string {
|
|
38
|
+
const normalized = (exchange ?? "").trim().toUpperCase();
|
|
39
|
+
return EXCHANGE_ALIASES[normalized] ?? normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isUsExtendedHoursExchange(exchange?: string): boolean {
|
|
43
|
+
return US_EXTENDED_HOURS_EXCHANGES.has(normalizeExchange(exchange));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exchangeLocalDate(exchange: string, timestampMs: number): string | null {
|
|
47
|
+
const timeZone = EXCHANGE_TIME_ZONES[normalizeExchange(exchange)];
|
|
48
|
+
if (!timeZone) return null;
|
|
49
|
+
|
|
50
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
51
|
+
timeZone,
|
|
52
|
+
year: "numeric",
|
|
53
|
+
month: "2-digit",
|
|
54
|
+
day: "2-digit",
|
|
55
|
+
}).formatToParts(new Date(timestampMs));
|
|
56
|
+
const year = parts.find((part) => part.type === "year")?.value;
|
|
57
|
+
const month = parts.find((part) => part.type === "month")?.value;
|
|
58
|
+
const day = parts.find((part) => part.type === "day")?.value;
|
|
59
|
+
if (!year || !month || !day) return null;
|
|
60
|
+
return `${year}-${month}-${day}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function usSessionState(timestampMs: number): UsSessionState {
|
|
64
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
65
|
+
timeZone: "America/New_York",
|
|
66
|
+
weekday: "short",
|
|
67
|
+
hour: "2-digit",
|
|
68
|
+
minute: "2-digit",
|
|
69
|
+
hourCycle: "h23",
|
|
70
|
+
}).formatToParts(new Date(timestampMs));
|
|
71
|
+
|
|
72
|
+
const weekday = parts.find((part) => part.type === "weekday")?.value ?? "";
|
|
73
|
+
if (weekday === "Sat" || weekday === "Sun") return "CLOSED";
|
|
74
|
+
|
|
75
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
|
|
76
|
+
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
|
|
77
|
+
const totalMinutes = hour * 60 + minute;
|
|
78
|
+
|
|
79
|
+
if (totalMinutes < 4 * 60) return "PREPRE";
|
|
80
|
+
if (totalMinutes < 9 * 60 + 30) return "PRE";
|
|
81
|
+
if (totalMinutes < 16 * 60) return "REGULAR";
|
|
82
|
+
if (totalMinutes < 20 * 60) return "POST";
|
|
83
|
+
return "POSTPOST";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isQuoteMissingActiveSessionPrice(quote: Quote): boolean {
|
|
87
|
+
if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice == null) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice == null) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isQuoteStaleForCurrentSession(quote: Quote | null | undefined, now = Date.now()): boolean {
|
|
97
|
+
if (!quote) return false;
|
|
98
|
+
if (isQuoteMissingActiveSessionPrice(quote)) return true;
|
|
99
|
+
|
|
100
|
+
const exchange = normalizeExchange(quote.listingExchangeName || quote.exchangeName);
|
|
101
|
+
if (!exchange || !Number.isFinite(quote.lastUpdated)) return false;
|
|
102
|
+
|
|
103
|
+
const quoteDate = exchangeLocalDate(exchange, quote.lastUpdated);
|
|
104
|
+
const currentDate = exchangeLocalDate(exchange, now);
|
|
105
|
+
if (!quoteDate || !currentDate || quoteDate === currentDate) return false;
|
|
106
|
+
|
|
107
|
+
if (quote.marketState === "REGULAR") return true;
|
|
108
|
+
|
|
109
|
+
if (isUsExtendedHoursExchange(exchange)) {
|
|
110
|
+
const session = usSessionState(now);
|
|
111
|
+
return session === "PRE" || session === "REGULAR" || session === "POST";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function hasFreshQuoteForCurrentSession(
|
|
118
|
+
quotes: Iterable<Quote | null | undefined>,
|
|
119
|
+
now = Date.now(),
|
|
120
|
+
): boolean {
|
|
121
|
+
for (const quote of quotes) {
|
|
122
|
+
if (quote && !isQuoteStaleForCurrentSession(quote, now)) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|