gloomberb 0.4.0 → 0.5.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.
Files changed (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  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 +296 -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 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  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 +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  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 +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  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 +33 -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/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -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");
@@ -0,0 +1,39 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { colors } from "../../theme/colors";
3
+
4
+ type HintMouseEvent = {
5
+ stopPropagation?: () => void;
6
+ preventDefault?: () => void;
7
+ };
8
+
9
+ interface ChartControlHintProps {
10
+ hotkey: string;
11
+ label: string;
12
+ disabled?: boolean;
13
+ onPress?: (event?: HintMouseEvent) => void;
14
+ }
15
+
16
+ function stopMouseEvent(event?: HintMouseEvent) {
17
+ event?.stopPropagation?.();
18
+ event?.preventDefault?.();
19
+ }
20
+
21
+ export function ChartControlHint({
22
+ hotkey,
23
+ label,
24
+ disabled = false,
25
+ onPress,
26
+ }: ChartControlHintProps) {
27
+ const interactive = !!onPress && !disabled;
28
+
29
+ return (
30
+ <text
31
+ fg={disabled ? colors.textMuted : colors.textDim}
32
+ attributes={interactive ? TextAttributes.BOLD : 0}
33
+ onMouseDown={interactive ? stopMouseEvent : undefined}
34
+ onMouseUp={interactive ? onPress : undefined}
35
+ >
36
+ <span fg={disabled ? colors.textMuted : colors.textBright}>[{hotkey}]</span>{label}
37
+ </text>
38
+ );
39
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clampDateWindowToBounds,
4
+ getCanonicalZoomLevel,
5
+ needsCanonicalPresetViewportReset,
6
+ resolveCanonicalPresetViewport,
7
+ resolveVisibleActivePreset,
8
+ shiftDateWindow,
9
+ } from "./chart-controller";
10
+
11
+ const dayMs = 24 * 60 * 60_000;
12
+
13
+ function utcDate(day: number): Date {
14
+ return new Date(Date.UTC(2026, 0, day));
15
+ }
16
+
17
+ function utcDates(count: number): Date[] {
18
+ return Array.from({ length: count }, (_, index) => utcDate(index + 1));
19
+ }
20
+
21
+ describe("chart-controller date windows", () => {
22
+ test("keeps the visible span when panning past the right edge", () => {
23
+ const current = {
24
+ start: utcDate(8),
25
+ end: utcDate(10),
26
+ };
27
+ const shifted = shiftDateWindow(current, -2);
28
+
29
+ const clamped = clampDateWindowToBounds(shifted, {
30
+ start: utcDate(1),
31
+ end: utcDate(11),
32
+ });
33
+
34
+ expect(clamped).toEqual({
35
+ start: utcDate(9),
36
+ end: utcDate(11),
37
+ });
38
+ expect(clamped!.end!.getTime() - clamped!.start!.getTime()).toBe(2 * dayMs);
39
+ });
40
+
41
+ test("keeps the visible span when panning past the left edge", () => {
42
+ const current = {
43
+ start: utcDate(2),
44
+ end: utcDate(4),
45
+ };
46
+ const shifted = shiftDateWindow(current, 2);
47
+
48
+ const clamped = clampDateWindowToBounds(shifted, {
49
+ start: utcDate(1),
50
+ end: utcDate(11),
51
+ });
52
+
53
+ expect(clamped).toEqual({
54
+ start: utcDate(1),
55
+ end: utcDate(3),
56
+ });
57
+ expect(clamped!.end!.getTime() - clamped!.start!.getTime()).toBe(2 * dayMs);
58
+ });
59
+
60
+ test("expands tiny windows around their center without crossing bounds", () => {
61
+ const clamped = clampDateWindowToBounds({
62
+ start: utcDate(11),
63
+ end: utcDate(11),
64
+ }, {
65
+ start: utcDate(1),
66
+ end: utcDate(11),
67
+ }, 2 * dayMs);
68
+
69
+ expect(clamped).toEqual({
70
+ start: utcDate(9),
71
+ end: utcDate(11),
72
+ });
73
+ });
74
+
75
+ test("keeps an active preset visible while loaded data still needs the canonical reset", () => {
76
+ const dates = utcDates(300);
77
+ const state = {
78
+ presetRange: "1M" as const,
79
+ activePreset: "1M" as const,
80
+ resolution: "15m" as const,
81
+ panOffset: 0,
82
+ zoomLevel: 1,
83
+ cursorX: 12,
84
+ cursorY: 3,
85
+ };
86
+
87
+ expect(needsCanonicalPresetViewportReset(dates, state)).toBe(true);
88
+ expect(resolveVisibleActivePreset(dates, state)).toBe("1M");
89
+
90
+ const resolved = resolveCanonicalPresetViewport(state, dates);
91
+ expect(resolved.zoomLevel).toBe(getCanonicalZoomLevel(dates, "1M"));
92
+ expect(resolved.panOffset).toBe(0);
93
+ expect(resolved.cursorX).toBeNull();
94
+ expect(resolved.cursorY).toBeNull();
95
+ });
96
+
97
+ test("does not keep a preset active after the viewport was panned", () => {
98
+ const dates = utcDates(300);
99
+ const state = {
100
+ presetRange: "1M" as const,
101
+ activePreset: "1M" as const,
102
+ resolution: "15m" as const,
103
+ panOffset: 2,
104
+ zoomLevel: 1,
105
+ };
106
+
107
+ expect(needsCanonicalPresetViewportReset(dates, state)).toBe(false);
108
+ expect(resolveVisibleActivePreset(dates, state)).toBeNull();
109
+ });
110
+ });
@@ -1,7 +1,7 @@
1
1
  import type { QueryEntry } from "../../market-data/result-types";
2
2
  import type { PricePoint } from "../../types/financials";
3
3
  import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
4
- import type { ChartResolution, ChartViewState, ComparisonChartViewState, TimeRange } from "./chart-types";
4
+ import type { ChartResolution, ChartViewState, ComparisonChartViewState, TimeRange, VisibleWindow } from "./chart-types";
5
5
  import {
6
6
  clampTimeRangeToMaxRange,
7
7
  getBestSupportedResolutionForPreset,
@@ -37,6 +37,11 @@ export interface ChartBodyState<T> {
37
37
  errorMessage: string | null;
38
38
  }
39
39
 
40
+ export interface DateWindowRange {
41
+ start: Date | null;
42
+ end: Date | null;
43
+ }
44
+
40
45
  function clamp(value: number, min: number, max: number): number {
41
46
  return Math.max(min, Math.min(max, value));
42
47
  }
@@ -45,10 +50,235 @@ function coerceDate(value: Date | string | number): Date {
45
50
  return value instanceof Date ? value : new Date(value);
46
51
  }
47
52
 
53
+ function normalizeDateWindowRange(window: DateWindowRange | null | undefined): { startMs: number; endMs: number } | null {
54
+ if (!window?.start || !window.end) return null;
55
+ const rawStartMs = window.start.getTime();
56
+ const rawEndMs = window.end.getTime();
57
+ if (!Number.isFinite(rawStartMs) || !Number.isFinite(rawEndMs)) return null;
58
+ return rawStartMs <= rawEndMs
59
+ ? { startMs: rawStartMs, endMs: rawEndMs }
60
+ : { startMs: rawEndMs, endMs: rawStartMs };
61
+ }
62
+
63
+ function lowerBoundDate(dates: readonly Date[], targetMs: number): number {
64
+ let low = 0;
65
+ let high = dates.length;
66
+ while (low < high) {
67
+ const mid = Math.floor((low + high) / 2);
68
+ if (dates[mid]!.getTime() < targetMs) {
69
+ low = mid + 1;
70
+ } else {
71
+ high = mid;
72
+ }
73
+ }
74
+ return low;
75
+ }
76
+
77
+ function upperBoundDate(dates: readonly Date[], targetMs: number): number {
78
+ let low = 0;
79
+ let high = dates.length;
80
+ while (low < high) {
81
+ const mid = Math.floor((low + high) / 2);
82
+ if (dates[mid]!.getTime() <= targetMs) {
83
+ low = mid + 1;
84
+ } else {
85
+ high = mid;
86
+ }
87
+ }
88
+ return low;
89
+ }
90
+
91
+ function resolveMinimumPointWindow(
92
+ dates: readonly Date[],
93
+ startMs: number,
94
+ endMs: number,
95
+ minimumPoints: number,
96
+ ): { startIdx: number; endIdx: number } {
97
+ if (dates.length === 0) return { startIdx: 0, endIdx: 0 };
98
+
99
+ let startIdx = lowerBoundDate(dates, startMs);
100
+ let endIdx = upperBoundDate(dates, endMs);
101
+
102
+ if (minimumPoints <= 0) {
103
+ return {
104
+ startIdx: Math.min(startIdx, dates.length),
105
+ endIdx: Math.min(Math.max(endIdx, startIdx), dates.length),
106
+ };
107
+ }
108
+
109
+ if (endIdx - startIdx >= minimumPoints || dates.length <= minimumPoints) {
110
+ return {
111
+ startIdx: Math.min(startIdx, dates.length),
112
+ endIdx: Math.min(Math.max(endIdx, startIdx), dates.length),
113
+ };
114
+ }
115
+
116
+ const centerMs = startMs + ((endMs - startMs) / 2);
117
+ let anchorIdx = lowerBoundDate(dates, centerMs);
118
+ if (anchorIdx >= dates.length) anchorIdx = dates.length - 1;
119
+ if (anchorIdx > 0) {
120
+ const currentDistance = Math.abs(dates[anchorIdx]!.getTime() - centerMs);
121
+ const previousDistance = Math.abs(dates[anchorIdx - 1]!.getTime() - centerMs);
122
+ if (previousDistance <= currentDistance) {
123
+ anchorIdx -= 1;
124
+ }
125
+ }
126
+
127
+ startIdx = Math.max(Math.min(anchorIdx - Math.floor((minimumPoints - 1) / 2), dates.length - minimumPoints), 0);
128
+ endIdx = Math.min(startIdx + minimumPoints, dates.length);
129
+
130
+ return { startIdx, endIdx };
131
+ }
132
+
48
133
  export function getPointDates(points: readonly Pick<PricePoint, "date">[]): Date[] {
49
134
  return points.map((point) => coerceDate(point.date as Date | string | number));
50
135
  }
51
136
 
137
+ export function getDateWindowBounds(dates: readonly Date[]): DateWindowRange | null {
138
+ if (dates.length === 0) return null;
139
+ return {
140
+ start: dates[0] ?? null,
141
+ end: dates[dates.length - 1] ?? null,
142
+ };
143
+ }
144
+
145
+ export function getMinimumDateStepMs(dates: readonly Date[]): number {
146
+ let minimum = Number.POSITIVE_INFINITY;
147
+ for (let index = 1; index < dates.length; index += 1) {
148
+ const delta = dates[index]!.getTime() - dates[index - 1]!.getTime();
149
+ if (delta > 0 && delta < minimum) {
150
+ minimum = delta;
151
+ }
152
+ }
153
+ return Number.isFinite(minimum) ? minimum : 1;
154
+ }
155
+
156
+ export function buildPresetDateWindow(dates: readonly Date[], presetRange: TimeRange): DateWindowRange | null {
157
+ if (dates.length === 0) return null;
158
+ if (presetRange === "ALL") {
159
+ return {
160
+ start: dates[0] ?? null,
161
+ end: dates[dates.length - 1] ?? null,
162
+ };
163
+ }
164
+
165
+ const end = dates[dates.length - 1]!;
166
+ const threshold = subtractTimeRange(end, presetRange).getTime();
167
+ const startIdx = dates.findIndex((date) => date.getTime() >= threshold);
168
+
169
+ return {
170
+ start: dates[startIdx < 0 ? 0 : startIdx] ?? null,
171
+ end,
172
+ };
173
+ }
174
+
175
+ export function sameDateWindow(
176
+ left: DateWindowRange | null | undefined,
177
+ right: DateWindowRange | null | undefined,
178
+ toleranceMs = 1,
179
+ ): boolean {
180
+ if (!left?.start || !left.end || !right?.start || !right.end) {
181
+ return left?.start === right?.start && left?.end === right?.end;
182
+ }
183
+ return Math.abs(left.start.getTime() - right.start.getTime()) <= toleranceMs
184
+ && Math.abs(left.end.getTime() - right.end.getTime()) <= toleranceMs;
185
+ }
186
+
187
+ export function clampDateWindowToBounds(
188
+ window: DateWindowRange | null | undefined,
189
+ bounds: DateWindowRange | null | undefined,
190
+ minimumSpanMs = 1,
191
+ ): DateWindowRange | null {
192
+ const normalizedWindow = normalizeDateWindowRange(window);
193
+ const normalizedBounds = normalizeDateWindowRange(bounds);
194
+ if (!normalizedWindow || !normalizedBounds) return null;
195
+
196
+ const availableSpanMs = Math.max(normalizedBounds.endMs - normalizedBounds.startMs, 0);
197
+ const effectiveMinimumSpanMs = availableSpanMs === 0
198
+ ? 0
199
+ : Math.min(Math.max(minimumSpanMs, 1), availableSpanMs);
200
+ const requestedSpanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 0);
201
+ const targetSpanMs = Math.min(
202
+ Math.max(requestedSpanMs, effectiveMinimumSpanMs),
203
+ availableSpanMs,
204
+ );
205
+
206
+ if (availableSpanMs === 0 || targetSpanMs === 0) {
207
+ return {
208
+ start: new Date(normalizedBounds.startMs),
209
+ end: new Date(normalizedBounds.endMs),
210
+ };
211
+ }
212
+
213
+ if (targetSpanMs >= availableSpanMs) {
214
+ return {
215
+ start: new Date(normalizedBounds.startMs),
216
+ end: new Date(normalizedBounds.endMs),
217
+ };
218
+ }
219
+
220
+ let startMs: number;
221
+ let endMs: number;
222
+
223
+ if (requestedSpanMs < targetSpanMs) {
224
+ const centerMs = normalizedWindow.startMs + (requestedSpanMs / 2);
225
+ startMs = centerMs - (targetSpanMs / 2);
226
+ endMs = centerMs + (targetSpanMs / 2);
227
+ } else {
228
+ startMs = normalizedWindow.startMs;
229
+ endMs = normalizedWindow.startMs + targetSpanMs;
230
+ }
231
+
232
+ if (startMs < normalizedBounds.startMs) {
233
+ startMs = normalizedBounds.startMs;
234
+ endMs = startMs + targetSpanMs;
235
+ } else if (endMs > normalizedBounds.endMs) {
236
+ endMs = normalizedBounds.endMs;
237
+ startMs = endMs - targetSpanMs;
238
+ }
239
+
240
+ return {
241
+ start: new Date(startMs),
242
+ end: new Date(endMs),
243
+ };
244
+ }
245
+
246
+ export function scaleDateWindow(
247
+ window: DateWindowRange | null | undefined,
248
+ spanScale: number,
249
+ anchorRatio: number,
250
+ ): DateWindowRange | null {
251
+ const normalizedWindow = normalizeDateWindowRange(window);
252
+ if (!normalizedWindow) return null;
253
+
254
+ const currentSpanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 1);
255
+ const nextSpanMs = Math.max(currentSpanMs * spanScale, 1);
256
+ const ratio = clamp(anchorRatio, 0, 1);
257
+ const anchorMs = normalizedWindow.startMs + (currentSpanMs * ratio);
258
+ const nextStartMs = anchorMs - (nextSpanMs * ratio);
259
+
260
+ return {
261
+ start: new Date(nextStartMs),
262
+ end: new Date(nextStartMs + nextSpanMs),
263
+ };
264
+ }
265
+
266
+ export function shiftDateWindow(
267
+ window: DateWindowRange | null | undefined,
268
+ shiftRatio: number,
269
+ ): DateWindowRange | null {
270
+ const normalizedWindow = normalizeDateWindowRange(window);
271
+ if (!normalizedWindow) return null;
272
+
273
+ const spanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 1);
274
+ const shiftMs = spanMs * shiftRatio;
275
+
276
+ return {
277
+ start: new Date(normalizedWindow.startMs - shiftMs),
278
+ end: new Date(normalizedWindow.endMs - shiftMs),
279
+ };
280
+ }
281
+
52
282
  export function getCanonicalVisiblePointCount(dates: readonly Date[], presetRange: TimeRange): number {
53
283
  if (dates.length === 0) return 0;
54
284
  if (presetRange === "ALL") return dates.length;
@@ -89,10 +319,93 @@ export function buildVisibleDateWindow(
89
319
  };
90
320
  }
91
321
 
322
+ export function buildVisibleDateWindowFromRange(
323
+ dates: readonly Date[],
324
+ window: DateWindowRange | null | undefined,
325
+ minimumPoints = 2,
326
+ ): VisibleDateWindow {
327
+ if (dates.length === 0) {
328
+ return { start: null, end: null, dates: [], startIdx: 0, endIdx: 0, totalDates: 0 };
329
+ }
330
+
331
+ const normalizedWindow = normalizeDateWindowRange(window);
332
+ if (!normalizedWindow) {
333
+ return {
334
+ start: dates[0] ?? null,
335
+ end: dates[dates.length - 1] ?? null,
336
+ dates: [...dates],
337
+ startIdx: 0,
338
+ endIdx: dates.length,
339
+ totalDates: dates.length,
340
+ };
341
+ }
342
+
343
+ const { startIdx, endIdx } = resolveMinimumPointWindow(dates, normalizedWindow.startMs, normalizedWindow.endMs, minimumPoints);
344
+ const visibleDates = dates.slice(startIdx, endIdx);
345
+
346
+ return {
347
+ start: visibleDates[0] ?? null,
348
+ end: visibleDates[visibleDates.length - 1] ?? null,
349
+ dates: visibleDates,
350
+ startIdx,
351
+ endIdx,
352
+ totalDates: dates.length,
353
+ };
354
+ }
355
+
356
+ export function getVisibleWindowForDateRange(
357
+ points: readonly PricePoint[],
358
+ window: DateWindowRange | null | undefined,
359
+ minimumPoints = 2,
360
+ ): VisibleWindow {
361
+ if (points.length === 0) {
362
+ return { points: [], startIdx: 0, endIdx: 0 };
363
+ }
364
+
365
+ const pointDates = getPointDates(points);
366
+ const visibleWindow = buildVisibleDateWindowFromRange(pointDates, window, minimumPoints);
367
+
368
+ return {
369
+ points: points.slice(visibleWindow.startIdx, visibleWindow.endIdx),
370
+ startIdx: visibleWindow.startIdx,
371
+ endIdx: visibleWindow.endIdx,
372
+ };
373
+ }
374
+
92
375
  export function formatVisibleSpanLabel(window: Pick<VisibleDateWindow, "start" | "end">): string {
93
376
  if (!window.start || !window.end) return "view: --";
94
377
  const sameYear = window.start.getFullYear() === window.end.getFullYear();
95
378
  const sameMonth = sameYear && window.start.getMonth() === window.end.getMonth();
379
+ const sameDay = sameMonth && window.start.getDate() === window.end.getDate();
380
+ const spanMs = Math.max(window.end.getTime() - window.start.getTime(), 0);
381
+ const showTime = sameDay || spanMs <= 2 * 24 * 60 * 60_000;
382
+
383
+ if (showTime) {
384
+ const startLabel = window.start.toLocaleDateString("en-US", {
385
+ month: "short",
386
+ day: "numeric",
387
+ ...(sameYear ? {} : { year: "numeric" }),
388
+ });
389
+ const startTime = window.start.toLocaleTimeString("en-US", {
390
+ hour: "numeric",
391
+ minute: "2-digit",
392
+ });
393
+ const endLabel = sameDay
394
+ ? window.end.toLocaleTimeString("en-US", {
395
+ hour: "numeric",
396
+ minute: "2-digit",
397
+ })
398
+ : `${window.end.toLocaleDateString("en-US", {
399
+ month: "short",
400
+ day: "numeric",
401
+ year: sameYear ? undefined : "numeric",
402
+ })} ${window.end.toLocaleTimeString("en-US", {
403
+ hour: "numeric",
404
+ minute: "2-digit",
405
+ })}`;
406
+ return `view:${startLabel} ${startTime}-${endLabel}`;
407
+ }
408
+
96
409
  const startLabel = window.start.toLocaleDateString("en-US", {
97
410
  month: "short",
98
411
  day: "numeric",
@@ -116,6 +429,52 @@ export function isCanonicalPresetViewport(
116
429
  return Math.abs(state.zoomLevel - canonicalZoom) < 0.001;
117
430
  }
118
431
 
432
+ type CanonicalPresetViewportState = Pick<ViewStateWithViewport, "presetRange" | "activePreset" | "panOffset" | "zoomLevel" | "cursorX" | "cursorY">;
433
+
434
+ export function needsCanonicalPresetViewportReset(
435
+ dates: readonly Date[],
436
+ state: Pick<CanonicalPresetViewportState, "presetRange" | "activePreset" | "panOffset" | "zoomLevel">,
437
+ ): boolean {
438
+ if (dates.length === 0) return false;
439
+ if (state.activePreset !== state.presetRange) return false;
440
+ if (state.panOffset !== 0) return false;
441
+ const canonicalZoom = getCanonicalZoomLevel(dates, state.presetRange);
442
+ return Math.abs(state.zoomLevel - canonicalZoom) >= 0.001;
443
+ }
444
+
445
+ export function resolvePresetRangeViewport<S extends CanonicalPresetViewportState>(
446
+ state: S,
447
+ dates: readonly Date[],
448
+ ): S {
449
+ if (dates.length === 0) return state;
450
+ const canonicalZoom = getCanonicalZoomLevel(dates, state.presetRange);
451
+ if (state.zoomLevel === canonicalZoom && state.panOffset === 0) return state;
452
+ return {
453
+ ...state,
454
+ panOffset: 0,
455
+ zoomLevel: canonicalZoom,
456
+ cursorX: null,
457
+ cursorY: null,
458
+ };
459
+ }
460
+
461
+ export function resolveCanonicalPresetViewport<S extends CanonicalPresetViewportState>(
462
+ state: S,
463
+ dates: readonly Date[],
464
+ ): S {
465
+ if (!needsCanonicalPresetViewportReset(dates, state)) return state;
466
+ return resolvePresetRangeViewport(state, dates);
467
+ }
468
+
469
+ export function resolveVisibleActivePreset(
470
+ dates: readonly Date[],
471
+ state: Pick<ViewStateWithViewport, "presetRange" | "activePreset" | "panOffset" | "zoomLevel" | "resolution">,
472
+ ): TimeRange | null {
473
+ if (!state.activePreset) return null;
474
+ if (isCanonicalPresetViewport(dates, state)) return state.activePreset;
475
+ return needsCanonicalPresetViewportReset(dates, state) ? state.activePreset : null;
476
+ }
477
+
119
478
  export function resolvePresetSelectionWithResolution<S extends ViewStateWithViewport>(
120
479
  state: S,
121
480
  presetRange: TimeRange,
@@ -245,9 +604,12 @@ export function resolveStoredChartSelection<S extends ViewStateWithViewport>(
245
604
  );
246
605
  }
247
606
 
607
+ const bestSupportedResolution = getBestSupportedResolutionForPreset(resolvedSelection.presetRange, support);
248
608
  return {
249
609
  ...resolvedSelection,
250
- activePreset: resolvedSelection.resolution === getBestSupportedResolutionForPreset(resolvedSelection.presetRange, support)
610
+ activePreset: resolvedSelection.resolution !== "auto"
611
+ && bestSupportedResolution !== null
612
+ && resolvedSelection.resolution === bestSupportedResolution
251
613
  ? resolvedSelection.presetRange
252
614
  : null,
253
615
  };