gloomberb 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -17,57 +17,6 @@ Available as a desktop app or TUI.
17
17
 
18
18
  </div>
19
19
 
20
- ## Functions
21
-
22
- Open the command bar with `Ctrl+P` or `` ` ``, then type a shortcut or command name.
23
-
24
- | Shortcut | Function |
25
- |----------|----------|
26
- | `DES <ticker>` | Security details for a ticker |
27
- | `FA <ticker>` | Financial statement view |
28
- | `GP <ticker>` | Price chart |
29
- | `GIP <ticker>` | Intraday price chart |
30
- | `HP <ticker>` | Historical OHLCV prices |
31
- | `GF <tickers>` | Fundamental statement graph |
32
- | `GE <tickers>` | Valuation multiple graph |
33
- | `GR <tickers>` | Security relationship graph |
34
- | `EE <ticker>` | Events view with earnings and revenue estimates |
35
- | `EM [tickers]` | Earnings monitor |
36
- | `SRCH <query>` | Provider symbol search |
37
- | `QQ <tickers>` | Ticker quote monitor |
38
- | `PM <query>` | Polymarket and Kalshi prediction data |
39
- | `TOP` | Ranked market stories |
40
- | `MOST` | Top gainers, losers, most active, and trending tickers |
41
- | `WEI` | Global equity indices |
42
- | `ECON` | Economic events and releases |
43
- | `CMP <tickers>` | Ticker charts |
44
- | `CORR <tickers>` | Ticker return correlations |
45
- | `ANR <ticker>` | Analyst targets and ratings |
46
- | `SEC <ticker>` | SEC filings and company disclosures |
47
- | `TWIT <query>` | Ticker-related market posts |
48
- | `OMON <ticker>` | Options monitor |
49
- | `PORT` | Portfolio risk and sector exposure |
50
- | `BI` / `SP` | S&P 500 sector performance |
51
- | `FXC` | Major FX cross rates |
52
- | `FNG` | Fear and greed market gauge |
53
- | `ALRT` | Price alerts |
54
- | `CHAT` | Gloom Cloud chat |
55
- | `PF` | Portfolio and watchlist workspace |
56
- | `N` | News feed |
57
- | `CN <ticker>` | Ticker news |
58
- | `NI` | Sector news |
59
- | `FIRST` | Breaking news |
60
- | `NOTE` | Notes |
61
- | `AI <prompt>` | AI screener |
62
- | `GC` | Yield curve |
63
- | `ERN` | Earnings calendar |
64
- | `HDS <ticker>` | Institutional holders |
65
- | `INS <ticker>` | Insider activity |
66
- | `EVT <ticker>` | Corporate actions, earnings, and estimates |
67
- | `RV <tickers>` | Relative valuation |
68
- | `IBKR` | IBKR trading pane |
69
- | `BR` | Broker connections |
70
-
71
20
  ## Install
72
21
 
73
22
  macOS desktop app + terminal command:
@@ -100,6 +49,25 @@ On macOS, app updates replace the app bundle in place and keep the terminal comm
100
49
 
101
50
  For the best terminal experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminal such as Ghostty, Kitty, or WezTerm.
102
51
 
52
+ ## Start
53
+
54
+ Open the command bar with `Ctrl+P` or `` ` ``, then type a command.
55
+
56
+ | Try | Opens |
57
+ |-----|-------|
58
+ | `DES AAPL` | Security details |
59
+ | `GP NVDA` | Price chart |
60
+ | `TOP` | Ranked market stories |
61
+ | `MOST` | Market movers |
62
+ | `PF` | Portfolios and watchlists |
63
+ | `HELP` | Full in-app shortcut list |
64
+
65
+ ## What It Does
66
+
67
+ - Research companies with quotes, charts, financials, filings, holders, insiders, options, analyst ratings, events, and relative valuation.
68
+ - Follow markets with top stories, breaking news, sector feeds, global indices, FX, macro events, yield curves, market movers, and fear/greed.
69
+ - Track portfolios and watchlists, connect brokers, set alerts, keep notes, run AI screens, browse prediction markets, and use Gloom Cloud chat.
70
+
103
71
  ## CLI
104
72
 
105
73
  Running `gloomberb` with no arguments launches the terminal UI. Use `gloomberb help` to see the full command list.
@@ -148,7 +116,89 @@ See [PLUGINS.md](PLUGINS.md) for the plugin API and the shared UI surface availa
148
116
  | `m` | Cycle chart mode |
149
117
  | `q` | Quit |
150
118
 
151
- Desktop builds also accept the matching `Cmd` shortcuts on macOS, plus `Cmd/Ctrl+Shift+O` to pop out a pane.
119
+ Desktop builds also accept `Cmd/Ctrl+K` for the command bar, the matching `Cmd` shortcuts on macOS, `Cmd/Ctrl+Shift+O` to pop out a pane, and `Cmd/Ctrl+Shift+C` to copy a focused pane screenshot.
120
+
121
+ ## Command Reference
122
+
123
+ Use `HELP` inside Gloomberb for the live shortcut list. The common command-bar prefixes are listed here for quick scanning.
124
+
125
+ ### Company Research
126
+
127
+ | Shortcut | Function |
128
+ |----------|----------|
129
+ | `DES <ticker>` / `T <ticker>` | Security details for a ticker |
130
+ | `FA <ticker>` | Financial statement view |
131
+ | `GP <ticker>` | Price chart |
132
+ | `GIP <ticker>` | Intraday price chart |
133
+ | `HP <ticker>` | Historical OHLCV prices |
134
+ | `GF <tickers>` | Fundamental statement graph |
135
+ | `GE <tickers>` | Valuation multiple graph |
136
+ | `GR <tickers>` | Security relationship graph |
137
+ | `EE <ticker>` | Events view with earnings and revenue estimates |
138
+ | `EM [tickers]` | Earnings monitor |
139
+ | `SRCH <query>` | Provider symbol search |
140
+ | `QQ <tickers>` | Ticker quote monitor |
141
+ | `CMP <tickers>` | Ticker charts |
142
+ | `CORR <tickers>` | Ticker return correlations |
143
+ | `ANR <ticker>` | Analyst targets and ratings |
144
+ | `SEC <ticker>` | SEC filings and company disclosures |
145
+ | `OMON <ticker>` | Options monitor |
146
+ | `HDS <ticker>` | Institutional holders |
147
+ | `13F [fund/ticker/CIK]` | 13F fund filings and holdings |
148
+ | `INS <ticker>` | Insider activity |
149
+ | `EVT <ticker>` | Corporate actions, earnings, and estimates |
150
+ | `RV <tickers>` | Relative valuation |
151
+
152
+ ### Markets, News, and Macro
153
+
154
+ | Shortcut | Function |
155
+ |----------|----------|
156
+ | `TOP` | Ranked market stories |
157
+ | `MOST` | Top gainers, losers, most active, and trending tickers |
158
+ | `N` | News feed |
159
+ | `CN <ticker>` | Ticker news |
160
+ | `NI` | Sector news |
161
+ | `FIRST` | Breaking news |
162
+ | `TWIT <query>` | Ticker-related market posts |
163
+ | `WEI` | Global equity indices |
164
+ | `ECON` | Economic events and releases |
165
+ | `GC` | Yield curve |
166
+ | `ERN` | Earnings calendar |
167
+ | `BI` / `SP` | S&P 500 sector performance |
168
+ | `FXC` | Major FX cross rates |
169
+ | `FNG` | Fear and greed market gauge |
170
+
171
+ ### Workspace and App Controls
172
+
173
+ | Shortcut | Function |
174
+ |----------|----------|
175
+ | `PF` | Portfolio and watchlist workspace |
176
+ | `PORT` | Portfolio risk and sector exposure |
177
+ | `ALRT` | Price alerts |
178
+ | `SA <symbol condition price>` | Create a price alert |
179
+ | `PM <query>` | Polymarket and Kalshi prediction data |
180
+ | `AI <prompt>` | AI screener |
181
+ | `CHAT [channel]` | Gloom Cloud chat |
182
+ | `DM @user [@user...]` | Open or start a direct or group chat |
183
+ | `ACM` | Gloom Cloud account settings |
184
+ | `TBO` | TheBuildout infrastructure intelligence |
185
+ | `CG` | Congress trading disclosures |
186
+ | `NOTE` | Notes |
187
+ | `IBKR` | IBKR trading pane |
188
+ | `BR` | Broker connections |
189
+ | `CHG` | Changelog |
190
+ | `HELP` | Open shortcut and layout help |
191
+ | `AW` / `AP <ticker>` | Add a ticker to the active watchlist or portfolio |
192
+ | `RW` / `RP <ticker>` | Remove a ticker from the active watchlist or portfolio |
193
+ | `PS` | Open focused pane settings |
194
+ | `LAY <action>` | Open layout actions |
195
+ | `WIN move\|resize` | Move or resize the focused window |
196
+ | `GL` | Gridlock all visible panes |
197
+ | `SB` | Toggle the status bar |
198
+ | `VF` | Toggle quote value flashing |
199
+ | `TH <theme>` | Change color theme |
200
+ | `CR` | Cycle chart renderer |
201
+ | `PL <plugin>` | Manage plugins |
152
202
 
153
203
  ## License
154
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -93,6 +93,14 @@ export class CloudChatApi {
93
93
  return normalizeChatMessage(message);
94
94
  }
95
95
 
96
+ async editMessage(channelId: string, messageId: string, content: string): Promise<ChatMessage> {
97
+ const message = await this.options.request<ChatMessage>(`/chat/channels/${channelId}/messages/${messageId}`, {
98
+ method: "PATCH",
99
+ body: JSON.stringify({ content }),
100
+ });
101
+ return normalizeChatMessage(message);
102
+ }
103
+
96
104
  connectChannel(
97
105
  channelId: string,
98
106
  onMessage: (msg: ChatMessage) => void,
@@ -379,6 +379,36 @@ describe("apiClient chat timestamps", () => {
379
379
  expect(sentMessage.createdAt).toBe("2026-04-08T07:29:27.625Z");
380
380
  });
381
381
 
382
+ test("edits a chat message and normalizes the edit timestamp", async () => {
383
+ const requests: Array<{ path: string; method: string; body: unknown }> = [];
384
+ globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
385
+ requests.push({
386
+ path: new URL(String(input)).pathname,
387
+ method: init?.method ?? "GET",
388
+ body: init?.body ? JSON.parse(String(init.body)) : null,
389
+ });
390
+ return createResponse({
391
+ id: "m2",
392
+ channelId: "everyone",
393
+ content: "hello edited",
394
+ replyToId: null,
395
+ createdAt: "2026-04-08 07:29:27.625",
396
+ editedAt: "2026-04-08 07:30:27.625",
397
+ user: { id: "u1", username: "alice", displayName: "Alice" },
398
+ replyTo: null,
399
+ });
400
+ });
401
+
402
+ const editedMessage = await apiClient.editMessage("everyone", "m2", "hello edited");
403
+
404
+ expect(requests).toEqual([{
405
+ path: "/chat/channels/everyone/messages/m2",
406
+ method: "PATCH",
407
+ body: { content: "hello edited" },
408
+ }]);
409
+ expect(editedMessage.editedAt).toBe("2026-04-08T07:30:27.625Z");
410
+ });
411
+
382
412
  test("normalizes websocket chat timestamps before notifying listeners", async () => {
383
413
  const seenCreatedAts: string[] = [];
384
414
  const channel = apiClient.connectChannel("everyone", (message) => {
@@ -244,6 +244,10 @@ class GloomApiClient {
244
244
  return this.chat.sendMessage(channelId, content, replyToId, clientMessageId);
245
245
  }
246
246
 
247
+ async editMessage(channelId: string, messageId: string, content: string): Promise<ChatMessage> {
248
+ return this.chat.editMessage(channelId, messageId, content);
249
+ }
250
+
247
251
  connectChannel(
248
252
  channelId: string,
249
253
  onMessage: (msg: ChatMessage) => void,
@@ -12,6 +12,7 @@ export function normalizeChatMessage(message: ChatMessage): ChatMessage {
12
12
  return {
13
13
  ...message,
14
14
  createdAt: normalizeTimestamp(message.createdAt),
15
+ ...(message.editedAt ? { editedAt: normalizeTimestamp(message.editedAt) } : {}),
15
16
  };
16
17
  }
17
18
 
@@ -28,6 +28,7 @@ export interface ChatMessage {
28
28
  content: string;
29
29
  replyToId: string | null;
30
30
  createdAt: string;
31
+ editedAt?: string | null;
31
32
  user: ChatUserSummary;
32
33
  replyTo?: { content: string; user: { id?: string; username: string } } | null;
33
34
  clientStatus?: "sending" | "failed";
@@ -52,6 +52,10 @@ export function assetDataProvider(provider: AssetDataProvider): AssetDataCapabil
52
52
  if (!provider.getEarningsCalendar) throw new Error(`${provider.name} does not provide earnings calendar.`);
53
53
  return provider.getEarningsCalendar(input.symbols ?? [], input.context);
54
54
  }, "query"),
55
+ getSecFilingDocuments: op((input: any) => {
56
+ if (!provider.getSecFilingDocuments) throw new Error(`${provider.name} does not provide SEC filing documents.`);
57
+ return provider.getSecFilingDocuments(input.filing);
58
+ }, "query"),
55
59
  getSecFilingContent: op((input: any) => {
56
60
  if (!provider.getSecFilingContent) throw new Error(`${provider.name} does not provide SEC filing content.`);
57
61
  return provider.getSecFilingContent(input.filing);
@@ -57,6 +57,60 @@ describe("projectComparisonChartData", () => {
57
57
  expect(projection.series[1]?.points.map((point) => point.rawValue)).toEqual([null, null, 20, 22]);
58
58
  });
59
59
 
60
+ test("carries the latest series value across shared timestamps from other markets", () => {
61
+ const projection = projectComparisonChartData([
62
+ makeSeries("DAX", "EUR", [
63
+ ["2024-01-02T08:00:00.000Z", 100],
64
+ ["2024-01-02T08:15:00.000Z", 101],
65
+ ["2024-01-03T08:00:00.000Z", 102],
66
+ ]),
67
+ makeSeries("AAPL", "USD", [
68
+ ["2024-01-02T14:30:00.000Z", 200],
69
+ ["2024-01-02T14:45:00.000Z", 201],
70
+ ]),
71
+ ], 8, {
72
+ panOffset: 0,
73
+ zoomLevel: 1,
74
+ renderMode: "line",
75
+ }, "price");
76
+
77
+ expect(projection.dates.map((date) => date.toISOString())).toEqual([
78
+ "2024-01-02T08:00:00.000Z",
79
+ "2024-01-02T08:15:00.000Z",
80
+ "2024-01-02T14:30:00.000Z",
81
+ "2024-01-02T14:45:00.000Z",
82
+ "2024-01-03T08:00:00.000Z",
83
+ ]);
84
+ expect(projection.series[0]?.points.map((point) => point.rawValue)).toEqual([100, 101, 101, 101, 102]);
85
+ expect(projection.series[1]?.points.map((point) => point.rawValue)).toEqual([null, null, 200, 201, 201]);
86
+ });
87
+
88
+ test("uses the same alternating close bucket selection as stock chart projection", () => {
89
+ const projection = projectComparisonChartData([
90
+ makeSeries("DAX", "EUR", [
91
+ ["2024-01-02T08:00:00.000Z", 100],
92
+ ["2024-01-02T08:15:00.000Z", 105],
93
+ ["2024-01-02T14:30:00.000Z", 101],
94
+ ["2024-01-02T14:45:00.000Z", 99],
95
+ ]),
96
+ makeSeries("AAPL", "USD", [
97
+ ["2024-01-02T14:30:00.000Z", 200],
98
+ ["2024-01-02T14:45:00.000Z", 201],
99
+ ]),
100
+ ], 2, {
101
+ panOffset: 0,
102
+ zoomLevel: 1,
103
+ renderMode: "line",
104
+ }, "price");
105
+
106
+ expect(projection.dates.map((date) => date.toISOString())).toEqual([
107
+ "2024-01-02T08:15:00.000Z",
108
+ "2024-01-02T14:45:00.000Z",
109
+ ]);
110
+ expect(projection.series[0]?.points.map((point) => point.rawValue)).toEqual([105, 99]);
111
+ expect(projection.series[1]?.points.map((point) => point.rawValue)).toEqual([null, 200]);
112
+ });
113
+
60
114
  test("normalizes percent mode from each series first visible point", () => {
61
115
  const projection = projectComparisonChartData([
62
116
  makeSeries("AAPL", "USD", [
@@ -7,6 +7,11 @@ import type {
7
7
  } from "../core/types";
8
8
  import { buildVisibleDateWindowFromRange } from "../core/date-window";
9
9
  import { getVisiblePointCount } from "../core/viewport";
10
+ import {
11
+ buildIndexProjectionBuckets,
12
+ selectRepresentativeCloseValue,
13
+ type IndexProjectionBucket,
14
+ } from "../core/projection";
10
15
 
11
16
  export interface ComparisonProjectedPoint {
12
17
  date: Date;
@@ -38,6 +43,14 @@ interface ComparisonAxisModeResolution {
38
43
  warning: string | null;
39
44
  }
40
45
 
46
+ type ComparisonWindowViewState =
47
+ Pick<ComparisonChartViewState, "panOffset" | "zoomLevel">
48
+ & Partial<Pick<ComparisonChartViewState, "dateWindow">>;
49
+
50
+ type ComparisonProjectionViewState =
51
+ ComparisonWindowViewState
52
+ & Pick<ComparisonChartViewState, "renderMode">;
53
+
41
54
  export interface ComparisonChartProjection {
42
55
  dates: Date[];
43
56
  series: ComparisonProjectedSeries[];
@@ -92,7 +105,7 @@ export function getMaxComparisonPanOffset(
92
105
 
93
106
  export function getVisibleComparisonWindow(
94
107
  series: ComparisonChartSeries[],
95
- viewState: Pick<ComparisonChartViewState, "dateWindow" | "panOffset" | "zoomLevel">,
108
+ viewState: ComparisonWindowViewState,
96
109
  ): ComparisonVisibleWindow {
97
110
  const dates = getUniqueSortedDates(series);
98
111
 
@@ -138,10 +151,7 @@ function transformRawValue(rawValue: number | null, axisMode: ChartAxisMode, bas
138
151
  }
139
152
 
140
153
  function resolveRepresentativeValue(values: number[], index: number): number {
141
- if (values.length === 1) return values[0]!;
142
- return index % 2 === 0
143
- ? Math.max(...values)
144
- : Math.min(...values);
154
+ return selectRepresentativeCloseValue(values, index, (value) => value);
145
155
  }
146
156
 
147
157
  function buildSeriesMap(points: PricePoint[]): Map<number, number> {
@@ -155,6 +165,28 @@ function buildSeriesMap(points: PricePoint[]): Map<number, number> {
155
165
  return map;
156
166
  }
157
167
 
168
+ function buildFilledRawValuesByDate(
169
+ rawByDate: ReadonlyMap<number, number>,
170
+ dates: readonly Date[],
171
+ ): Array<number | null> {
172
+ let latestRawValue: number | null = null;
173
+ return dates.map((date) => {
174
+ const timestamp = date.getTime();
175
+ if (rawByDate.has(timestamp)) {
176
+ latestRawValue = rawByDate.get(timestamp)!;
177
+ return latestRawValue;
178
+ }
179
+ return latestRawValue;
180
+ });
181
+ }
182
+
183
+ function getBucketDate(
184
+ dates: readonly Date[],
185
+ bucket: IndexProjectionBucket,
186
+ ): Date {
187
+ return dates[Math.max(bucket.end - 1, 0)] ?? dates[dates.length - 1] ?? new Date(0);
188
+ }
189
+
158
190
  function resolveComparisonAxisMode(
159
191
  requestedAxisMode: ChartAxisMode,
160
192
  series: ComparisonChartSeries[],
@@ -194,7 +226,7 @@ function resolveComparisonAxisMode(
194
226
  export function projectComparisonChartData(
195
227
  series: ComparisonChartSeries[],
196
228
  chartWidth: number,
197
- viewState: Pick<ComparisonChartViewState, "dateWindow" | "panOffset" | "zoomLevel" | "renderMode">,
229
+ viewState: ComparisonProjectionViewState,
198
230
  requestedAxisMode: ChartAxisMode,
199
231
  ): ComparisonChartProjection {
200
232
  const normalizedSeries = series.map((entry) => ({ ...entry, points: normalizeSeriesPoints(entry.points) }));
@@ -221,31 +253,26 @@ export function projectComparisonChartData(
221
253
  warning: axisResolution.warning,
222
254
  };
223
255
  }
224
- const bucketCount = Math.min(Math.max(chartWidth, 1), Math.max(window.dates.length, 1));
225
- const bucketSize = window.dates.length > 0 ? window.dates.length / bucketCount : 1;
256
+ const buckets = buildIndexProjectionBuckets(window.dates.length, chartWidth);
226
257
 
227
258
  const projectedSeries = normalizedSeries.map((entry) => {
228
259
  const rawByDate = buildSeriesMap(entry.points);
229
- const visibleRawValues = window.dates
230
- .map((date) => rawByDate.get(date.getTime()) ?? null)
260
+ const filledRawValues = buildFilledRawValuesByDate(rawByDate, window.dates);
261
+ const visibleRawValues = filledRawValues
231
262
  .filter((value): value is number => value !== null);
232
263
  const baseValue = visibleRawValues[0] ?? null;
233
264
  const latestRawValue = visibleRawValues[visibleRawValues.length - 1] ?? null;
234
265
 
235
- const points = Array.from({ length: bucketCount }, (_, index) => {
236
- const start = Math.floor(index * bucketSize);
237
- const end = Math.min(window.dates.length, Math.max(Math.floor((index + 1) * bucketSize), start + 1));
238
- const bucketDates = window.dates.slice(start, end);
239
- const date = bucketDates[bucketDates.length - 1] ?? window.dates[window.dates.length - 1] ?? new Date(0);
240
- const bucketRawValues = bucketDates
241
- .map((bucketDate) => rawByDate.get(bucketDate.getTime()) ?? null)
266
+ const points = buckets.map((bucket) => {
267
+ const bucketRawValues = filledRawValues
268
+ .slice(bucket.start, bucket.end)
242
269
  .filter((value): value is number => value !== null);
243
270
  const rawValue = bucketRawValues.length > 0
244
- ? resolveRepresentativeValue(bucketRawValues, index)
271
+ ? resolveRepresentativeValue(bucketRawValues, bucket.index)
245
272
  : null;
246
273
 
247
274
  return {
248
- date,
275
+ date: getBucketDate(window.dates, bucket),
249
276
  rawValue,
250
277
  value: transformRawValue(rawValue, axisResolution.effectiveAxisMode, baseValue),
251
278
  };
@@ -264,11 +291,7 @@ export function projectComparisonChartData(
264
291
  });
265
292
 
266
293
  return {
267
- dates: Array.from({ length: bucketCount }, (_, index) => {
268
- const start = Math.floor(index * bucketSize);
269
- const end = Math.min(window.dates.length, Math.max(Math.floor((index + 1) * bucketSize), start + 1));
270
- return window.dates[Math.max(end - 1, 0)] ?? new Date(0);
271
- }),
294
+ dates: buckets.map((bucket) => getBucketDate(window.dates, bucket)),
272
295
  series: projectedSeries,
273
296
  requestedMode,
274
297
  effectiveMode: requestedMode,
@@ -24,6 +24,12 @@ export interface ProjectChartDataOptions {
24
24
  sourceIndexOffset?: number;
25
25
  }
26
26
 
27
+ export interface IndexProjectionBucket {
28
+ index: number;
29
+ start: number;
30
+ end: number;
31
+ }
32
+
27
33
  interface StableOhlcProjectionOptionsInput {
28
34
  pointCount: number;
29
35
  sourceIndexOffset: number;
@@ -131,6 +137,44 @@ function aggregateOhlcBucket(bucket: readonly ProjectedChartPoint[]): ProjectedC
131
137
  };
132
138
  }
133
139
 
140
+ export function buildIndexProjectionBuckets(
141
+ itemCount: number,
142
+ targetWidth: number,
143
+ ): IndexProjectionBucket[] {
144
+ const normalizedItemCount = Math.max(Math.floor(itemCount), 0);
145
+ if (normalizedItemCount === 0) return [];
146
+
147
+ const bucketCount = Math.min(normalizedItemCount, Math.max(Math.floor(targetWidth), 1));
148
+ const bucketSize = normalizedItemCount / bucketCount;
149
+
150
+ return Array.from({ length: bucketCount }, (_, index) => {
151
+ const start = Math.floor(index * bucketSize);
152
+ const end = Math.min(
153
+ normalizedItemCount,
154
+ Math.max(Math.floor((index + 1) * bucketSize), start + 1),
155
+ );
156
+ return { index, start, end };
157
+ });
158
+ }
159
+
160
+ export function selectRepresentativeCloseValue<T>(
161
+ bucket: readonly T[],
162
+ index: number,
163
+ getClose: (item: T) => number,
164
+ ): T {
165
+ let best = bucket[0] as T;
166
+ if (index % 2 === 0) {
167
+ for (const item of bucket) {
168
+ if (getClose(item) > getClose(best)) best = item;
169
+ }
170
+ } else {
171
+ for (const item of bucket) {
172
+ if (getClose(item) < getClose(best)) best = item;
173
+ }
174
+ }
175
+ return best;
176
+ }
177
+
134
178
  function bucketOhlcBySourceIndex(
135
179
  normalizedPoints: readonly ProjectedChartPoint[],
136
180
  bucketSize: number,
@@ -202,33 +246,13 @@ function projectCloseSeries(
202
246
  targetWidth: number,
203
247
  ): ProjectedChartPoint[] {
204
248
  if (points.length === 0) return [];
205
- if (points.length <= targetWidth) return points.map(normalizePoint);
206
-
207
- const result: ProjectedChartPoint[] = [];
208
- const bucketSize = points.length / targetWidth;
209
-
210
- for (let i = 0; i < targetWidth; i++) {
211
- const start = Math.floor(i * bucketSize);
212
- const end = Math.min(points.length, Math.max(Math.floor((i + 1) * bucketSize), start + 1));
213
- const bucket = points.slice(start, end);
214
-
215
- if (bucket.length === 0) continue;
216
-
217
- // Alternate bucket extremes to preserve line/area shape after downsampling.
218
- let best = bucket[0]!;
219
- if (i % 2 === 0) {
220
- for (const point of bucket) {
221
- if (point.close > best.close) best = point;
222
- }
223
- } else {
224
- for (const point of bucket) {
225
- if (point.close < best.close) best = point;
226
- }
227
- }
228
- result.push(normalizePoint(best));
229
- }
230
-
231
- return result;
249
+ return buildIndexProjectionBuckets(points.length, targetWidth).map((bucket) => (
250
+ normalizePoint(selectRepresentativeCloseValue(
251
+ points.slice(bucket.start, bucket.end),
252
+ bucket.index,
253
+ (point) => point.close,
254
+ ))
255
+ ));
232
256
  }
233
257
 
234
258
  /**
@@ -67,18 +67,18 @@ function Harness() {
67
67
  )
68
68
  }
69
69
  detailTitle={openRow?.title}
70
- selectedIndex={selectedIndex}
71
- onSelectIndex={(index) => setSelectedIndex(index)}
72
- onActivateIndex={(_index, row) => setOpenRow(row)}
70
+ selection={{
71
+ kind: "index",
72
+ selectedIndex,
73
+ onChange: (index) => setSelectedIndex(index),
74
+ }}
75
+ onActivate={(row) => setOpenRow(row)}
73
76
  columns={columns}
74
77
  items={rows}
75
78
  sortColumnId={null}
76
79
  sortDirection="asc"
77
80
  onHeaderClick={() => {}}
78
81
  getItemKey={(row) => row.id}
79
- isSelected={(_row, index) => index === selectedIndex}
80
- onSelect={(_row, index) => setSelectedIndex(index)}
81
- onActivate={(row) => setOpenRow(row)}
82
82
  renderCell={(row): DataTableCell => ({ text: row.title })}
83
83
  emptyStateTitle="No rows"
84
84
  showHorizontalScrollbar={false}
@@ -90,13 +90,14 @@ function Harness() {
90
90
 
91
91
  function GroupedHarness() {
92
92
  const [selectedIndex, setSelectedIndex] = useState(0);
93
+ const [cursorIndex, setCursorIndex] = useState(0);
93
94
  const state = createInitialState(
94
95
  createDefaultConfig("/tmp/gloomberb-data-table-stack-view-grouped-test"),
95
96
  );
96
97
  const columns: Column[] = [
97
98
  { id: "title", label: "Title", width: 20, align: "left" },
98
99
  ];
99
- const selectedTitle = groupedRows[selectedIndex]?.title ?? "none";
100
+ const selectedTitle = groupedRows[cursorIndex]?.title ?? "none";
100
101
 
101
102
  return (
102
103
  <AppContext value={{ state, dispatch: () => {} }}>
@@ -106,8 +107,12 @@ function GroupedHarness() {
106
107
  detailOpen={false}
107
108
  onBack={() => {}}
108
109
  detailContent={<Box flexGrow={1} />}
109
- selectedIndex={selectedIndex}
110
- onSelectIndex={(index) => setSelectedIndex(index)}
110
+ selection={{
111
+ kind: "index",
112
+ selectedIndex,
113
+ onChange: (index) => setSelectedIndex(index),
114
+ }}
115
+ onCursorChange={(_row, index) => setCursorIndex(index)}
111
116
  isNavigable={(row) => row.type !== "section"}
112
117
  rootAfter={
113
118
  <Box height={1}>
@@ -120,8 +125,6 @@ function GroupedHarness() {
120
125
  sortDirection="asc"
121
126
  onHeaderClick={() => {}}
122
127
  getItemKey={(row) => row.id}
123
- isSelected={(_row, index) => index === selectedIndex}
124
- onSelect={(_row, index) => setSelectedIndex(index)}
125
128
  renderSectionHeader={(row) => row.type === "section"
126
129
  ? { text: row.title }
127
130
  : null}
@@ -10,13 +10,12 @@ import { PageStackView, type DataTableColumn } from "../ui";
10
10
  export interface DataTableStackViewProps<
11
11
  T,
12
12
  C extends DataTableColumn = DataTableColumn,
13
- > extends Omit<DataTableViewProps<T, C>, "focused" | "selectedIndex"> {
13
+ > extends Omit<DataTableViewProps<T, C>, "focused"> {
14
14
  focused: boolean;
15
15
  detailOpen: boolean;
16
16
  onBack: () => void;
17
17
  detailContent: ReactNode;
18
18
  detailTitle?: string;
19
- selectedIndex: number;
20
19
  onDetailKeyDown?: (event: DataTableKeyEvent) => boolean | void;
21
20
  }
22
21