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,362 @@
1
+ import type { ChartResolution, TimeRange } from "./chart-types";
2
+
3
+ export type ManualChartResolution = Exclude<ChartResolution, "auto">;
4
+
5
+ export interface ChartResolutionSupport {
6
+ resolution: ManualChartResolution;
7
+ maxRange: TimeRange;
8
+ }
9
+
10
+ export const TIME_RANGE_ORDER: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
11
+
12
+ export const CHART_RESOLUTION_ORDER: ChartResolution[] = [
13
+ "auto",
14
+ "1m",
15
+ "5m",
16
+ "15m",
17
+ "30m",
18
+ "45m",
19
+ "1h",
20
+ "1d",
21
+ "1wk",
22
+ "1mo",
23
+ ];
24
+
25
+ export const CHART_RESOLUTION_LABELS: Record<ChartResolution, string> = {
26
+ auto: "AUTO",
27
+ "1m": "1M",
28
+ "5m": "5M",
29
+ "15m": "15M",
30
+ "30m": "30M",
31
+ "45m": "45M",
32
+ "1h": "1H",
33
+ "1d": "1D",
34
+ "1wk": "1W",
35
+ "1mo": "1MO",
36
+ };
37
+
38
+ export const RANGE_PRESET_RESOLUTION: Record<TimeRange, ManualChartResolution> = {
39
+ "1D": "1m",
40
+ "1W": "5m",
41
+ "1M": "15m",
42
+ "3M": "1h",
43
+ "6M": "1d",
44
+ "1Y": "1d",
45
+ "5Y": "1wk",
46
+ "ALL": "1mo",
47
+ };
48
+
49
+ export const RANGE_PRELOAD_BUFFER: Record<TimeRange, TimeRange> = {
50
+ "1D": "1W",
51
+ "1W": "1M",
52
+ "1M": "3M",
53
+ "3M": "6M",
54
+ "6M": "1Y",
55
+ "1Y": "5Y",
56
+ "5Y": "ALL",
57
+ "ALL": "ALL",
58
+ };
59
+
60
+ export const DEFAULT_TICKER_CHART_RANGE_PRESET: TimeRange = "5Y";
61
+ export const DEFAULT_TICKER_CHART_RESOLUTION: ManualChartResolution = "1wk";
62
+ export const DEFAULT_COMPARISON_CHART_RANGE_PRESET: TimeRange = "1Y";
63
+ export const DEFAULT_COMPARISON_CHART_RESOLUTION: ManualChartResolution = "1d";
64
+
65
+ export const DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS: ManualChartResolution[] = ["1m", "5m", "15m", "1h", "1d", "1wk", "1mo"];
66
+
67
+ const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
68
+ const RANGE_PRESETS_BY_RESOLUTION = TIME_RANGE_ORDER.reduce<Record<ManualChartResolution, TimeRange[]>>((acc, range) => {
69
+ const resolution = RANGE_PRESET_RESOLUTION[range];
70
+ acc[resolution] ??= [];
71
+ acc[resolution].push(range);
72
+ return acc;
73
+ }, {
74
+ "1m": [],
75
+ "5m": [],
76
+ "15m": [],
77
+ "30m": [],
78
+ "45m": [],
79
+ "1h": [],
80
+ "1d": [],
81
+ "1wk": [],
82
+ "1mo": [],
83
+ });
84
+
85
+ function getTimeRangeIndex(range: TimeRange): number {
86
+ return TIME_RANGE_INDEX.get(range) ?? 0;
87
+ }
88
+
89
+ function compareTimeRange(left: TimeRange, right: TimeRange): number {
90
+ return getTimeRangeIndex(left) - getTimeRangeIndex(right);
91
+ }
92
+
93
+ export function isChartResolution(value: unknown): value is ChartResolution {
94
+ return CHART_RESOLUTION_ORDER.includes(value as ChartResolution);
95
+ }
96
+
97
+ export function isManualChartResolution(value: unknown): value is ManualChartResolution {
98
+ return value !== "auto" && CHART_RESOLUTION_ORDER.includes(value as ChartResolution);
99
+ }
100
+
101
+ export function normalizeChartResolution(value: unknown, fallback: ChartResolution = "auto"): ChartResolution {
102
+ return isChartResolution(value) ? value : fallback;
103
+ }
104
+
105
+ export function getChartResolutionLabel(resolution: ChartResolution): string {
106
+ return CHART_RESOLUTION_LABELS[resolution];
107
+ }
108
+
109
+ export function getPresetResolution(range: TimeRange): ManualChartResolution {
110
+ return RANGE_PRESET_RESOLUTION[range];
111
+ }
112
+
113
+ function getManualResolutionOrderIndex(resolution: ManualChartResolution): number {
114
+ return CHART_RESOLUTION_ORDER.indexOf(resolution);
115
+ }
116
+
117
+ export function getPresetBufferRange(range: TimeRange): TimeRange {
118
+ return RANGE_PRELOAD_BUFFER[range];
119
+ }
120
+
121
+ export function getNextBufferRange(range: TimeRange): TimeRange {
122
+ return RANGE_PRELOAD_BUFFER[range];
123
+ }
124
+
125
+ export function sortChartResolutions<T extends ChartResolution>(resolutions: readonly T[]): T[] {
126
+ return [...resolutions].sort((left, right) => (
127
+ CHART_RESOLUTION_ORDER.indexOf(left) - CHART_RESOLUTION_ORDER.indexOf(right)
128
+ ));
129
+ }
130
+
131
+ export function normalizeManualChartResolutions(resolutions: readonly string[]): ManualChartResolution[] {
132
+ const seen = new Set<ManualChartResolution>();
133
+ for (const resolution of resolutions) {
134
+ if (isManualChartResolution(resolution)) {
135
+ seen.add(resolution);
136
+ }
137
+ }
138
+ return sortChartResolutions([...seen]);
139
+ }
140
+
141
+ export function intersectChartResolutions(resolutionSets: Array<readonly string[]>): ManualChartResolution[] {
142
+ if (resolutionSets.length === 0) return [];
143
+ const [first, ...rest] = resolutionSets.map((set) => new Set(normalizeManualChartResolutions(set)));
144
+ return sortChartResolutions(
145
+ [...first].filter((resolution) => rest.every((set) => set.has(resolution))),
146
+ );
147
+ }
148
+
149
+ export function sortChartResolutionSupport(support: readonly ChartResolutionSupport[]): ChartResolutionSupport[] {
150
+ return [...support].sort((left, right) => (
151
+ CHART_RESOLUTION_ORDER.indexOf(left.resolution) - CHART_RESOLUTION_ORDER.indexOf(right.resolution)
152
+ ));
153
+ }
154
+
155
+ export function normalizeChartResolutionSupport(support: readonly ChartResolutionSupport[]): ChartResolutionSupport[] {
156
+ const byResolution = new Map<ManualChartResolution, TimeRange>();
157
+ for (const entry of support) {
158
+ if (!isManualChartResolution(entry.resolution)) continue;
159
+ const current = byResolution.get(entry.resolution);
160
+ byResolution.set(
161
+ entry.resolution,
162
+ current ? (compareTimeRange(current, entry.maxRange) >= 0 ? current : entry.maxRange) : entry.maxRange,
163
+ );
164
+ }
165
+ return sortChartResolutionSupport(
166
+ [...byResolution.entries()].map(([resolution, maxRange]) => ({ resolution, maxRange })),
167
+ );
168
+ }
169
+
170
+ export function intersectChartResolutionSupport(
171
+ supportSets: Array<readonly ChartResolutionSupport[]>,
172
+ ): ChartResolutionSupport[] {
173
+ if (supportSets.length === 0) return [];
174
+ const normalized = supportSets.map((support) => normalizeChartResolutionSupport(support));
175
+ const first = normalized[0] ?? [];
176
+ return sortChartResolutionSupport(first.flatMap((entry) => {
177
+ let maxRange = entry.maxRange;
178
+ for (const set of normalized.slice(1)) {
179
+ const match = set.find((candidate) => candidate.resolution === entry.resolution);
180
+ if (!match) return [];
181
+ maxRange = minTimeRange(maxRange, match.maxRange);
182
+ }
183
+ return [{ resolution: entry.resolution, maxRange }];
184
+ }));
185
+ }
186
+
187
+ export function buildChartResolutionSupportMap(
188
+ support: readonly ChartResolutionSupport[],
189
+ ): ReadonlyMap<ManualChartResolution, TimeRange> {
190
+ return new Map(normalizeChartResolutionSupport(support).map((entry) => [entry.resolution, entry.maxRange] as const));
191
+ }
192
+
193
+ export function getSupportMaxRange(
194
+ support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
195
+ resolution: ManualChartResolution,
196
+ ): TimeRange | null {
197
+ if (support instanceof Map) {
198
+ return support.get(resolution) ?? null;
199
+ }
200
+ return normalizeChartResolutionSupport(support).find((entry) => entry.resolution === resolution)?.maxRange ?? null;
201
+ }
202
+
203
+ export function minTimeRange(left: TimeRange, right: TimeRange): TimeRange {
204
+ return compareTimeRange(left, right) <= 0 ? left : right;
205
+ }
206
+
207
+ export function maxTimeRange(left: TimeRange, right: TimeRange): TimeRange {
208
+ return compareTimeRange(left, right) >= 0 ? left : right;
209
+ }
210
+
211
+ export function isTimeRangeAtOrBelow(candidate: TimeRange, maxRange: TimeRange): boolean {
212
+ return compareTimeRange(candidate, maxRange) <= 0;
213
+ }
214
+
215
+ export function clampTimeRangeToMaxRange(range: TimeRange, maxRange: TimeRange): TimeRange {
216
+ return isTimeRangeAtOrBelow(range, maxRange) ? range : maxRange;
217
+ }
218
+
219
+ export function clampTimeRangeForResolution(range: TimeRange, resolution: ChartResolution): TimeRange {
220
+ if (resolution === "auto") return range;
221
+ const maxRange = getSupportMaxRange(
222
+ normalizeChartResolutionSupport([{ resolution, maxRange: "ALL" }]),
223
+ resolution,
224
+ ) ?? "ALL";
225
+ const intrinsicMaxRange = resolution === "1m" || resolution === "5m"
226
+ ? "1W"
227
+ : resolution === "15m"
228
+ ? "1M"
229
+ : resolution === "30m" || resolution === "45m" || resolution === "1h"
230
+ ? "3M"
231
+ : "ALL";
232
+ return clampTimeRangeToMaxRange(range, minTimeRange(maxRange, intrinsicMaxRange));
233
+ }
234
+
235
+ export function isIntradayResolution(resolution: ManualChartResolution): boolean {
236
+ return resolution === "1m"
237
+ || resolution === "5m"
238
+ || resolution === "15m"
239
+ || resolution === "30m"
240
+ || resolution === "45m"
241
+ || resolution === "1h";
242
+ }
243
+
244
+ export function isRangePresetSupported(
245
+ range: TimeRange,
246
+ support: readonly ChartResolutionSupport[] | readonly ManualChartResolution[],
247
+ ): boolean {
248
+ const resolution = RANGE_PRESET_RESOLUTION[range];
249
+ if (support.length === 0) return false;
250
+ if (typeof support[0] === "string") {
251
+ return (support as readonly ManualChartResolution[]).includes(resolution);
252
+ }
253
+ const maxRange = getSupportMaxRange(support as readonly ChartResolutionSupport[], resolution);
254
+ return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
255
+ }
256
+
257
+ export function getActiveRangePreset(
258
+ range: TimeRange,
259
+ resolution: ChartResolution,
260
+ zoomLevel: number,
261
+ panOffset: number,
262
+ ): TimeRange | null {
263
+ if (zoomLevel !== 1 || panOffset !== 0) return null;
264
+ return RANGE_PRESET_RESOLUTION[range] === resolution ? range : null;
265
+ }
266
+
267
+ export function getWidestPresetForResolution(
268
+ resolution: ManualChartResolution,
269
+ maxRange: TimeRange,
270
+ ): TimeRange {
271
+ const presets = RANGE_PRESETS_BY_RESOLUTION[resolution] ?? [];
272
+ const supported = presets.filter((range) => isTimeRangeAtOrBelow(range, maxRange));
273
+ if (supported.length > 0) {
274
+ return supported[supported.length - 1]!;
275
+ }
276
+ const fallback = TIME_RANGE_ORDER.filter((range) => isTimeRangeAtOrBelow(range, maxRange));
277
+ return fallback[fallback.length - 1] ?? "1D";
278
+ }
279
+
280
+ export function getBestSupportedResolutionForPreset(
281
+ range: TimeRange,
282
+ support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
283
+ preferredResolution = getPresetResolution(range),
284
+ ): ManualChartResolution | null {
285
+ const supportedResolutions = CHART_RESOLUTION_ORDER
286
+ .filter((resolution): resolution is ManualChartResolution => resolution !== "auto")
287
+ .filter((resolution) => {
288
+ const maxRange = getSupportMaxRange(support, resolution);
289
+ return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
290
+ });
291
+
292
+ if (supportedResolutions.length === 0) return null;
293
+ if (supportedResolutions.includes(preferredResolution)) return preferredResolution;
294
+
295
+ const preferredIndex = getManualResolutionOrderIndex(preferredResolution);
296
+ const coarserResolution = supportedResolutions.find((resolution) => (
297
+ getManualResolutionOrderIndex(resolution) > preferredIndex
298
+ ));
299
+ if (coarserResolution) return coarserResolution;
300
+
301
+ return supportedResolutions[supportedResolutions.length - 1] ?? null;
302
+ }
303
+
304
+ export function getNextFallbackResolution(
305
+ range: TimeRange,
306
+ currentResolution: ManualChartResolution,
307
+ support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
308
+ ): ChartResolution | null {
309
+ const currentIndex = getManualResolutionOrderIndex(currentResolution);
310
+ const nextResolution = CHART_RESOLUTION_ORDER
311
+ .filter((resolution): resolution is ManualChartResolution => resolution !== "auto")
312
+ .find((resolution) => (
313
+ getManualResolutionOrderIndex(resolution) > currentIndex
314
+ && (() => {
315
+ const maxRange = getSupportMaxRange(support, resolution);
316
+ return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
317
+ })()
318
+ ));
319
+
320
+ return nextResolution ?? "auto";
321
+ }
322
+
323
+ export function getCompatibleBufferRange(presetRange: TimeRange, maxRange: TimeRange | null): TimeRange {
324
+ const preloadRange = getPresetBufferRange(presetRange);
325
+ return maxRange ? clampTimeRangeToMaxRange(preloadRange, maxRange) : preloadRange;
326
+ }
327
+
328
+ export function subtractTimeRange(endDate: Date, range: TimeRange): Date {
329
+ const startDate = new Date(endDate);
330
+ switch (range) {
331
+ case "1D":
332
+ startDate.setDate(startDate.getDate() - 1);
333
+ break;
334
+ case "1W":
335
+ startDate.setDate(startDate.getDate() - 7);
336
+ break;
337
+ case "1M":
338
+ startDate.setMonth(startDate.getMonth() - 1);
339
+ break;
340
+ case "3M":
341
+ startDate.setMonth(startDate.getMonth() - 3);
342
+ break;
343
+ case "6M":
344
+ startDate.setMonth(startDate.getMonth() - 6);
345
+ break;
346
+ case "1Y":
347
+ startDate.setFullYear(startDate.getFullYear() - 1);
348
+ break;
349
+ case "5Y":
350
+ startDate.setFullYear(startDate.getFullYear() - 5);
351
+ break;
352
+ case "ALL":
353
+ startDate.setFullYear(startDate.getFullYear() - 50);
354
+ break;
355
+ }
356
+ return startDate;
357
+ }
358
+
359
+ export function isDateWindowWithinTimeRange(startDate: Date, endDate: Date, maxRange: TimeRange): boolean {
360
+ if (maxRange === "ALL") return true;
361
+ return startDate.getTime() >= subtractTimeRange(endDate, maxRange).getTime();
362
+ }
@@ -1,13 +1,14 @@
1
1
  import type { PricePoint } from "../../types/financials";
2
2
 
3
- export type TimeRange = "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
3
+ export type TimeRange = "1D" | "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
4
+ export type ChartResolution = "auto" | "1m" | "5m" | "15m" | "30m" | "45m" | "1h" | "1d" | "1wk" | "1mo";
4
5
  export type ChartRenderMode = "area" | "line" | "candles" | "ohlc";
5
6
  export type ChartAxisMode = "price" | "percent";
6
7
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
7
8
  export type ResolvedChartRenderer = "kitty" | "braille";
8
9
  export type ComparisonChartRenderMode = "area" | "line";
9
10
 
10
- export const TIME_RANGES: TimeRange[] = ["1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
11
+ export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
11
12
  export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc"];
12
13
  export const CHART_AXIS_MODES: ChartAxisMode[] = ["price", "percent"];
13
14
  export const CHART_RENDERER_PREFERENCES: ChartRendererPreference[] = ["auto", "kitty", "braille"];
@@ -15,6 +16,7 @@ export const COMPARISON_RENDER_MODES: ComparisonChartRenderMode[] = ["area", "li
15
16
 
16
17
  /** Number of trading days for each time range */
17
18
  export const RANGE_DAYS: Record<TimeRange, number> = {
19
+ "1D": 1,
18
20
  "1W": 5,
19
21
  "1M": 21,
20
22
  "3M": 63,
@@ -25,7 +27,10 @@ export const RANGE_DAYS: Record<TimeRange, number> = {
25
27
  };
26
28
 
27
29
  export interface ChartViewState {
28
- timeRange: TimeRange;
30
+ presetRange: TimeRange;
31
+ bufferRange: TimeRange;
32
+ activePreset: TimeRange | null;
33
+ resolution: ChartResolution;
29
34
  panOffset: number; // data points shifted left from right edge (0 = most recent)
30
35
  zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
31
36
  cursorX: number | null; // local plot x in cell units; may be fractional with pixel mouse
@@ -72,7 +77,10 @@ export interface ComparisonChartSeries {
72
77
  }
73
78
 
74
79
  export interface ComparisonChartViewState {
75
- timeRange: TimeRange;
80
+ presetRange: TimeRange;
81
+ bufferRange: TimeRange;
82
+ activePreset: TimeRange | null;
83
+ resolution: ChartResolution;
76
84
  panOffset: number;
77
85
  zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
78
86
  cursorX: number | null;
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { RIGHT_EDGE_ANCHOR_RATIO, resolveAnchoredChartZoom } from "./chart-viewport";
3
+
4
+ describe("resolveAnchoredChartZoom", () => {
5
+ test("keeps keyboard zoom pinned to the latest data when anchored to the right edge", () => {
6
+ expect(resolveAnchoredChartZoom(252, 1, 0, 2, RIGHT_EDGE_ANCHOR_RATIO)).toEqual({
7
+ zoomLevel: 2,
8
+ panOffset: 0,
9
+ });
10
+ });
11
+
12
+ test("preserves the current right edge when zooming a panned view", () => {
13
+ expect(resolveAnchoredChartZoom(252, 2, 20, 3, RIGHT_EDGE_ANCHOR_RATIO)).toEqual({
14
+ zoomLevel: 3,
15
+ panOffset: 20,
16
+ });
17
+ });
18
+
19
+ test("still supports center-anchored zoom for pointer-driven interactions", () => {
20
+ expect(resolveAnchoredChartZoom(252, 1, 0, 2, 0.5)).toEqual({
21
+ zoomLevel: 2,
22
+ panOffset: 63,
23
+ });
24
+ });
25
+ });
@@ -1,4 +1,5 @@
1
1
  const MIN_VISIBLE_POINTS = 10;
2
+ export const RIGHT_EDGE_ANCHOR_RATIO = 1;
2
3
 
3
4
  function clamp(value: number, min: number, max: number): number {
4
5
  return Math.max(min, Math.min(max, value));
@@ -19,3 +20,30 @@ export function getVisiblePointCount(totalPoints: number, zoomLevel: number): nu
19
20
  const clampedZoom = clampChartZoom(totalPoints, zoomLevel);
20
21
  return Math.min(totalPoints, Math.max(Math.floor(totalPoints / clampedZoom), MIN_VISIBLE_POINTS));
21
22
  }
23
+
24
+ export function resolveAnchoredChartZoom(
25
+ totalPoints: number,
26
+ currentZoomLevel: number,
27
+ currentPanOffset: number,
28
+ nextZoomLevel: number,
29
+ anchorRatio: number,
30
+ ): { zoomLevel: number; panOffset: number } {
31
+ if (totalPoints <= 0) {
32
+ return { zoomLevel: 1, panOffset: 0 };
33
+ }
34
+
35
+ const clampedZoom = clampChartZoom(totalPoints, nextZoomLevel);
36
+ const currentVisibleCount = getVisiblePointCount(totalPoints, currentZoomLevel);
37
+ const nextVisibleCount = getVisiblePointCount(totalPoints, clampedZoom);
38
+ const currentPan = clamp(currentPanOffset, 0, Math.max(totalPoints - currentVisibleCount, 0));
39
+ const ratio = clamp(anchorRatio, 0, 1);
40
+ const anchorIndex = totalPoints - currentPan - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
41
+ const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
42
+ const clampedStart = clamp(nextStart, 0, Math.max(totalPoints - nextVisibleCount, 0));
43
+ const nextPanOffset = totalPoints - nextVisibleCount - clampedStart;
44
+
45
+ return {
46
+ zoomLevel: clampedZoom,
47
+ panOffset: clamp(nextPanOffset, 0, Math.max(totalPoints - nextVisibleCount, 0)),
48
+ };
49
+ }
@@ -206,6 +206,32 @@ describe("renderChart", () => {
206
206
  expect(buildTimeAxis(yearlyDates, 48)).toBe("2020 2021 2022 2023 2024 2025");
207
207
  });
208
208
 
209
+ test("uses source time-axis dates instead of projected extrema dates when provided", () => {
210
+ const sourceHistory = Array.from({ length: 96 }, (_, index) => ({
211
+ date: new Date(Date.UTC(2026, 2, 25, index * 3)),
212
+ close: index % 2 === 0 ? 0.7 + index * 0.001 : 0.3 + index * 0.001,
213
+ volume: 100 + index,
214
+ })) as PricePoint[];
215
+ const sourceDates = sourceHistory.map((point) => point.date);
216
+ const projection = projectChartData(sourceHistory, 48, "area", true);
217
+
218
+ const result = renderChart(projection.points, {
219
+ width: 48,
220
+ height: 8,
221
+ showVolume: false,
222
+ volumeHeight: 0,
223
+ cursorX: null,
224
+ cursorY: null,
225
+ mode: "area",
226
+ axisMode: "price",
227
+ currency: "USD",
228
+ colors: palette,
229
+ timeAxisDates: sourceDates,
230
+ });
231
+
232
+ expect(result.timeLabels).toBe(buildTimeAxis(sourceDates, 48));
233
+ });
234
+
209
235
  test("maps active bucket metadata across all render modes", () => {
210
236
  const cases: Array<{ mode: ChartRenderMode; width: number; cursorX: number }> = [
211
237
  { mode: "area", width: 12, cursorX: 4 },
@@ -7,7 +7,7 @@ import type {
7
7
  TimeRange,
8
8
  } from "./chart-types";
9
9
  import { RANGE_DAYS } from "./chart-types";
10
- import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
10
+ import { getVisiblePointCount, resolveAnchoredChartZoom } from "./chart-viewport";
11
11
 
12
12
  export interface ComparisonProjectedPoint {
13
13
  date: Date;
@@ -97,23 +97,21 @@ function getUniqueSortedDates(series: ComparisonChartSeries[]): Date[] {
97
97
 
98
98
  export function getMaxComparisonPanOffset(
99
99
  series: ComparisonChartSeries[],
100
- timeRange: TimeRange,
100
+ _timeRange: TimeRange,
101
101
  zoomLevel: number,
102
102
  chartWidth: number,
103
103
  ): number {
104
- const filtered = filterComparisonSeriesByTimeRange(series, timeRange);
105
- const dates = getUniqueSortedDates(filtered);
104
+ const dates = getUniqueSortedDates(series);
106
105
  const visibleCount = getVisiblePointCount(dates.length, zoomLevel);
107
106
  return Math.max(dates.length - visibleCount, 0);
108
107
  }
109
108
 
110
109
  export function getVisibleComparisonWindow(
111
110
  series: ComparisonChartSeries[],
112
- viewState: Pick<ComparisonChartViewState, "timeRange" | "panOffset" | "zoomLevel">,
111
+ viewState: Pick<ComparisonChartViewState, "panOffset" | "zoomLevel">,
113
112
  chartWidth: number,
114
113
  ): ComparisonVisibleWindow {
115
- const filtered = filterComparisonSeriesByTimeRange(series, viewState.timeRange);
116
- const dates = getUniqueSortedDates(filtered);
114
+ const dates = getUniqueSortedDates(series);
117
115
 
118
116
  if (dates.length === 0) {
119
117
  return { dates: [], startIdx: 0, endIdx: 0, totalDates: 0 };
@@ -138,26 +136,21 @@ export function applyComparisonZoomAroundAnchor(
138
136
  nextZoomLevel: number,
139
137
  anchorRatio: number,
140
138
  series: ComparisonChartSeries[],
141
- chartWidth: number,
142
139
  ): ComparisonChartViewState {
143
- const filtered = filterComparisonSeriesByTimeRange(series, view.timeRange);
144
- const dates = getUniqueSortedDates(filtered);
140
+ const dates = getUniqueSortedDates(series);
145
141
  if (dates.length === 0) return view;
146
142
 
147
- const clampedZoom = clampChartZoom(dates.length, nextZoomLevel);
148
- const currentVisibleCount = getVisiblePointCount(dates.length, view.zoomLevel);
149
- const nextVisibleCount = getVisiblePointCount(dates.length, clampedZoom);
150
- const currentPanOffset = clamp(view.panOffset, 0, Math.max(dates.length - currentVisibleCount, 0));
151
- const ratio = clamp(anchorRatio, 0, 1);
152
- const anchorIndex = dates.length - currentPanOffset - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
153
- const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
154
- const clampedStart = clamp(nextStart, 0, Math.max(dates.length - nextVisibleCount, 0));
155
- const nextPanOffset = dates.length - nextVisibleCount - clampedStart;
143
+ const nextZoom = resolveAnchoredChartZoom(
144
+ dates.length,
145
+ view.zoomLevel,
146
+ view.panOffset,
147
+ nextZoomLevel,
148
+ anchorRatio,
149
+ );
156
150
 
157
151
  return {
158
152
  ...view,
159
- zoomLevel: clampedZoom,
160
- panOffset: clamp(nextPanOffset, 0, Math.max(dates.length - nextVisibleCount, 0)),
153
+ ...nextZoom,
161
154
  };
162
155
  }
163
156
 
@@ -231,17 +224,17 @@ export function resolveComparisonAxisMode(
231
224
  export function projectComparisonChartData(
232
225
  series: ComparisonChartSeries[],
233
226
  chartWidth: number,
234
- viewState: Pick<ComparisonChartViewState, "timeRange" | "panOffset" | "zoomLevel" | "renderMode">,
227
+ viewState: Pick<ComparisonChartViewState, "panOffset" | "zoomLevel" | "renderMode">,
235
228
  requestedAxisMode: ChartAxisMode,
236
229
  ): ComparisonChartProjection {
237
- const filteredSeries = filterComparisonSeriesByTimeRange(series, viewState.timeRange);
238
- const window = getVisibleComparisonWindow(filteredSeries, viewState, chartWidth);
239
- const axisResolution = resolveComparisonAxisMode(requestedAxisMode, filteredSeries, window.dates);
230
+ const normalizedSeries = series.map((entry) => ({ ...entry, points: normalizeSeriesPoints(entry.points) }));
231
+ const window = getVisibleComparisonWindow(normalizedSeries, viewState, chartWidth);
232
+ const axisResolution = resolveComparisonAxisMode(requestedAxisMode, normalizedSeries, window.dates);
240
233
  const requestedMode = normalizeComparisonMode(viewState.renderMode);
241
234
  if (window.dates.length === 0) {
242
235
  return {
243
236
  dates: [],
244
- series: filteredSeries.map((entry) => ({
237
+ series: normalizedSeries.map((entry) => ({
245
238
  symbol: entry.symbol,
246
239
  color: entry.color,
247
240
  fillColor: entry.fillColor,
@@ -261,7 +254,7 @@ export function projectComparisonChartData(
261
254
  const bucketCount = Math.min(Math.max(chartWidth, 1), Math.max(window.dates.length, 1));
262
255
  const bucketSize = window.dates.length > 0 ? window.dates.length / bucketCount : 1;
263
256
 
264
- const projectedSeries = filteredSeries.map((entry) => {
257
+ const projectedSeries = normalizedSeries.map((entry) => {
265
258
  const rawByDate = buildSeriesMap(entry.points);
266
259
  const visibleRawValues = window.dates
267
260
  .map((date) => rawByDate.get(date.getTime()) ?? null)