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
@@ -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
  };