gloomberb 0.4.1 → 0.5.1

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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  # 📉 Gloomberb
4
4
 
5
- **Extensible financial terminal, for the terminal.**
5
+ **Modern extensible financial terminal.**
6
6
 
7
- > Why pay for Bloomberg when you can have Gloomberb?
7
+ > The Bloomberb Terminal for the rest of us.
8
8
 
9
9
  <img src="https://gloomberb.com/landing-terminal.png" alt="Gloomberb terminal screenshot" width="720" />
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { useKeyboard, useRenderer } from "@opentui/react";
3
3
  import type { CliRenderer } from "@opentui/core";
4
4
  import {
@@ -10,6 +10,7 @@ import {
10
10
  useAppState,
11
11
  } from "./state/app-context";
12
12
  import { bindAppActivity, useAppActive } from "./state/app-activity";
13
+ import { copyActiveSelection, isCopyShortcut, isPasteShortcut, pasteSystemClipboard } from "./utils/selection-clipboard";
13
14
  import { Header } from "./components/layout/header";
14
15
  import { StatusBar } from "./components/layout/status-bar";
15
16
  import { Shell } from "./components/layout/shell";
@@ -20,12 +21,15 @@ import { PluginRegistry } from "./plugins/registry";
20
21
  import { AppPersistence } from "./data/app-persistence";
21
22
  import { TickerRepository } from "./data/ticker-repository";
22
23
  import { ProviderRouter } from "./sources/provider-router";
23
- import { colors, applyTheme } from "./theme/colors";
24
+ import { colors, syncTheme } from "./theme/colors";
24
25
  import {
25
26
  createPaneInstance,
26
27
  findPaneInstance,
28
+ findPrimaryPaneInstance,
27
29
  isTickerPaneId,
28
30
  normalizePaneLayout,
31
+ resolveFollowBindingInstance,
32
+ resolvePaneInstance,
29
33
  type AppConfig,
30
34
  type BrokerInstanceConfig,
31
35
  type LayoutConfig,
@@ -93,6 +97,7 @@ import { debugLog } from "./utils/debug-log";
93
97
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./market-data/coordinator";
94
98
  import { instrumentFromTicker } from "./market-data/request-types";
95
99
  import { syncBrokerInstance } from "./brokers/sync-broker-instance";
100
+ import { createAppNotifier } from "./notifications/app-notifier";
96
101
 
97
102
  /** Global-level dedup: prevents concurrent refresh calls for the same symbol. */
98
103
  const refreshInFlight: Set<string> = (globalThis as any).__refreshInFlight ??= new Set<string>();
@@ -100,6 +105,14 @@ const quoteRefreshInFlight: Set<string> = (globalThis as any).__quoteRefreshInFl
100
105
  const PANEL_RESOLUTION_BOUNDS = { x: 0, y: 0, width: 120, height: 40 };
101
106
  const appLog = debugLog.createLogger("app");
102
107
 
108
+ function isCollectionPaneInstance(instance: PaneInstanceConfig): boolean {
109
+ return instance.paneId === "portfolio-list";
110
+ }
111
+
112
+ function isTickerContextPaneInstance(instance: PaneInstanceConfig): boolean {
113
+ return instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId);
114
+ }
115
+
103
116
  function summarizeError(error: unknown): Record<string, string> {
104
117
  if (error instanceof Error) {
105
118
  return {
@@ -122,30 +135,45 @@ interface AppInnerProps {
122
135
  function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData, sessionSnapshot = null }: AppInnerProps) {
123
136
  const { state, dispatch } = useAppState();
124
137
  const appActive = useAppActive();
138
+ const appActiveRef = useRef(appActive);
125
139
  const renderer = useRenderer();
126
140
  const dialog = useDialog();
127
141
  const stateRef = useRef(state);
128
142
  stateRef.current = state;
143
+ appActiveRef.current = appActive;
129
144
  const focusedCollectionId = getFocusedCollectionId(state);
130
-
131
- const resolvePrimaryPaneInstanceId = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
132
- const instances = layout.instances.filter((instance) => instance.paneId === paneId);
133
- if (instances.length === 0) return null;
134
- if (isTickerPaneId(paneId)) {
135
- return instances.find((instance) =>
136
- instance.instanceId === `${paneId}:main` && instance.binding?.kind !== "fixed",
137
- )?.instanceId
138
- ?? instances.find((instance) => instance.binding?.kind !== "fixed")?.instanceId
139
- ?? null;
140
- }
141
- return instances[0]?.instanceId ?? null;
142
- }, [state.config.layout]);
145
+ const appNotifier = useMemo(() => createAppNotifier({
146
+ isAppActive: () => appActiveRef.current,
147
+ renderToast: (notification) => {
148
+ const type = notification.type ?? "info";
149
+ let toastId: string | number | undefined;
150
+ const options = {
151
+ duration: notification.duration,
152
+ action: notification.action
153
+ ? {
154
+ label: notification.action.label,
155
+ onClick: () => {
156
+ try {
157
+ notification.action?.onClick();
158
+ } finally {
159
+ if (toastId != null) toast.dismiss(toastId);
160
+ }
161
+ },
162
+ }
163
+ : undefined,
164
+ };
165
+ if (type === "success") toastId = toast.success(notification.body, options);
166
+ else if (type === "error") toastId = toast.error(notification.body, options);
167
+ else toastId = toast.info(notification.body, options);
168
+ },
169
+ }), []);
170
+ const notify = useCallback((body: string, options?: { type?: "info" | "success" | "error" }) => {
171
+ pluginRegistry.notify({ body, ...options });
172
+ }, [pluginRegistry]);
143
173
 
144
174
  const resolvePaneTarget = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
145
- const byInstance = layout.instances.find((instance) => instance.instanceId === paneId);
146
- if (byInstance) return byInstance.instanceId;
147
- return resolvePrimaryPaneInstanceId(paneId, layout);
148
- }, [resolvePrimaryPaneInstanceId, state.config.layout]);
175
+ return resolvePaneInstance(layout, paneId)?.instanceId ?? null;
176
+ }, [state.config.layout]);
149
177
 
150
178
  const getPreferredPortfolio = useCallback((ticker: TickerRecord | null) => {
151
179
  const focusedPortfolio = state.config.portfolios.find((portfolio) => portfolio.id === focusedCollectionId);
@@ -197,39 +225,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
197
225
  }, [dialog]);
198
226
 
199
227
  const resolveCollectionSourcePaneId = useCallback((preferredPaneId?: string | null) => {
200
- const tryResolve = (candidate: string | null | undefined): string | null => {
201
- if (!candidate) return null;
202
- const instance = findPaneInstance(state.config.layout, candidate);
203
- if (!instance) return null;
204
- if (instance.paneId === "portfolio-list") return instance.instanceId;
205
- if (instance.binding?.kind === "follow") {
206
- return tryResolve(instance.binding.sourceInstanceId);
207
- }
208
- return null;
209
- };
210
-
211
- return tryResolve(preferredPaneId)
212
- ?? tryResolve(state.focusedPaneId)
213
- ?? state.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
228
+ return resolveFollowBindingInstance(state.config.layout, preferredPaneId, isCollectionPaneInstance)?.instanceId
229
+ ?? resolveFollowBindingInstance(state.config.layout, state.focusedPaneId, isCollectionPaneInstance)?.instanceId
230
+ ?? findPrimaryPaneInstance(state.config.layout, "portfolio-list")?.instanceId
214
231
  ?? null;
215
232
  }, [state.config.layout, state.focusedPaneId]);
216
233
 
217
234
  const resolveTickerContextSourcePaneId = useCallback((preferredPaneId?: string | null) => {
218
- const tryResolve = (candidate: string | null | undefined): string | null => {
219
- if (!candidate) return null;
220
- const instance = findPaneInstance(state.config.layout, candidate);
221
- if (!instance) return null;
222
- if (instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId)) return instance.instanceId;
223
- if (instance.binding?.kind === "follow") {
224
- return tryResolve(instance.binding.sourceInstanceId);
225
- }
226
- return null;
227
- };
228
-
229
- return tryResolve(preferredPaneId)
230
- ?? tryResolve(state.focusedPaneId)
231
- ?? state.config.layout.instances.find((instance) => instance.paneId === "ticker-detail" && instance.binding?.kind !== "fixed")?.instanceId
232
- ?? state.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
235
+ return resolveFollowBindingInstance(state.config.layout, preferredPaneId, isTickerContextPaneInstance)?.instanceId
236
+ ?? resolveFollowBindingInstance(state.config.layout, state.focusedPaneId, isTickerContextPaneInstance)?.instanceId
237
+ ?? findPrimaryPaneInstance(state.config.layout, "ticker-detail")?.instanceId
238
+ ?? findPrimaryPaneInstance(state.config.layout, "portfolio-list")?.instanceId
233
239
  ?? null;
234
240
  }, [state.config.layout, state.focusedPaneId]);
235
241
 
@@ -273,9 +279,9 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
273
279
 
274
280
  const switchDetailTab = useCallback((tabId: string, preferredPaneId?: string | null) => {
275
281
  const targetPaneId = (() => {
276
- const target = preferredPaneId ? findPaneInstance(state.config.layout, preferredPaneId) : null;
282
+ const target = preferredPaneId ? resolvePaneInstance(state.config.layout, preferredPaneId) : null;
277
283
  if (target?.paneId === "ticker-detail") return target.instanceId;
278
- const focused = state.focusedPaneId ? findPaneInstance(state.config.layout, state.focusedPaneId) : null;
284
+ const focused = state.focusedPaneId ? resolvePaneInstance(state.config.layout, state.focusedPaneId) : null;
279
285
  if (focused?.paneId === "ticker-detail") return focused.instanceId;
280
286
  const sourcePaneId = resolveCollectionSourcePaneId(preferredPaneId);
281
287
  if (!sourcePaneId) return null;
@@ -796,6 +802,14 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
796
802
  pluginRegistry.switchPanelFn = (panel) => dispatch({ type: "SET_ACTIVE_PANEL", panel });
797
803
  pluginRegistry.switchTabFn = (tabId, paneId) => switchDetailTab(tabId, paneId);
798
804
  pluginRegistry.openCommandBarFn = (query) => dispatch({ type: "SET_COMMAND_BAR", open: true, query });
805
+ pluginRegistry.openPluginCommandWorkflowFn = (commandId) => {
806
+ dispatch({
807
+ type: "SET_COMMAND_BAR",
808
+ open: true,
809
+ query: "",
810
+ launch: { kind: "plugin-command", commandId },
811
+ });
812
+ };
799
813
  const persistLayout = (layout: LayoutConfig, options?: { pushHistory?: boolean }) => {
800
814
  const currentState = stateRef.current;
801
815
  const normalizedLayout = normalizePaneLayout(layout);
@@ -894,7 +908,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
894
908
  if (paneId === "ticker-detail") {
895
909
  const sourcePaneId = resolveCollectionSourcePaneId();
896
910
  if (!sourcePaneId) {
897
- pluginRegistry.showToastFn("Open a collection pane first to inspect a ticker.");
911
+ notify("Open a collection pane first to inspect a ticker.");
898
912
  return;
899
913
  }
900
914
  const ensured = ensureInspectorPane(sourcePaneId);
@@ -917,7 +931,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
917
931
  : buildPaneInstance(paneId);
918
932
  if (!instance) {
919
933
  if (isTickerPaneId(paneId)) {
920
- pluginRegistry.showToastFn("Open a ticker or collection context first.");
934
+ notify("Open a ticker or collection context first.");
921
935
  }
922
936
  return;
923
937
  }
@@ -960,7 +974,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
960
974
  placePaneInstance,
961
975
  });
962
976
  } catch (error) {
963
- pluginRegistry.showToastFn(
977
+ notify(
964
978
  error instanceof Error ? error.message : `Could not create ${getPaneTemplateDisplayLabel(template).toLowerCase()}.`,
965
979
  { type: "info" },
966
980
  );
@@ -969,6 +983,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
969
983
  buildPaneInstance,
970
984
  dataProvider,
971
985
  dispatch,
986
+ notify,
972
987
  placePaneInstance,
973
988
  pluginRegistry,
974
989
  runPaneTemplateWizard,
@@ -1080,6 +1095,90 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1080
1095
  activatePane(instance.instanceId, nextLayout);
1081
1096
  };
1082
1097
 
1098
+ pluginRegistry.navigateTickerFn = (rawSymbol) => {
1099
+ (async () => {
1100
+ try {
1101
+ // Resolve or create the ticker in the local database
1102
+ const resolved = await resolveTickerSearch({
1103
+ query: rawSymbol,
1104
+ activeTicker: null,
1105
+ tickers: stateRef.current.tickers,
1106
+ dataProvider,
1107
+ });
1108
+
1109
+ let symbol = rawSymbol;
1110
+ if (resolved?.kind === "local") {
1111
+ symbol = resolved.symbol;
1112
+ } else if (resolved?.kind === "provider" && resolved.result) {
1113
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1114
+ symbol = ticker.metadata.ticker;
1115
+ dispatch({ type: "UPDATE_TICKER", ticker });
1116
+ if (created) {
1117
+ pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1118
+ }
1119
+ }
1120
+
1121
+ // Active panel resolution — navigate the focused or linked detail pane:
1122
+ // 1. If the focused pane IS a ticker-detail, retarget it directly
1123
+ // 2. If a ticker-detail follows the focused pane, retarget that
1124
+ // 3. Any follow-mode ticker-detail in the layout
1125
+ // 4. Any ticker-detail in the layout
1126
+ // 5. Fall back to pinning a new pane
1127
+ const currentState = stateRef.current;
1128
+ const currentLayout = currentState.config.layout;
1129
+ const focused = currentState.focusedPaneId;
1130
+
1131
+ const focusedInstance = focused
1132
+ ? findPaneInstance(currentLayout, focused)
1133
+ : null;
1134
+
1135
+ const detailPane =
1136
+ (focusedInstance?.paneId === "ticker-detail" && isPaneInLayout(currentLayout, focusedInstance.instanceId)
1137
+ ? focusedInstance
1138
+ : null)
1139
+ ?? currentLayout.instances.find((inst) =>
1140
+ inst.paneId === "ticker-detail"
1141
+ && inst.binding?.kind === "follow"
1142
+ && inst.binding.sourceInstanceId === focused
1143
+ && isPaneInLayout(currentLayout, inst.instanceId),
1144
+ )
1145
+ ?? currentLayout.instances.find((inst) =>
1146
+ inst.paneId === "ticker-detail"
1147
+ && inst.binding?.kind === "follow"
1148
+ && isPaneInLayout(currentLayout, inst.instanceId),
1149
+ )
1150
+ ?? currentLayout.instances.find((inst) =>
1151
+ inst.paneId === "ticker-detail"
1152
+ && isPaneInLayout(currentLayout, inst.instanceId),
1153
+ );
1154
+
1155
+ if (detailPane) {
1156
+ if (detailPane.binding?.kind === "follow") {
1157
+ const sourceId = detailPane.binding.sourceInstanceId;
1158
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: sourceId, patch: { cursorSymbol: symbol } });
1159
+ activatePane(detailPane.instanceId, currentLayout);
1160
+ } else {
1161
+ const nextLayout = {
1162
+ ...currentLayout,
1163
+ instances: currentLayout.instances.map((instance) => (
1164
+ instance.instanceId === detailPane.instanceId
1165
+ ? { ...instance, title: symbol, binding: { kind: "fixed" as const, symbol } }
1166
+ : instance
1167
+ )),
1168
+ };
1169
+ persistLayout(nextLayout);
1170
+ activatePane(detailPane.instanceId, nextLayout);
1171
+ }
1172
+ } else {
1173
+ pluginRegistry.pinTickerFn(symbol, { floating: false });
1174
+ }
1175
+ } catch (err) {
1176
+ const message = err instanceof Error ? err.message : String(err);
1177
+ pluginRegistry.notify({ body: `Failed to navigate to ${rawSymbol}: ${message}`, type: "error" });
1178
+ }
1179
+ })();
1180
+ };
1181
+
1083
1182
  setLayoutManagerDispatch(dispatch, () => ({
1084
1183
  layout: state.config.layout,
1085
1184
  termWidth: pluginRegistry.getTermSizeFn().width,
@@ -1087,14 +1186,8 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1087
1186
  focusedPaneId: state.focusedPaneId,
1088
1187
  }));
1089
1188
 
1090
- // Wire up toast
1091
- pluginRegistry.showToastFn = (message, options) => {
1092
- const type = options?.type ?? "info";
1093
- const duration = options?.duration;
1094
- if (type === "success") toast.success(message, { duration });
1095
- else if (type === "error") toast.error(message, { duration });
1096
- else toast.info(message, { duration });
1097
- };
1189
+ // Wire up app-level notifications.
1190
+ pluginRegistry.notifyFn = appNotifier.notify;
1098
1191
 
1099
1192
  // Persist layout changes (switching, saving, deleting, renaming layouts)
1100
1193
  const prevLayouts = useRef(state.config.layouts);
@@ -1124,6 +1217,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1124
1217
 
1125
1218
  // Global keyboard shortcuts
1126
1219
  useKeyboard((event) => {
1220
+ if (isCopyShortcut(event) && copyActiveSelection(renderer)) {
1221
+ event.preventDefault();
1222
+ event.stopPropagation();
1223
+ return;
1224
+ }
1225
+ if (isPasteShortcut(event) && pasteSystemClipboard(renderer)) {
1226
+ event.preventDefault();
1227
+ event.stopPropagation();
1228
+ return;
1229
+ }
1230
+
1127
1231
  // Skip all global shortcuts when a dialog is open
1128
1232
  if (dialogOpen) return;
1129
1233
 
@@ -1250,11 +1354,10 @@ export function App({
1250
1354
  });
1251
1355
  const [showOnboarding, setShowOnboarding] = useState(!initialConfig.onboardingComplete);
1252
1356
 
1253
- useEffect(() => bindAppActivity(renderer), [renderer]);
1357
+ // Keep the shared palette aligned before this render builds any JSX that reads `colors`.
1358
+ syncTheme(config.theme);
1254
1359
 
1255
- useLayoutEffect(() => {
1256
- if (config.theme) applyTheme(config.theme);
1257
- }, [config.theme]);
1360
+ useEffect(() => bindAppActivity(renderer), [renderer]);
1258
1361
 
1259
1362
  const services = useMemo(() => {
1260
1363
  const dbPath = join(config.dataDir, ".gloomberb-cache.db");
@@ -4,6 +4,7 @@ import {
4
4
  formatNumber,
5
5
  formatPercent,
6
6
  } from "../../utils/format";
7
+ import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity } from "../../utils/market-format";
7
8
  import {
8
9
  cliStyles,
9
10
  colorBySign,
@@ -77,6 +78,30 @@ function appendTextSection(lines: string[], title: string, content: string | und
77
78
  lines.push(text);
78
79
  }
79
80
 
81
+ function normalizeTimestamp(value: Date | string | number | undefined): number | null {
82
+ if (value instanceof Date) {
83
+ const timestamp = value.getTime();
84
+ return Number.isNaN(timestamp) ? null : timestamp;
85
+ }
86
+
87
+ if (typeof value === "string" || typeof value === "number") {
88
+ const timestamp = new Date(value).getTime();
89
+ return Number.isNaN(timestamp) ? null : timestamp;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ function formatFeedDate(value: Date | string | number | undefined): string {
96
+ const timestamp = normalizeTimestamp(value);
97
+ if (timestamp == null) return "";
98
+ return new Date(timestamp).toLocaleDateString("en-US", {
99
+ month: "short",
100
+ day: "numeric",
101
+ year: "numeric",
102
+ });
103
+ }
104
+
80
105
  function appendFeedSection(
81
106
  lines: string[],
82
107
  title: string,
@@ -215,16 +240,16 @@ export async function buildTickerReport({
215
240
  : "—";
216
241
 
217
242
  appendMetricSection(lines, "Quote", [
218
- ["Last", colorBySign(formatCurrency(quote.price, quote.currency), quote.change)],
243
+ ["Last", colorBySign(formatMarketPriceWithCurrency(quote.price, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }), quote.change)],
219
244
  ["Change", colorBySign(`${formatSignedCurrency(quote.change, quote.currency)} (${formatSignedPercentRaw(quote.changePercent)})`, quote.change)],
220
- ["Open", quote.open != null ? formatCurrency(quote.open, quote.currency) : "—"],
245
+ ["Open", quote.open != null ? formatMarketPriceWithCurrency(quote.open, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"],
221
246
  ["Day Range", quote.low != null || quote.high != null
222
- ? `${quote.low != null ? formatCurrency(quote.low, quote.currency) : "—"} - ${quote.high != null ? formatCurrency(quote.high, quote.currency) : "—"}`
247
+ ? `${quote.low != null ? formatMarketPriceWithCurrency(quote.low, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"} - ${quote.high != null ? formatMarketPriceWithCurrency(quote.high, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"}`
223
248
  : "—"],
224
249
  ["52W Range", quote.low52w != null || quote.high52w != null
225
- ? `${quote.low52w != null ? formatCurrency(quote.low52w, quote.currency) : "—"} - ${quote.high52w != null ? formatCurrency(quote.high52w, quote.currency) : "—"}`
250
+ ? `${quote.low52w != null ? formatMarketPriceWithCurrency(quote.low52w, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"} - ${quote.high52w != null ? formatMarketPriceWithCurrency(quote.high52w, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"}`
226
251
  : "—"],
227
- ["Bid / Ask", formatBidAsk(quote.bid, quote.ask, quote.bidSize, quote.askSize, quote.currency)],
252
+ ["Bid / Ask", formatBidAsk(quote.bid, quote.ask, quote.bidSize, quote.askSize, quote.currency, tickerFile?.metadata.assetCategory)],
228
253
  ["Volume", quote.volume != null ? formatNumber(quote.volume, 0) : "—"],
229
254
  ["Updated", formatTimestamp(quote.lastUpdated)],
230
255
  ]);
@@ -232,13 +257,13 @@ export async function buildTickerReport({
232
257
  appendMetricSection(lines, "Extended Hours", [
233
258
  ["Pre-Market", quote.preMarketPrice != null
234
259
  ? colorBySign(
235
- `${formatCurrency(quote.preMarketPrice, quote.currency)} (${formatSignedPercentRaw(quote.preMarketChangePercent ?? 0)})`,
260
+ `${formatMarketPriceWithCurrency(quote.preMarketPrice, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory })} (${formatSignedPercentRaw(quote.preMarketChangePercent ?? 0)})`,
236
261
  quote.preMarketChange ?? 0,
237
262
  )
238
263
  : "—"],
239
264
  ["After Hours", quote.postMarketPrice != null
240
265
  ? colorBySign(
241
- `${formatCurrency(quote.postMarketPrice, quote.currency)} (${formatSignedPercentRaw(quote.postMarketChangePercent ?? 0)})`,
266
+ `${formatMarketPriceWithCurrency(quote.postMarketPrice, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory })} (${formatSignedPercentRaw(quote.postMarketChangePercent ?? 0)})`,
242
267
  quote.postMarketChange ?? 0,
243
268
  )
244
269
  : "—"],
@@ -288,18 +313,20 @@ export async function buildTickerReport({
288
313
  title: item.title,
289
314
  meta: [
290
315
  item.source,
291
- Number.isNaN(item.publishedAt.getTime()) ? "" : formatTimestamp(item.publishedAt.getTime()),
316
+ (() => {
317
+ const publishedAt = normalizeTimestamp(item.publishedAt as Date | string | number | undefined);
318
+ return publishedAt == null ? "" : formatTimestamp(publishedAt);
319
+ })(),
292
320
  ],
293
321
  body: item.summary,
294
322
  link: item.url,
295
323
  })));
296
324
 
297
325
  appendFeedSection(lines, "Recent SEC Filings", recentSecFilings.map((filing) => ({
298
- title: `${filing.form} | ${filing.filingDate.toLocaleDateString("en-US", {
299
- month: "short",
300
- day: "numeric",
301
- year: "numeric",
302
- })}`,
326
+ title: (() => {
327
+ const filingDate = formatFeedDate(filing.filingDate as Date | string | number | undefined);
328
+ return filingDate ? `${filing.form} | ${filingDate}` : filing.form;
329
+ })(),
303
330
  meta: [
304
331
  filing.items ? `Items ${filing.items}` : "",
305
332
  getFilingDescription(filing) ?? "",
@@ -320,12 +347,15 @@ export async function buildTickerReport({
320
347
  const pnl = marketValueBase - costBasisBase;
321
348
 
322
349
  lines.push(cliStyles.bold(`${portfolioName} (${position.broker})`));
323
- lines.push(renderStat("Position", `${position.shares} ${multiplier > 1 ? "contracts" : "shares"} @ ${formatCurrency(position.avgCost, positionCurrency)}`));
350
+ lines.push(renderStat(
351
+ "Position",
352
+ `${formatMarketQuantity(position.shares, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })} ${multiplier > 1 ? "contracts" : "shares"} @ ${formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })}`,
353
+ ));
324
354
  lines.push(renderStat("Cost Basis", formatCurrency(costBasisBase, config.baseCurrency)));
325
355
  lines.push(renderStat("Market Value", formatCurrency(marketValueBase, config.baseCurrency)));
326
356
  lines.push(renderStat("P&L", colorBySign(formatSignedCurrency(pnl, config.baseCurrency), pnl)));
327
357
  if (position.markPrice != null) {
328
- lines.push(renderStat("Mark", formatCurrency(position.markPrice, positionCurrency)));
358
+ lines.push(renderStat("Mark", formatMarketPriceWithCurrency(position.markPrice, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })));
329
359
  }
330
360
  if (index < tickerFile.metadata.positions.length - 1) {
331
361
  lines.push(cliStyles.muted("-".repeat(24)));
@@ -4,6 +4,7 @@ import {
4
4
  formatNumber,
5
5
  formatPercentRaw,
6
6
  } from "../utils/format";
7
+ import { formatMarketPriceWithCurrency } from "../utils/market-format";
7
8
  export { slugifyName } from "../utils/slugify";
8
9
  import type { AppConfig } from "../types/config";
9
10
  import type { Watchlist, TickerRecord } from "../types/ticker";
@@ -42,13 +43,14 @@ export function formatBidAsk(
42
43
  bidSize: number | undefined,
43
44
  askSize: number | undefined,
44
45
  currency: string,
46
+ assetCategory?: string,
45
47
  ): string {
46
48
  if (bid == null && ask == null) return "—";
47
49
  const bidText = bid != null
48
- ? `${formatCurrency(bid, currency)}${bidSize != null ? ` x ${formatNumber(bidSize, 0)}` : ""}`
50
+ ? `${formatMarketPriceWithCurrency(bid, currency, { assetCategory })}${bidSize != null ? ` x ${formatNumber(bidSize, 0)}` : ""}`
49
51
  : "—";
50
52
  const askText = ask != null
51
- ? `${formatCurrency(ask, currency)}${askSize != null ? ` x ${formatNumber(askSize, 0)}` : ""}`
53
+ ? `${formatMarketPriceWithCurrency(ask, currency, { assetCategory })}${askSize != null ? ` x ${formatNumber(askSize, 0)}` : ""}`
52
54
  : "—";
53
55
  return `${bidText} / ${askText}`;
54
56
  }
@@ -186,41 +186,48 @@ describe("CLI dispatch", () => {
186
186
  test("routes prediction market commands and aliases through launch-ui requests", async () => {
187
187
  process.env.HOME = await createTempHome("gloomberb-cli-prediction-home-");
188
188
 
189
- for (const rootCommand of ["predictions", "prediction-markets", "pm"]) {
190
- const dispatchResult = await dispatchCli([rootCommand, "world"]);
191
- expect(dispatchResult.kind).toBe("launch-ui");
192
- if (dispatchResult.kind !== "launch-ui") continue;
193
-
194
- const config = createDefaultConfig("/tmp/gloomberb-cli-prediction");
195
- const intent = parsePredictionCommandArgs(["world"]);
196
- const expectedConfigResult = applyPredictionLaunchIntentToConfig(config, intent, {
197
- width: 132,
198
- height: 40,
199
- });
200
- const actualConfigResult = dispatchResult.request.applyConfig(config, {
201
- terminalWidth: 132,
202
- terminalHeight: 40,
203
- });
204
-
205
- expect(actualConfigResult.config).toEqual(expectedConfigResult.config);
206
- expect(actualConfigResult.launchState).toEqual({
207
- paneInstanceId: expectedConfigResult.paneInstanceId,
208
- intent,
209
- });
210
-
211
- const expectedSession = applyPredictionLaunchIntentToSessionSnapshot(
212
- actualConfigResult.config,
213
- null,
214
- expectedConfigResult.paneInstanceId,
215
- intent,
216
- );
217
- const actualSession = dispatchResult.request.applySessionSnapshot?.(
218
- actualConfigResult.config,
219
- null,
220
- actualConfigResult.launchState,
221
- );
222
-
223
- expect(actualSession).toEqual(expectedSession);
189
+ const fixedNow = Date.parse("2026-04-08T10:30:00Z");
190
+ const realDateNow = Date.now;
191
+ Date.now = () => fixedNow;
192
+ try {
193
+ for (const rootCommand of ["predictions", "prediction-markets", "pm"]) {
194
+ const dispatchResult = await dispatchCli([rootCommand, "world"]);
195
+ expect(dispatchResult.kind).toBe("launch-ui");
196
+ if (dispatchResult.kind !== "launch-ui") continue;
197
+
198
+ const config = createDefaultConfig("/tmp/gloomberb-cli-prediction");
199
+ const intent = parsePredictionCommandArgs(["world"]);
200
+ const expectedConfigResult = applyPredictionLaunchIntentToConfig(config, intent, {
201
+ width: 132,
202
+ height: 40,
203
+ });
204
+ const actualConfigResult = dispatchResult.request.applyConfig(config, {
205
+ terminalWidth: 132,
206
+ terminalHeight: 40,
207
+ });
208
+
209
+ expect(actualConfigResult.config).toEqual(expectedConfigResult.config);
210
+ expect(actualConfigResult.launchState).toEqual({
211
+ paneInstanceId: expectedConfigResult.paneInstanceId,
212
+ intent,
213
+ });
214
+
215
+ const expectedSession = applyPredictionLaunchIntentToSessionSnapshot(
216
+ actualConfigResult.config,
217
+ null,
218
+ expectedConfigResult.paneInstanceId,
219
+ intent,
220
+ );
221
+ const actualSession = dispatchResult.request.applySessionSnapshot?.(
222
+ actualConfigResult.config,
223
+ null,
224
+ actualConfigResult.launchState,
225
+ );
226
+
227
+ expect(actualSession).toEqual(expectedSession);
228
+ }
229
+ } finally {
230
+ Date.now = realDateNow;
224
231
  }
225
232
  });
226
233
  });
package/src/cli.test.ts CHANGED
@@ -53,6 +53,7 @@ async function createCliFixture({
53
53
  if (baseCurrency) {
54
54
  config.baseCurrency = baseCurrency;
55
55
  }
56
+ config.disabledPlugins = [...new Set([...(config.disabledPlugins ?? []), "yahoo", "gloomberb-cloud"])];
56
57
  if (portfolios) {
57
58
  config.portfolios = portfolios;
58
59
  }
@@ -482,14 +483,14 @@ describe("buildTickerReport", () => {
482
483
  title: "NVIDIA unveils next platform",
483
484
  source: "Example News",
484
485
  url: "https://example.com/nvda-platform",
485
- publishedAt: new Date(Date.UTC(2026, 3, 1, 15, 45)),
486
+ publishedAt: "2026-04-01T15:45:00.000Z" as unknown as Date,
486
487
  summary: "Analysts expect the launch to expand datacenter demand.",
487
488
  }];
488
489
 
489
490
  const recentSecFilings: SecFilingItem[] = [{
490
491
  accessionNumber: "0000000000-26-000001",
491
492
  form: "8-K",
492
- filingDate: new Date(Date.UTC(2026, 2, 31)),
493
+ filingDate: "2026-03-31T00:00:00.000Z" as unknown as Date,
493
494
  cik: "0001045810",
494
495
  filingUrl: "https://www.sec.gov/Archives/example-8k",
495
496
  primaryDocument: "nvda-8k.htm",
@@ -521,6 +522,7 @@ describe("buildTickerReport", () => {
521
522
  expect(report).toContain("Recent News");
522
523
  expect(report).toContain("NVIDIA unveils next platform");
523
524
  expect(report).toContain("Example News");
525
+ expect(report).toContain("Apr 1, 2026");
524
526
  expect(report).toContain("Recent SEC Filings");
525
527
  expect(report).toContain("8-K | Mar 31, 2026");
526
528
  expect(report).toContain("Current report announcing a product launch");