gloomberb 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -0,0 +1,309 @@
1
+ import type { QueryEntry } from "../../market-data/result-types";
2
+ import type { PricePoint } from "../../types/financials";
3
+ import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
4
+ import type { ChartResolution, ChartViewState, ComparisonChartViewState, TimeRange } from "./chart-types";
5
+ import {
6
+ clampTimeRangeToMaxRange,
7
+ getBestSupportedResolutionForPreset,
8
+ getCompatibleBufferRange,
9
+ getNextBufferRange,
10
+ getPresetResolution,
11
+ getSupportMaxRange,
12
+ getWidestPresetForResolution,
13
+ isDateWindowWithinTimeRange,
14
+ isTimeRangeAtOrBelow,
15
+ subtractTimeRange,
16
+ type ChartResolutionSupport,
17
+ type ManualChartResolution,
18
+ } from "./chart-resolution";
19
+
20
+ type ViewStateWithViewport = Pick<ChartViewState, "presetRange" | "bufferRange" | "activePreset" | "resolution" | "panOffset" | "zoomLevel" | "cursorX" | "cursorY">
21
+ | Pick<ComparisonChartViewState, "presetRange" | "bufferRange" | "activePreset" | "resolution" | "panOffset" | "zoomLevel" | "cursorX" | "cursorY">;
22
+
23
+ export interface VisibleDateWindow {
24
+ start: Date | null;
25
+ end: Date | null;
26
+ dates: Date[];
27
+ startIdx: number;
28
+ endIdx: number;
29
+ totalDates: number;
30
+ }
31
+
32
+ export interface ChartBodyState<T> {
33
+ data: T | null;
34
+ blocking: boolean;
35
+ updating: boolean;
36
+ emptyMessage: string | null;
37
+ errorMessage: string | null;
38
+ }
39
+
40
+ function clamp(value: number, min: number, max: number): number {
41
+ return Math.max(min, Math.min(max, value));
42
+ }
43
+
44
+ function coerceDate(value: Date | string | number): Date {
45
+ return value instanceof Date ? value : new Date(value);
46
+ }
47
+
48
+ export function getPointDates(points: readonly Pick<PricePoint, "date">[]): Date[] {
49
+ return points.map((point) => coerceDate(point.date as Date | string | number));
50
+ }
51
+
52
+ export function getCanonicalVisiblePointCount(dates: readonly Date[], presetRange: TimeRange): number {
53
+ if (dates.length === 0) return 0;
54
+ if (presetRange === "ALL") return dates.length;
55
+ const endDate = dates[dates.length - 1]!;
56
+ const threshold = subtractTimeRange(endDate, presetRange).getTime();
57
+ const firstVisibleIndex = dates.findIndex((date) => date.getTime() >= threshold);
58
+ if (firstVisibleIndex < 0) return dates.length;
59
+ return Math.max(dates.length - firstVisibleIndex, 1);
60
+ }
61
+
62
+ export function getCanonicalZoomLevel(dates: readonly Date[], presetRange: TimeRange): number {
63
+ if (dates.length === 0) return 1;
64
+ const visibleCount = getCanonicalVisiblePointCount(dates, presetRange);
65
+ return clampChartZoom(dates.length, dates.length / Math.max(visibleCount, 1));
66
+ }
67
+
68
+ export function buildVisibleDateWindow(
69
+ dates: readonly Date[],
70
+ panOffset: number,
71
+ zoomLevel: number,
72
+ ): VisibleDateWindow {
73
+ if (dates.length === 0) {
74
+ return { start: null, end: null, dates: [], startIdx: 0, endIdx: 0, totalDates: 0 };
75
+ }
76
+ const visibleCount = getVisiblePointCount(dates.length, zoomLevel);
77
+ const maxPan = Math.max(dates.length - visibleCount, 0);
78
+ const pan = clamp(panOffset, 0, maxPan);
79
+ const endIdx = dates.length - pan;
80
+ const startIdx = Math.max(endIdx - visibleCount, 0);
81
+ const visibleDates = dates.slice(startIdx, endIdx);
82
+ return {
83
+ start: visibleDates[0] ?? null,
84
+ end: visibleDates[visibleDates.length - 1] ?? null,
85
+ dates: visibleDates,
86
+ startIdx,
87
+ endIdx,
88
+ totalDates: dates.length,
89
+ };
90
+ }
91
+
92
+ export function formatVisibleSpanLabel(window: Pick<VisibleDateWindow, "start" | "end">): string {
93
+ if (!window.start || !window.end) return "view: --";
94
+ const sameYear = window.start.getFullYear() === window.end.getFullYear();
95
+ const sameMonth = sameYear && window.start.getMonth() === window.end.getMonth();
96
+ const startLabel = window.start.toLocaleDateString("en-US", {
97
+ month: "short",
98
+ day: "numeric",
99
+ ...(sameYear ? {} : { year: "numeric" }),
100
+ });
101
+ const endLabel = window.end.toLocaleDateString("en-US", {
102
+ month: sameMonth ? undefined : "short",
103
+ day: "numeric",
104
+ year: sameYear ? undefined : "numeric",
105
+ });
106
+ return `view:${startLabel}-${endLabel}`;
107
+ }
108
+
109
+ export function isCanonicalPresetViewport(
110
+ dates: readonly Date[],
111
+ state: Pick<ViewStateWithViewport, "activePreset" | "panOffset" | "zoomLevel" | "resolution">,
112
+ ): boolean {
113
+ if (!state.activePreset) return false;
114
+ if (state.panOffset !== 0) return false;
115
+ const canonicalZoom = getCanonicalZoomLevel(dates, state.activePreset);
116
+ return Math.abs(state.zoomLevel - canonicalZoom) < 0.001;
117
+ }
118
+
119
+ export function resolvePresetSelectionWithResolution<S extends ViewStateWithViewport>(
120
+ state: S,
121
+ presetRange: TimeRange,
122
+ resolution: ManualChartResolution,
123
+ supportMaxRange: TimeRange | null = null,
124
+ ): S {
125
+ return {
126
+ ...state,
127
+ presetRange,
128
+ bufferRange: getCompatibleBufferRange(presetRange, supportMaxRange),
129
+ activePreset: presetRange,
130
+ resolution,
131
+ panOffset: 0,
132
+ zoomLevel: 1,
133
+ cursorX: null,
134
+ cursorY: null,
135
+ };
136
+ }
137
+
138
+ export function resolvePresetSelection<S extends ViewStateWithViewport>(
139
+ state: S,
140
+ presetRange: TimeRange,
141
+ supportMaxRange: TimeRange | null = null,
142
+ ): S {
143
+ return resolvePresetSelectionWithResolution(
144
+ state,
145
+ presetRange,
146
+ getPresetResolution(presetRange),
147
+ supportMaxRange,
148
+ );
149
+ }
150
+
151
+ export function resolveResolutionSelection<S extends ViewStateWithViewport>(
152
+ state: S,
153
+ resolution: ChartResolution,
154
+ support: ReadonlyMap<ManualChartResolution, TimeRange>,
155
+ visibleWindow: Pick<VisibleDateWindow, "start" | "end"> | null,
156
+ ): S | null {
157
+ if (resolution === "auto") {
158
+ return {
159
+ ...state,
160
+ resolution,
161
+ bufferRange: getNextBufferRange(state.presetRange),
162
+ activePreset: null,
163
+ panOffset: 0,
164
+ zoomLevel: 1,
165
+ cursorX: null,
166
+ cursorY: null,
167
+ };
168
+ }
169
+
170
+ const maxRange = getSupportMaxRange(support, resolution);
171
+ if (!maxRange) return null;
172
+
173
+ const visibleSpanTooWide = visibleWindow?.start && visibleWindow.end
174
+ ? !isDateWindowWithinTimeRange(visibleWindow.start, visibleWindow.end, maxRange)
175
+ : false;
176
+ const shouldSnap = !isTimeRangeAtOrBelow(state.presetRange, maxRange) || visibleSpanTooWide;
177
+ const presetRange = shouldSnap
178
+ ? getWidestPresetForResolution(resolution, maxRange)
179
+ : clampTimeRangeToMaxRange(state.presetRange, maxRange);
180
+
181
+ return {
182
+ ...state,
183
+ presetRange,
184
+ bufferRange: getCompatibleBufferRange(presetRange, maxRange),
185
+ activePreset: shouldSnap ? presetRange : null,
186
+ resolution,
187
+ panOffset: 0,
188
+ zoomLevel: 1,
189
+ cursorX: null,
190
+ cursorY: null,
191
+ };
192
+ }
193
+
194
+ export function resolveStoredChartSelection<S extends ViewStateWithViewport>(
195
+ state: S,
196
+ presetRange: TimeRange,
197
+ resolution: ChartResolution,
198
+ support: ReadonlyMap<ManualChartResolution, TimeRange>,
199
+ ): S {
200
+ const resetState = {
201
+ ...state,
202
+ presetRange,
203
+ panOffset: 0,
204
+ zoomLevel: 1,
205
+ cursorX: null,
206
+ cursorY: null,
207
+ };
208
+
209
+ if (resolution === "auto") {
210
+ return {
211
+ ...resetState,
212
+ resolution,
213
+ bufferRange: getNextBufferRange(presetRange),
214
+ activePreset: null,
215
+ };
216
+ }
217
+
218
+ const resolvedSelection = resolveResolutionSelection(
219
+ {
220
+ ...resetState,
221
+ bufferRange: getNextBufferRange(presetRange),
222
+ activePreset: null,
223
+ resolution: "auto",
224
+ },
225
+ resolution,
226
+ support,
227
+ null,
228
+ );
229
+
230
+ if (!resolvedSelection) {
231
+ const fallbackResolution = getBestSupportedResolutionForPreset(presetRange, support);
232
+ if (!fallbackResolution) {
233
+ return {
234
+ ...resetState,
235
+ resolution: "auto",
236
+ bufferRange: getNextBufferRange(presetRange),
237
+ activePreset: null,
238
+ };
239
+ }
240
+ return resolvePresetSelectionWithResolution(
241
+ resetState,
242
+ presetRange,
243
+ fallbackResolution,
244
+ getSupportMaxRange(support, fallbackResolution),
245
+ );
246
+ }
247
+
248
+ return {
249
+ ...resolvedSelection,
250
+ activePreset: resolvedSelection.resolution === getBestSupportedResolutionForPreset(resolvedSelection.presetRange, support)
251
+ ? resolvedSelection.presetRange
252
+ : null,
253
+ };
254
+ }
255
+
256
+ export function clearActivePreset<S extends Pick<ViewStateWithViewport, "activePreset">>(state: S): S {
257
+ if (state.activePreset === null) return state;
258
+ return {
259
+ ...state,
260
+ activePreset: null,
261
+ };
262
+ }
263
+
264
+ export function resolveChartBodyState<T>(
265
+ entry: QueryEntry<T> | null | undefined,
266
+ hasData: (value: T | null) => boolean,
267
+ emptyMessage: string,
268
+ ): ChartBodyState<T> {
269
+ const data = entry?.data ?? null;
270
+ const hasCurrentData = hasData(data);
271
+
272
+ if (!entry || entry.phase === "idle" || ((entry.phase === "loading" || entry.phase === "refreshing") && !hasCurrentData)) {
273
+ return {
274
+ data: null,
275
+ blocking: true,
276
+ updating: false,
277
+ emptyMessage: null,
278
+ errorMessage: null,
279
+ };
280
+ }
281
+
282
+ if (entry.phase === "error" && !hasCurrentData) {
283
+ return {
284
+ data: null,
285
+ blocking: false,
286
+ updating: false,
287
+ emptyMessage: null,
288
+ errorMessage: entry.error?.message ?? "Chart data failed to load.",
289
+ };
290
+ }
291
+
292
+ if (!hasCurrentData) {
293
+ return {
294
+ data,
295
+ blocking: false,
296
+ updating: false,
297
+ emptyMessage,
298
+ errorMessage: null,
299
+ };
300
+ }
301
+
302
+ return {
303
+ data,
304
+ blocking: false,
305
+ updating: entry.phase === "refreshing",
306
+ emptyMessage: null,
307
+ errorMessage: null,
308
+ };
309
+ }
@@ -78,22 +78,21 @@ export function getVisibleWindow(
78
78
  viewState: ChartViewState,
79
79
  chartWidth: number,
80
80
  ): VisibleWindow {
81
- const filtered = filterByTimeRange(history, viewState.timeRange);
82
- if (filtered.length === 0) {
81
+ if (history.length === 0) {
83
82
  return { points: [], startIdx: 0, endIdx: 0 };
84
83
  }
85
84
 
86
- const visibleCount = getVisiblePointCount(filtered.length, viewState.zoomLevel);
85
+ const visibleCount = getVisiblePointCount(history.length, viewState.zoomLevel);
87
86
 
88
87
  // Clamp pan offset
89
- const maxPan = Math.max(filtered.length - visibleCount, 0);
88
+ const maxPan = Math.max(history.length - visibleCount, 0);
90
89
  const pan = Math.min(Math.max(viewState.panOffset, 0), maxPan);
91
90
 
92
- const endIdx = filtered.length - pan;
91
+ const endIdx = history.length - pan;
93
92
  const startIdx = Math.max(endIdx - visibleCount, 0);
94
93
 
95
94
  return {
96
- points: filtered.slice(startIdx, endIdx),
95
+ points: history.slice(startIdx, endIdx),
97
96
  startIdx,
98
97
  endIdx,
99
98
  };
@@ -0,0 +1,27 @@
1
+ import { saveConfig } from "../../data/config-store";
2
+ import { setPaneSettings } from "../../pane-settings";
3
+ import { useAppState, usePaneInstance, usePaneInstanceId } from "../../state/app-context";
4
+ import type { ChartResolution, TimeRange } from "./chart-types";
5
+
6
+ export function usePersistChartControlSelection(rangePresetKey: string): (
7
+ range: TimeRange,
8
+ resolution: ChartResolution,
9
+ ) => void {
10
+ const { state, dispatch } = useAppState();
11
+ const paneId = usePaneInstanceId();
12
+ const pane = usePaneInstance();
13
+
14
+ return (range, resolution) => {
15
+ const layout = setPaneSettings(state.config.layout, paneId, {
16
+ ...(pane?.settings ?? {}),
17
+ [rangePresetKey]: range,
18
+ chartResolution: resolution,
19
+ });
20
+ const layouts = state.config.layouts.map((savedLayout, index) => (
21
+ index === state.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
22
+ ));
23
+ const nextConfig = { ...state.config, layout, layouts };
24
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
25
+ saveConfig(nextConfig).catch(() => {});
26
+ };
27
+ }
@@ -917,6 +917,7 @@ export interface RenderChartOptions {
917
917
  axisMode?: ChartAxisMode;
918
918
  currency?: string;
919
919
  colors: ResolvedChartPalette;
920
+ timeAxisDates?: Array<Date | string | number>;
920
921
  }
921
922
 
922
923
  export interface ChartScene {
@@ -1027,6 +1028,8 @@ export function buildChartScene(
1027
1028
  ? null
1028
1029
  : max - (cursorY / Math.max(chartRows - 1, 1)) * range;
1029
1030
 
1031
+ const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1032
+
1030
1033
  return {
1031
1034
  points,
1032
1035
  width: opts.width,
@@ -1045,7 +1048,7 @@ export function buildChartScene(
1045
1048
  dateAtCursor: activePoint.date,
1046
1049
  changeAtCursor: activePoint.close - points[0]!.close,
1047
1050
  changePctAtCursor: points[0]!.close ? ((activePoint.close - points[0]!.close) / points[0]!.close) * 100 : 0,
1048
- timeLabels: buildTimeAxis(points.map((point) => point.date), opts.width),
1051
+ timeLabels: buildTimeAxis(timeAxisDates, opts.width),
1049
1052
  cursorX,
1050
1053
  cursorY,
1051
1054
  cursorColumn,
@@ -1138,10 +1141,12 @@ export function renderChart(
1138
1141
  );
1139
1142
  }
1140
1143
 
1144
+ const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1145
+
1141
1146
  return {
1142
1147
  lines: bufferToBrailleLines(buf, palette.bgColor),
1143
1148
  axisLabels: [...axisLabelsByRow.entries()].map(([row, label]) => ({ row, label })),
1144
- timeLabels: buildTimeAxis(points.map((point) => point.date), width),
1149
+ timeLabels: buildTimeAxis(timeAxisDates, width),
1145
1150
  activePoint,
1146
1151
  priceAtCursor,
1147
1152
  crosshairPrice,
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clampTimeRangeForResolution,
4
+ getActiveRangePreset,
5
+ getPresetResolution,
6
+ intersectChartResolutions,
7
+ isRangePresetSupported,
8
+ normalizeChartResolution,
9
+ sortChartResolutions,
10
+ } from "./chart-resolution";
11
+
12
+ describe("chart-resolution", () => {
13
+ test("maps range presets to their default manual resolutions", () => {
14
+ expect(getPresetResolution("1W")).toBe("5m");
15
+ expect(getPresetResolution("1M")).toBe("15m");
16
+ expect(getPresetResolution("3M")).toBe("1h");
17
+ expect(getPresetResolution("6M")).toBe("1d");
18
+ expect(getPresetResolution("1Y")).toBe("1d");
19
+ expect(getPresetResolution("5Y")).toBe("1wk");
20
+ expect(getPresetResolution("ALL")).toBe("1mo");
21
+ });
22
+
23
+ test("derives the active range preset only for the exact reset preset pair", () => {
24
+ expect(getActiveRangePreset("1Y", "1d", 1, 0)).toBe("1Y");
25
+ expect(getActiveRangePreset("1Y", "auto", 1, 0)).toBeNull();
26
+ expect(getActiveRangePreset("1Y", "1d", 1.2, 0)).toBeNull();
27
+ expect(getActiveRangePreset("1Y", "1d", 1, 2)).toBeNull();
28
+ });
29
+
30
+ test("intersects and sorts provider capabilities", () => {
31
+ expect(intersectChartResolutions([
32
+ ["1d", "5m", "1wk", "nope"],
33
+ ["1wk", "1d", "1mo"],
34
+ ["1d", "1wk"],
35
+ ])).toEqual(["1d", "1wk"]);
36
+ });
37
+
38
+ test("checks whether a range preset is supported by the visible capability set", () => {
39
+ expect(isRangePresetSupported("1Y", ["1d", "1wk"])).toBe(true);
40
+ expect(isRangePresetSupported("ALL", ["1d", "1wk"])).toBe(false);
41
+ });
42
+
43
+ test("clamps overly-wide ranges for manual intraday resolutions", () => {
44
+ expect(clampTimeRangeForResolution("5Y", "5m")).toBe("1W");
45
+ expect(clampTimeRangeForResolution("6M", "1h")).toBe("3M");
46
+ expect(clampTimeRangeForResolution("1M", "15m")).toBe("1M");
47
+ expect(clampTimeRangeForResolution("5Y", "1d")).toBe("5Y");
48
+ expect(clampTimeRangeForResolution("5Y", "auto")).toBe("5Y");
49
+ });
50
+
51
+ test("normalizes and sorts chart resolution values", () => {
52
+ expect(normalizeChartResolution("1h")).toBe("1h");
53
+ expect(normalizeChartResolution("bogus", "1d")).toBe("1d");
54
+ expect(sortChartResolutions(["1wk", "auto", "15m", "1d"])).toEqual(["auto", "15m", "1d", "1wk"]);
55
+ });
56
+ });