gloomberb 0.3.7 → 0.4.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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  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-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  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
@@ -1,14 +1,51 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
3
3
  import { useKeyboard, useRenderer } from "@opentui/react";
4
- import { useAppState, usePaneInstanceId, usePaneTicker } from "../../state/app-context";
4
+ import { useAppDispatch, useAppSelector, usePaneInstanceId, usePaneSettingValue, usePaneTicker } from "../../state/app-context";
5
5
  import { saveConfig } from "../../data/config-store";
6
+ import { getSharedDataProvider } from "../../plugins/registry";
6
7
  import { colors, priceColor } from "../../theme/colors";
7
8
  import { formatCompact, formatCurrency } from "../../utils/format";
8
- import { useChartQuery, useResolvedEntryValue } from "../../market-data/hooks";
9
+ import { useChartQuery } from "../../market-data/hooks";
9
10
  import { instrumentFromTicker } from "../../market-data/request-types";
10
- import { filterByTimeRange, getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
11
- import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
11
+ import { usePersistChartControlSelection } from "./chart-pane-settings";
12
+ import { getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
13
+ import {
14
+ buildVisibleDateWindow,
15
+ clearActivePreset,
16
+ formatVisibleSpanLabel,
17
+ getCanonicalZoomLevel,
18
+ getPointDates,
19
+ isCanonicalPresetViewport,
20
+ resolveChartBodyState,
21
+ resolvePresetSelection,
22
+ resolvePresetSelectionWithResolution,
23
+ resolveResolutionSelection,
24
+ resolveStoredChartSelection,
25
+ } from "./chart-controller";
26
+ import {
27
+ buildChartResolutionSupportMap,
28
+ clampTimeRangeToMaxRange,
29
+ DEFAULT_TICKER_CHART_RANGE_PRESET,
30
+ DEFAULT_TICKER_CHART_RESOLUTION,
31
+ DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS,
32
+ getBestSupportedResolutionForPreset,
33
+ getChartResolutionLabel,
34
+ getNextFallbackResolution,
35
+ getNextBufferRange,
36
+ getPresetResolution,
37
+ getSupportMaxRange,
38
+ isRangePresetSupported,
39
+ normalizeChartResolutionSupport,
40
+ sortChartResolutions,
41
+ type ChartResolutionSupport,
42
+ type ManualChartResolution,
43
+ } from "./chart-resolution";
44
+ import {
45
+ getVisiblePointCount,
46
+ RIGHT_EDGE_ANCHOR_RATIO,
47
+ resolveAnchoredChartZoom,
48
+ } from "./chart-viewport";
12
49
  import {
13
50
  buildChartScene,
14
51
  formatDateShort,
@@ -30,6 +67,7 @@ import {
30
67
  TIME_RANGES,
31
68
  type ChartAxisMode,
32
69
  type ChartRenderMode,
70
+ type ChartResolution,
33
71
  type ChartViewState,
34
72
  type ResolvedChartRenderer,
35
73
  } from "./chart-types";
@@ -46,7 +84,8 @@ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-suppor
46
84
  import { resolveChartRendererState } from "./native/renderer-selection";
47
85
  import { getNativeSurfaceManager } from "./native/surface-manager";
48
86
  import { syncCachedNativeSurface } from "./native/surface-sync";
49
- import type { PricePoint } from "../../types/financials";
87
+ import type { PricePoint, TickerFinancials } from "../../types/financials";
88
+ import type { TickerRecord } from "../../types/ticker";
50
89
 
51
90
  const MODE_CHIPS: Record<ChartRenderMode, string> = {
52
91
  area: "A",
@@ -73,11 +112,22 @@ interface StockChartProps {
73
112
  currencyOverride?: string | null;
74
113
  }
75
114
 
115
+ interface ResolvedStockChartProps extends StockChartProps {
116
+ symbol: string | null;
117
+ ticker: TickerRecord | null;
118
+ financials: TickerFinancials | null;
119
+ }
120
+
76
121
  interface DragState {
77
122
  startGlobalX: number;
78
123
  startPanOffset: number;
79
124
  }
80
125
 
126
+ type PendingExpansionAction =
127
+ | { kind: "zoom-out"; targetVisibleCount: number; anchorRatio: number }
128
+ | { kind: "pan-left"; targetPanOffset: number }
129
+ | null;
130
+
81
131
  interface ChartMouseEvent {
82
132
  x: number;
83
133
  y: number;
@@ -400,10 +450,9 @@ function formatAxisCell(label: string | null, width: number): string {
400
450
  return label.length >= width ? label.slice(0, width) : label.padStart(width);
401
451
  }
402
452
 
403
- function getMaxPanOffset(history: PricePoint[], timeRange: ChartViewState["timeRange"], zoomLevel: number, chartWidth: number): number {
404
- const filtered = filterByTimeRange(history, timeRange);
405
- const visibleCount = getVisiblePointCount(filtered.length, zoomLevel);
406
- return Math.max(filtered.length - visibleCount, 0);
453
+ function getMaxPanOffset(history: PricePoint[], zoomLevel: number): number {
454
+ const visibleCount = getVisiblePointCount(history.length, zoomLevel);
455
+ return Math.max(history.length - visibleCount, 0);
407
456
  }
408
457
 
409
458
  function applyZoomAroundAnchor(
@@ -411,25 +460,20 @@ function applyZoomAroundAnchor(
411
460
  nextZoomLevel: number,
412
461
  anchorRatio: number,
413
462
  history: PricePoint[],
414
- chartWidth: number,
415
463
  ): ChartViewState {
416
- const filtered = filterByTimeRange(history, view.timeRange);
417
- if (filtered.length === 0) return view;
418
-
419
- const clampedZoom = clampChartZoom(filtered.length, nextZoomLevel);
420
- const currentVisibleCount = getVisiblePointCount(filtered.length, view.zoomLevel);
421
- const nextVisibleCount = getVisiblePointCount(filtered.length, clampedZoom);
422
- const currentPanOffset = clamp(view.panOffset, 0, Math.max(filtered.length - currentVisibleCount, 0));
423
- const ratio = clamp(anchorRatio, 0, 1);
424
- const anchorIndex = filtered.length - currentPanOffset - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
425
- const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
426
- const clampedStart = clamp(nextStart, 0, Math.max(filtered.length - nextVisibleCount, 0));
427
- const nextPanOffset = filtered.length - nextVisibleCount - clampedStart;
464
+ if (history.length === 0) return view;
465
+
466
+ const nextZoom = resolveAnchoredChartZoom(
467
+ history.length,
468
+ view.zoomLevel,
469
+ view.panOffset,
470
+ nextZoomLevel,
471
+ anchorRatio,
472
+ );
428
473
 
429
474
  return {
430
- ...view,
431
- zoomLevel: clampedZoom,
432
- panOffset: clamp(nextPanOffset, 0, Math.max(filtered.length - nextVisibleCount, 0)),
475
+ ...clearActivePreset(view),
476
+ ...nextZoom,
433
477
  };
434
478
  }
435
479
 
@@ -507,7 +551,12 @@ function resolveSelectionCursor(
507
551
  };
508
552
  }
509
553
 
510
- export function StockChart({
554
+ export function StockChart(props: StockChartProps) {
555
+ const { symbol, ticker, financials } = usePaneTicker();
556
+ return <ResolvedStockChart {...props} symbol={symbol} ticker={ticker} financials={financials} />;
557
+ }
558
+
559
+ export const ResolvedStockChart = memo(function ResolvedStockChart({
511
560
  width,
512
561
  height,
513
562
  focused,
@@ -516,22 +565,32 @@ export function StockChart({
516
565
  axisMode = "price",
517
566
  historyOverride = null,
518
567
  currencyOverride = null,
519
- }: StockChartProps) {
568
+ symbol,
569
+ ticker,
570
+ financials,
571
+ }: ResolvedStockChartProps) {
520
572
  const renderer = useRenderer();
521
- const { state, dispatch } = useAppState();
573
+ const dispatch = useAppDispatch();
574
+ const config = useAppSelector((state) => state.config);
522
575
  const paneId = usePaneInstanceId();
523
- const { ticker, financials } = usePaneTicker();
524
576
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
525
- const defaultRenderMode = state.config.chartPreferences.defaultRenderMode;
526
- const preferredRenderer = state.config.chartPreferences.renderer;
577
+ const defaultRenderMode = config.chartPreferences.defaultRenderMode;
578
+ const preferredRenderer = config.chartPreferences.renderer;
579
+ const [storedRangePreset] = usePaneSettingValue("chartRangePreset", DEFAULT_TICKER_CHART_RANGE_PRESET);
580
+ const [storedResolution] = usePaneSettingValue<ChartResolution>("chartResolution", DEFAULT_TICKER_CHART_RESOLUTION);
581
+ const persistChartControls = usePersistChartControlSelection("chartRangePreset");
527
582
  const [viewState, setViewState] = useState<ChartViewState>({
528
- timeRange: compact ? "1Y" : "5Y",
583
+ presetRange: compact ? "1Y" : storedRangePreset,
584
+ bufferRange: compact ? "1Y" : storedRangePreset,
585
+ activePreset: compact ? null : storedRangePreset,
586
+ resolution: compact ? "auto" : storedResolution,
529
587
  panOffset: 0,
530
588
  zoomLevel: 1,
531
589
  cursorX: null,
532
590
  cursorY: null,
533
591
  renderMode: defaultRenderMode,
534
592
  });
593
+ const [resolutionSupport, setResolutionSupport] = useState<ChartResolutionSupport[] | null>(null);
535
594
  const [showVolume, setShowVolume] = useState(!compact);
536
595
  const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
537
596
  const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
@@ -546,6 +605,9 @@ export function StockChart({
546
605
  const targetCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
547
606
  const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
548
607
  const animationFrameRef = useRef<number | null>(null);
608
+ const pendingCanonicalResetRef = useRef(1);
609
+ const appliedCanonicalResetRef = useRef(0);
610
+ const pendingExpansionRef = useRef<PendingExpansionAction>(null);
549
611
 
550
612
  const commitDisplayCursor = (next: DisplayCursorState) => {
551
613
  displayCursorRef.current = next;
@@ -631,16 +693,107 @@ export function StockChart({
631
693
  () => instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null),
632
694
  [ticker],
633
695
  );
696
+ const capabilityKey = instrumentRef ? [
697
+ instrumentRef.symbol,
698
+ instrumentRef.exchange ?? "",
699
+ instrumentRef.brokerId ?? "",
700
+ instrumentRef.brokerInstanceId ?? "",
701
+ instrumentRef.instrument?.conId ?? "",
702
+ ].join("|") : null;
703
+
704
+ useEffect(() => {
705
+ if (compact) return;
706
+ pendingCanonicalResetRef.current += 1;
707
+ setViewState((current) => {
708
+ const nextState = resolvePresetSelection(
709
+ {
710
+ ...current,
711
+ resolution: storedResolution,
712
+ },
713
+ storedRangePreset,
714
+ storedResolution === "auto" ? null : getSupportMaxRange(resolutionSupport ?? [], storedResolution),
715
+ );
716
+ return nextState;
717
+ });
718
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
719
+ }, [compact, resolutionSupport, storedRangePreset, storedResolution]);
720
+
721
+ useEffect(() => {
722
+ if (compact || !instrumentRef) {
723
+ setResolutionSupport(null);
724
+ return;
725
+ }
726
+ const provider = getSharedDataProvider();
727
+ if (!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) {
728
+ setResolutionSupport(null);
729
+ return;
730
+ }
731
+
732
+ let cancelled = false;
733
+ setResolutionSupport(null);
734
+ Promise.resolve(provider.getChartResolutionSupport
735
+ ? provider.getChartResolutionSupport(
736
+ instrumentRef.symbol,
737
+ instrumentRef.exchange ?? "",
738
+ {
739
+ brokerId: instrumentRef.brokerId,
740
+ brokerInstanceId: instrumentRef.brokerInstanceId,
741
+ instrument: instrumentRef.instrument ?? null,
742
+ },
743
+ )
744
+ : normalizeChartResolutionSupport(
745
+ (provider.getChartResolutionCapabilities?.(
746
+ instrumentRef.symbol,
747
+ instrumentRef.exchange ?? "",
748
+ {
749
+ brokerId: instrumentRef.brokerId,
750
+ brokerInstanceId: instrumentRef.brokerInstanceId,
751
+ instrument: instrumentRef.instrument ?? null,
752
+ },
753
+ ) ?? []).map((resolution) => ({ resolution, maxRange: "ALL" })),
754
+ )
755
+ ).then((support) => {
756
+ if (!cancelled) {
757
+ setResolutionSupport(support);
758
+ }
759
+ }).catch(() => {
760
+ if (!cancelled) {
761
+ setResolutionSupport(null);
762
+ }
763
+ });
764
+
765
+ return () => {
766
+ cancelled = true;
767
+ };
768
+ }, [capabilityKey, compact, instrumentRef]);
769
+
770
+ const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
771
+ const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
772
+ resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
773
+ ), [resolutionSupport]);
774
+ const availableManualResolutions = resolutionSupport?.map((entry) => entry.resolution) ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS;
775
+ const effectiveResolution: ChartResolution = !compact
776
+ && viewState.resolution !== "auto"
777
+ && resolutionSupport !== null
778
+ && !supportMap.has(viewState.resolution)
779
+ ? "auto"
780
+ : viewState.resolution;
781
+ const resolutionChips = useMemo(
782
+ () => sortChartResolutions(["auto", ...availableManualResolutions] as ChartResolution[]),
783
+ [availableManualResolutions],
784
+ );
785
+
634
786
  const baseChartEntry = useChartQuery(
635
787
  !compact && instrumentRef
636
788
  ? {
637
789
  instrument: instrumentRef,
638
- range: viewState.timeRange,
639
- granularity: "daily",
790
+ bufferRange: viewState.bufferRange,
791
+ granularity: effectiveResolution === "auto" ? "range" : "resolution",
792
+ resolution: effectiveResolution === "auto" ? undefined : effectiveResolution,
640
793
  }
641
794
  : null,
642
795
  );
643
- const rangeHistory = useResolvedEntryValue(baseChartEntry);
796
+ const baseBodyState = resolveChartBodyState(baseChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
644
797
  const axisWidth = compact
645
798
  ? axisMode === "percent" ? 11 : 8
646
799
  : axisMode === "percent" ? 11 : 10;
@@ -648,12 +801,11 @@ export function StockChart({
648
801
  const chartWidth = Math.max(width - axisWidth - axisGap, compact ? 12 : 20);
649
802
  const maxCursorX = chartWidth - 1;
650
803
  const panStep = Math.max(Math.floor(chartWidth / 10), 1);
651
- const baseHistory = historyOverride
652
- ?? (rangeHistory && rangeHistory.length > 0
653
- ? rangeHistory
654
- : (financials?.priceHistory ?? []));
804
+ const baseHistory = compact
805
+ ? (historyOverride ?? financials?.priceHistory ?? [])
806
+ : (baseBodyState.data ?? []);
655
807
  const detailRequest = useMemo(() => {
656
- if (compact || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
808
+ if (compact || effectiveResolution !== "auto" || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
657
809
  const window = getVisibleWindow(baseHistory, viewState, chartWidth);
658
810
  if (window.points.length < 2) return null;
659
811
 
@@ -666,17 +818,70 @@ export function StockChart({
666
818
 
667
819
  return {
668
820
  instrument: instrumentRef,
669
- range: viewState.timeRange,
821
+ bufferRange: viewState.bufferRange,
670
822
  granularity: "detail" as const,
671
823
  startDate,
672
824
  endDate,
673
825
  barSize,
674
826
  };
675
- }, [baseHistory, chartWidth, compact, instrumentRef, viewState]);
827
+ }, [baseHistory, chartWidth, compact, effectiveResolution, instrumentRef, viewState]);
676
828
  const detailChartEntry = useChartQuery(detailRequest);
677
- const detailHistory = useResolvedEntryValue(detailChartEntry);
829
+ const detailBodyState = resolveChartBodyState(detailChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
830
+ const history = (effectiveResolution === "auto" && viewState.zoomLevel > 1 && detailBodyState.data) ? detailBodyState.data : baseHistory;
831
+ const baseHistoryDates = useMemo(() => getPointDates(baseHistory), [baseHistory]);
832
+ const visibleDateWindow = useMemo(() => buildVisibleDateWindow(baseHistoryDates, viewState.panOffset, viewState.zoomLevel), [baseHistoryDates, viewState.panOffset, viewState.zoomLevel]);
833
+ const activePreset = compact || !isCanonicalPresetViewport(baseHistoryDates, {
834
+ activePreset: viewState.activePreset,
835
+ panOffset: viewState.panOffset,
836
+ zoomLevel: viewState.zoomLevel,
837
+ resolution: effectiveResolution,
838
+ })
839
+ ? null
840
+ : viewState.activePreset;
678
841
 
679
- const history = (viewState.zoomLevel > 1 && detailHistory) ? detailHistory : baseHistory;
842
+ useEffect(() => {
843
+ if (compact || baseHistoryDates.length === 0) return;
844
+
845
+ if (appliedCanonicalResetRef.current < pendingCanonicalResetRef.current) {
846
+ const canonicalZoom = getCanonicalZoomLevel(baseHistoryDates, viewState.presetRange);
847
+ appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
848
+ setViewState((current) => (
849
+ current.zoomLevel === canonicalZoom && current.panOffset === 0
850
+ ? current
851
+ : {
852
+ ...current,
853
+ zoomLevel: canonicalZoom,
854
+ panOffset: 0,
855
+ cursorX: null,
856
+ cursorY: null,
857
+ }
858
+ ));
859
+ return;
860
+ }
861
+
862
+ if (!pendingExpansionRef.current) return;
863
+ const pendingExpansion = pendingExpansionRef.current;
864
+ pendingExpansionRef.current = null;
865
+ setViewState((current) => {
866
+ if (pendingExpansion.kind === "zoom-out") {
867
+ const nextVisibleCount = Math.min(baseHistoryDates.length, Math.max(pendingExpansion.targetVisibleCount, 1));
868
+ return {
869
+ ...current,
870
+ ...resolveAnchoredChartZoom(
871
+ baseHistoryDates.length,
872
+ 1,
873
+ 0,
874
+ baseHistoryDates.length / nextVisibleCount,
875
+ pendingExpansion.anchorRatio,
876
+ ),
877
+ };
878
+ }
879
+ return {
880
+ ...current,
881
+ panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxPanOffset(baseHistory, current.zoomLevel)),
882
+ };
883
+ });
884
+ }, [baseHistory, baseHistoryDates, compact, viewState.presetRange]);
680
885
 
681
886
  useEffect(() => {
682
887
  if (interactive) {
@@ -717,9 +922,9 @@ export function StockChart({
717
922
  const persistDefaultRenderMode = (nextMode: ChartRenderMode) => {
718
923
  if (nextMode === defaultRenderMode) return;
719
924
  const nextConfig = {
720
- ...state.config,
925
+ ...config,
721
926
  chartPreferences: {
722
- ...state.config.chartPreferences,
927
+ ...config.chartPreferences,
723
928
  defaultRenderMode: nextMode,
724
929
  },
725
930
  };
@@ -727,16 +932,43 @@ export function StockChart({
727
932
  saveConfig(nextConfig).catch(() => {});
728
933
  };
729
934
 
730
- const setRange = (range: ChartViewState["timeRange"]) => {
731
- updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
935
+ const expandBufferRange = (action: PendingExpansionAction): boolean => {
936
+ if (compact) return false;
937
+ const nextCandidate = getNextBufferRange(viewState.bufferRange);
938
+ const nextBufferRange = effectiveResolution === "auto"
939
+ ? nextCandidate
940
+ : clampTimeRangeToMaxRange(nextCandidate, supportMap.get(effectiveResolution) ?? viewState.bufferRange);
941
+ if (nextBufferRange === viewState.bufferRange) return false;
942
+ pendingExpansionRef.current = action;
732
943
  setViewState((current) => ({
733
- ...current,
734
- timeRange: range,
735
- panOffset: 0,
736
- zoomLevel: 1,
944
+ ...clearActivePreset(current),
945
+ bufferRange: nextBufferRange,
737
946
  cursorX: null,
738
947
  cursorY: null,
739
948
  }));
949
+ return true;
950
+ };
951
+
952
+ const setRange = (range: TimeRange) => {
953
+ if (!compact && !isRangePresetSupported(range, effectiveResolutionSupport)) return;
954
+ const supportMaxRange = getSupportMaxRange(effectiveResolutionSupport, getPresetResolution(range));
955
+ if (!compact) {
956
+ persistChartControls(range, getPresetResolution(range));
957
+ }
958
+ pendingCanonicalResetRef.current += 1;
959
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
960
+ setViewState((current) => resolvePresetSelection(current, range, supportMaxRange));
961
+ };
962
+
963
+ const setResolution = (resolution: ChartResolution) => {
964
+ if (compact) return;
965
+ if (resolution !== "auto" && !availableManualResolutions.includes(resolution)) return;
966
+ const nextState = resolveResolutionSelection(viewState, resolution, supportMap, visibleDateWindow);
967
+ if (!nextState) return;
968
+ pendingCanonicalResetRef.current += 1;
969
+ persistChartControls(nextState.presetRange, resolution);
970
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
971
+ setViewState(nextState);
740
972
  };
741
973
 
742
974
  const setRenderMode = (mode: ChartRenderMode) => {
@@ -744,12 +976,12 @@ export function StockChart({
744
976
  setViewState((current) => ({ ...current, renderMode: mode }));
745
977
  };
746
978
 
747
- const headerRows = compact ? 0 : 3;
979
+ const headerRows = compact ? 0 : 4;
748
980
  const helpRow = compact ? 0 : 1;
749
981
  const timeAxisRow = 1;
750
982
  const volumeHeight = showVolume && !compact ? 3 : 0;
751
983
  const chartHeight = Math.max(height - headerRows - helpRow - timeAxisRow, 4);
752
- const isDetailView = viewState.zoomLevel > 1 && detailHistory != null && detailHistory.length > 0;
984
+ const isDetailView = effectiveResolution === "auto" && viewState.zoomLevel > 1 && !!detailBodyState.data?.length;
753
985
  const historyRenderKey = history.length === 0
754
986
  ? "empty"
755
987
  : [
@@ -770,7 +1002,7 @@ export function StockChart({
770
1002
  return isDetailView
771
1003
  ? { points: history, startIdx: 0, endIdx: history.length }
772
1004
  : getVisibleWindow(history, viewState, chartWidth);
773
- }, [chartWidth, history, historyOverride, isDetailView, viewState.panOffset, viewState.timeRange, viewState.zoomLevel]);
1005
+ }, [chartWidth, history, historyOverride, isDetailView, viewState.panOffset, viewState.zoomLevel]);
774
1006
  const timeAxisDates = useMemo(
775
1007
  () => chartWindow.points.map((point) => point.date),
776
1008
  [chartWindow.points],
@@ -785,23 +1017,56 @@ export function StockChart({
785
1017
 
786
1018
  switch (event.name) {
787
1019
  case "=":
788
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.5, 0.5, baseHistory, chartWidth));
1020
+ setViewState((current) => applyZoomAroundAnchor(
1021
+ current,
1022
+ current.zoomLevel * 1.5,
1023
+ RIGHT_EDGE_ANCHOR_RATIO,
1024
+ baseHistory,
1025
+ ));
789
1026
  return;
790
1027
  case "-":
791
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.5, 0.5, baseHistory, chartWidth));
1028
+ if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1029
+ kind: "zoom-out",
1030
+ targetVisibleCount: Math.round(baseHistory.length * 1.5),
1031
+ anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
1032
+ })) {
1033
+ return;
1034
+ }
1035
+ setViewState((current) => applyZoomAroundAnchor(
1036
+ current,
1037
+ current.zoomLevel / 1.5,
1038
+ RIGHT_EDGE_ANCHOR_RATIO,
1039
+ baseHistory,
1040
+ ));
792
1041
  return;
793
1042
  case "0":
1043
+ pendingCanonicalResetRef.current += 1;
794
1044
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
795
- setViewState((current) => ({ ...current, panOffset: 0, zoomLevel: 1, cursorX: null, cursorY: null }));
1045
+ setViewState((current) => {
1046
+ const nextState = resolveResolutionSelection(current, effectiveResolution, supportMap, visibleDateWindow) ?? current;
1047
+ return {
1048
+ ...nextState,
1049
+ panOffset: 0,
1050
+ zoomLevel: 1,
1051
+ cursorX: null,
1052
+ cursorY: null,
1053
+ };
1054
+ });
796
1055
  return;
797
1056
  case "v":
798
1057
  setShowVolume((value) => !value);
799
1058
  return;
800
1059
  case "a":
801
- setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
1060
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1061
+ kind: "pan-left",
1062
+ targetPanOffset: viewState.panOffset + panStep,
1063
+ })) {
1064
+ return;
1065
+ }
1066
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
802
1067
  return;
803
1068
  case "d":
804
- setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
1069
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
805
1070
  return;
806
1071
  case "m":
807
1072
  setViewState((current) => {
@@ -812,6 +1077,12 @@ export function StockChart({
812
1077
  return { ...current, renderMode: nextMode };
813
1078
  });
814
1079
  return;
1080
+ case "r": {
1081
+ const currentIndex = resolutionChips.indexOf(effectiveResolution);
1082
+ const nextResolution = resolutionChips[(currentIndex + 1) % resolutionChips.length] ?? "auto";
1083
+ setResolution(nextResolution);
1084
+ return;
1085
+ }
815
1086
  }
816
1087
 
817
1088
  if (event.name >= "1" && event.name <= "7") {
@@ -825,7 +1096,13 @@ export function StockChart({
825
1096
  switch (event.name) {
826
1097
  case "left":
827
1098
  if (event.shift) {
828
- setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
1099
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1100
+ kind: "pan-left",
1101
+ targetPanOffset: viewState.panOffset + panStep,
1102
+ })) {
1103
+ return;
1104
+ }
1105
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
829
1106
  } else {
830
1107
  cursorMotionKindRef.current = "discrete";
831
1108
  setViewState((current) => {
@@ -845,10 +1122,10 @@ export function StockChart({
845
1122
  chartWidth,
846
1123
  projection.effectiveMode,
847
1124
  );
848
- const maxPan = getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth);
1125
+ const maxPan = getMaxPanOffset(baseHistory, current.zoomLevel);
849
1126
  if (currentIndex <= 0) {
850
1127
  return {
851
- ...current,
1128
+ ...clearActivePreset(current),
852
1129
  cursorX: nextCursor,
853
1130
  cursorY: null,
854
1131
  panOffset: clamp(current.panOffset + 1, 0, maxPan),
@@ -860,7 +1137,7 @@ export function StockChart({
860
1137
  return;
861
1138
  case "right":
862
1139
  if (event.shift) {
863
- setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
1140
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
864
1141
  } else {
865
1142
  cursorMotionKindRef.current = "discrete";
866
1143
  setViewState((current) => {
@@ -882,7 +1159,7 @@ export function StockChart({
882
1159
  );
883
1160
  if (currentIndex >= pointCount - 1) {
884
1161
  return {
885
- ...current,
1162
+ ...clearActivePreset(current),
886
1163
  cursorX: nextCursor,
887
1164
  cursorY: null,
888
1165
  panOffset: Math.max(current.panOffset - 1, 0),
@@ -1266,10 +1543,7 @@ export function StockChart({
1266
1543
  rendererState.nativeReady,
1267
1544
  ]);
1268
1545
 
1269
- if (history.length === 0) {
1270
- return <text fg={colors.textDim}>No price history available.</text>;
1271
- }
1272
-
1546
+ const hasHistory = history.length > 0;
1273
1547
  const firstPrice = chartWindow.points[0]?.close ?? 0;
1274
1548
  const lastPrice = chartWindow.points[chartWindow.points.length - 1]?.close ?? 0;
1275
1549
  const change = lastPrice - firstPrice;
@@ -1278,9 +1552,21 @@ export function StockChart({
1278
1552
  const showOhlcSummary = projection.effectiveMode === "candles" || projection.effectiveMode === "ohlc";
1279
1553
  const hasSelectionCursor = selectionCursorX !== null;
1280
1554
  const hasDisplayCursor = displayCursorX !== null && displayCursorY !== null;
1281
- const displayPrice = hasSelectionCursor ? (selectionScene?.priceAtCursor ?? lastPrice) : lastPrice;
1282
- const displayChange = hasSelectionCursor ? (selectionScene?.changeAtCursor ?? change) : change;
1283
- const displayChangePct = hasSelectionCursor ? (selectionScene?.changePctAtCursor ?? changePct) : changePct;
1555
+ const displayPrice = !hasHistory
1556
+ ? null
1557
+ : hasSelectionCursor
1558
+ ? (selectionScene?.priceAtCursor ?? lastPrice)
1559
+ : lastPrice;
1560
+ const displayChange = !hasHistory
1561
+ ? null
1562
+ : hasSelectionCursor
1563
+ ? (selectionScene?.changeAtCursor ?? change)
1564
+ : change;
1565
+ const displayChangePct = !hasHistory
1566
+ ? null
1567
+ : hasSelectionCursor
1568
+ ? (selectionScene?.changePctAtCursor ?? changePct)
1569
+ : changePct;
1284
1570
  const displayDate = hasSelectionCursor || showOhlcSummary
1285
1571
  ? (selectionScene?.dateAtCursor ? formatDateShort(selectionScene.dateAtCursor) : null)
1286
1572
  : null;
@@ -1289,6 +1575,15 @@ export function StockChart({
1289
1575
  const cursorAxisLabel = hasDisplayCursor && cursorRow !== null && crosshairPrice !== null
1290
1576
  ? formatAxisValue(crosshairPrice, axisMode, projection.points[0]?.close ?? 0, chartCurrency)
1291
1577
  : null;
1578
+ const visibleLabel = formatVisibleSpanLabel({
1579
+ start: chartWindow.points[0]?.date ? coerceChartDate(chartWindow.points[0].date as Date | string | number) : null,
1580
+ end: chartWindow.points[chartWindow.points.length - 1]?.date
1581
+ ? coerceChartDate(chartWindow.points[chartWindow.points.length - 1]!.date as Date | string | number)
1582
+ : null,
1583
+ });
1584
+ const isBlockingBody = !compact && baseBodyState.blocking;
1585
+ const bodyMessage = !compact ? (baseBodyState.errorMessage ?? baseBodyState.emptyMessage) : null;
1586
+ const isUpdating = !compact && (baseBodyState.updating || (detailChartEntry?.phase === "refreshing" && !!detailBodyState.data?.length));
1292
1587
 
1293
1588
  const handlePlotMove = (event: ChartMouseEvent) => {
1294
1589
  if (!interactive || compact) return;
@@ -1352,16 +1647,15 @@ export function StockChart({
1352
1647
  }
1353
1648
  if (!dragRef.current) return;
1354
1649
 
1355
- const filtered = filterByTimeRange(baseHistory, viewState.timeRange);
1356
- const visibleCount = getVisiblePointCount(filtered.length, viewState.zoomLevel);
1650
+ const visibleCount = getVisiblePointCount(baseHistory.length, viewState.zoomLevel);
1357
1651
  const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1358
1652
  const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * visibleCount);
1359
1653
  const nextPan = clamp(
1360
1654
  dragRef.current.startPanOffset - pointDelta,
1361
1655
  0,
1362
- Math.max(filtered.length - visibleCount, 0),
1656
+ Math.max(baseHistory.length - visibleCount, 0),
1363
1657
  );
1364
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1658
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1365
1659
  };
1366
1660
 
1367
1661
  const handlePlotScroll = (event: ChartMouseEvent) => {
@@ -1390,39 +1684,59 @@ export function StockChart({
1390
1684
 
1391
1685
  if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1392
1686
  const shiftDirection = direction === "up" ? 1 : -1;
1687
+ const targetPanOffset = viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1);
1688
+ if (shiftDirection > 0 && viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1689
+ kind: "pan-left",
1690
+ targetPanOffset,
1691
+ })) {
1692
+ return;
1693
+ }
1393
1694
  const nextPan = clamp(
1394
- viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1),
1695
+ targetPanOffset,
1395
1696
  0,
1396
- getMaxPanOffset(baseHistory, viewState.timeRange, viewState.zoomLevel, chartWidth),
1697
+ getMaxPanOffset(baseHistory, viewState.zoomLevel),
1397
1698
  );
1398
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1699
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1399
1700
  return;
1400
1701
  }
1401
1702
 
1402
1703
  switch (direction) {
1403
1704
  case "up":
1404
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory, chartWidth));
1705
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory));
1405
1706
  return;
1406
1707
  case "down":
1407
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory, chartWidth));
1708
+ if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1709
+ kind: "zoom-out",
1710
+ targetVisibleCount: Math.round(baseHistory.length * 1.18),
1711
+ anchorRatio,
1712
+ })) {
1713
+ return;
1714
+ }
1715
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory));
1408
1716
  return;
1409
1717
  case "left":
1718
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1719
+ kind: "pan-left",
1720
+ targetPanOffset: viewState.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1721
+ })) {
1722
+ return;
1723
+ }
1410
1724
  setViewState((current) => ({
1411
- ...current,
1725
+ ...clearActivePreset(current),
1412
1726
  panOffset: clamp(
1413
1727
  current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1414
1728
  0,
1415
- getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1729
+ getMaxPanOffset(baseHistory, current.zoomLevel),
1416
1730
  ),
1417
1731
  }));
1418
1732
  return;
1419
1733
  case "right":
1420
1734
  setViewState((current) => ({
1421
- ...current,
1735
+ ...clearActivePreset(current),
1422
1736
  panOffset: clamp(
1423
1737
  current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1424
1738
  0,
1425
- getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1739
+ getMaxPanOffset(baseHistory, current.zoomLevel),
1426
1740
  ),
1427
1741
  }));
1428
1742
  return;
@@ -1443,17 +1757,29 @@ export function StockChart({
1443
1757
  height={chartHeight}
1444
1758
  flexDirection="column"
1445
1759
  backgroundColor={chartColors.bgColor}
1446
- onMouseMove={compact ? undefined : handlePlotMove}
1447
- onMouseDown={compact ? undefined : handlePlotDown}
1448
- onMouseUp={compact ? undefined : () => { dragRef.current = null; }}
1449
- onMouseDrag={compact ? undefined : handlePlotDrag}
1450
- onMouseDragEnd={compact ? undefined : () => { dragRef.current = null; }}
1451
- onMouseOut={compact ? undefined : () => {
1760
+ onMouseMove={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotMove}
1761
+ onMouseDown={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotDown}
1762
+ onMouseUp={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => { dragRef.current = null; }}
1763
+ onMouseDrag={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotDrag}
1764
+ onMouseDragEnd={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => { dragRef.current = null; }}
1765
+ onMouseOut={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => {
1452
1766
  dragRef.current = null;
1453
1767
  }}
1454
- onMouseScroll={compact ? undefined : handlePlotScroll}
1768
+ onMouseScroll={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotScroll}
1455
1769
  >
1456
- {plotContent}
1770
+ {isBlockingBody
1771
+ ? (
1772
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1773
+ <text fg={colors.textDim}>Loading chart...</text>
1774
+ </box>
1775
+ )
1776
+ : bodyMessage
1777
+ ? (
1778
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1779
+ <text fg={colors.textDim}>{bodyMessage}</text>
1780
+ </box>
1781
+ )
1782
+ : plotContent}
1457
1783
  </box>
1458
1784
  );
1459
1785
 
@@ -1489,13 +1815,16 @@ export function StockChart({
1489
1815
  <box flexDirection="column" flexGrow={1}>
1490
1816
  <box flexDirection="row" gap={2} height={1}>
1491
1817
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1492
- {ticker?.metadata.ticker ?? ""} - {viewState.timeRange}
1818
+ {ticker?.metadata.ticker ?? ""} - {getChartResolutionLabel(effectiveResolution)}
1493
1819
  </text>
1494
- <text fg={priceColor(displayChange)}>
1495
- {formatCurrency(displayPrice, chartCurrency)}
1820
+ <text fg={colors.textDim}>{visibleLabel}</text>
1821
+ <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1822
+ {displayPrice !== null ? formatCurrency(displayPrice, chartCurrency) : "—"}
1496
1823
  </text>
1497
- <text fg={priceColor(displayChange)}>
1498
- {displayChange >= 0 ? "+" : ""}{displayChange.toFixed(2)} ({displayChangePct >= 0 ? "+" : ""}{displayChangePct.toFixed(2)}%)
1824
+ <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1825
+ {displayChange !== null && displayChangePct !== null
1826
+ ? `${displayChange >= 0 ? "+" : ""}${displayChange.toFixed(2)} (${displayChangePct >= 0 ? "+" : ""}${displayChangePct.toFixed(2)}%)`
1827
+ : "No data"}
1499
1828
  </text>
1500
1829
  {displayDate && <text fg={colors.textDim}>{displayDate}</text>}
1501
1830
  {showOhlcSummary && activePoint && (
@@ -1514,15 +1843,35 @@ export function StockChart({
1514
1843
  {TIME_RANGES.map((range, index) => (
1515
1844
  <text
1516
1845
  key={range}
1517
- fg={viewState.timeRange === range ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1518
- attributes={viewState.timeRange === range ? TextAttributes.BOLD : 0}
1519
- onMouseDown={() => setRange(range)}
1846
+ fg={activePreset === range ? chartColors.activeRangeColor : (isRangePresetSupported(range, availableManualResolutions) ? chartColors.inactiveRangeColor : colors.textMuted)}
1847
+ attributes={activePreset === range ? TextAttributes.BOLD : 0}
1848
+ onMouseDown={() => {
1849
+ if (isRangePresetSupported(range, availableManualResolutions)) setRange(range);
1850
+ }}
1520
1851
  >
1521
1852
  {`${index + 1}:${range}`}
1522
1853
  </text>
1523
1854
  ))}
1855
+ </box>
1856
+ </box>
1857
+
1858
+ <box flexDirection="row" height={1}>
1859
+ <box flexDirection="row" gap={1}>
1860
+ {resolutionChips.map((resolution) => (
1861
+ <text
1862
+ key={resolution}
1863
+ fg={effectiveResolution === resolution ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1864
+ attributes={effectiveResolution === resolution ? TextAttributes.BOLD : 0}
1865
+ onMouseDown={() => setResolution(resolution)}
1866
+ >
1867
+ {getChartResolutionLabel(resolution)}
1868
+ </text>
1869
+ ))}
1524
1870
  {viewState.zoomLevel !== 1 && (
1525
- <text fg={colors.textDim}> zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1871
+ <text fg={colors.textDim}>zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1872
+ )}
1873
+ {isUpdating && (
1874
+ <text fg={colors.textDim}>updating</text>
1526
1875
  )}
1527
1876
  </box>
1528
1877
  <box flexGrow={1} />
@@ -1570,10 +1919,10 @@ export function StockChart({
1570
1919
  <box height={1}>
1571
1920
  <text fg={colors.textMuted}>
1572
1921
  {interactive
1573
- ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode 1-7 range v vol Esc exit"
1574
- : "Enter crosshair click chart to focus a/d pan +/- zoom m mode 1-7 range v volume 0 reset"}
1922
+ ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode r res 1-7 presets v vol 0 reset Esc exit"
1923
+ : "Enter crosshair click chart to focus a/d pan +/- zoom m mode r res 1-7 presets v volume 0 reset"}
1575
1924
  </text>
1576
1925
  </box>
1577
1926
  </box>
1578
1927
  );
1579
- }
1928
+ });