gloomberb 0.10.2 → 0.10.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -41,7 +41,7 @@ export { Checkbox } from "./ui/checkbox";
41
41
  export { ConfirmDialog } from "./ui/confirm-dialog";
42
42
  export { ChoiceDialog } from "./ui/choice-dialog";
43
43
  export type { ChoiceDialogChoice } from "./ui/choice-dialog";
44
- export type { DataTableCell, DataTableColumn } from "./ui/data-table";
44
+ export type { DataTableCell, DataTableColumn, DataTableVisibleRange } from "./ui/data-table";
45
45
  export { EmptyState } from "./ui/status";
46
46
  export { getMessageComposerBlockHeight, MessageComposer } from "./ui/message-composer";
47
47
  export { NumberField, TextField } from "./ui/fields";
@@ -13,6 +13,7 @@ export type {
13
13
  DataTableColumn,
14
14
  DataTableProps,
15
15
  DataTableSectionHeader,
16
+ DataTableVisibleRange,
16
17
  } from "./types";
17
18
 
18
19
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
@@ -19,7 +19,9 @@ import {
19
19
  import type {
20
20
  DataTableColumn,
21
21
  DataTableProps,
22
+ DataTableVisibleRange,
22
23
  } from "../types";
24
+ import { resolveDataTableVisibleRange } from "../visible-range";
23
25
  import {
24
26
  resolveDataTableScrollTop,
25
27
  resolveDataTableVisibleWindow,
@@ -47,6 +49,8 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
47
49
  onSelect,
48
50
  onActivate,
49
51
  onTableMouseDown,
52
+ visibleRangeKey,
53
+ onVisibleRangeChange,
50
54
  onRowMouseDown,
51
55
  onRowContextMenu,
52
56
  rowContextMenuSurface = false,
@@ -74,6 +78,10 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
74
78
  const nativeRenderer = useNativeRenderer();
75
79
  const [scrollVersion, setScrollVersion] = useState(0);
76
80
  const lastAppliedScrollRequestRef = useRef<string | null>(null);
81
+ const lastVisibleRangeRef = useRef<{
82
+ key: string | number | undefined;
83
+ range: DataTableVisibleRange;
84
+ } | null>(null);
77
85
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
78
86
  const measuredViewportHeight = scrollRef.current?.viewport?.height;
79
87
  const tableWindow = useMemo(
@@ -123,6 +131,25 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
123
131
  () => expandTableColumns(columns, contentWidth, columnGap, horizontalPadding),
124
132
  [columnGap, columns, contentWidth, horizontalPadding],
125
133
  );
134
+ const emitVisibleRange = useCallback(() => {
135
+ if (!onVisibleRangeChange) return;
136
+ const scrollBox = scrollRef.current;
137
+ const range = resolveDataTableVisibleRange({
138
+ itemCount: items.length,
139
+ rowSize: 1,
140
+ scrollOffset: scrollBox?.scrollTop ?? scrollTop,
141
+ viewportSize: scrollBox?.viewport?.height ?? viewportHeight,
142
+ });
143
+ const previous = lastVisibleRangeRef.current;
144
+ if (
145
+ previous !== null
146
+ && previous.key === visibleRangeKey
147
+ && previous.range.start === range.start
148
+ && previous.range.end === range.end
149
+ ) return;
150
+ lastVisibleRangeRef.current = { key: visibleRangeKey, range };
151
+ onVisibleRangeChange(range);
152
+ }, [items.length, onVisibleRangeChange, scrollRef, scrollTop, viewportHeight, visibleRangeKey]);
126
153
  const handleRowMouseDown =
127
154
  useDoubleClickActivation<DataTableRowPointerTarget<T>>({
128
155
  onSelect: ({ item, index }) => {
@@ -140,13 +167,26 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
140
167
  setScrollVersion((current) => current + 1);
141
168
  }
142
169
  onBodyScrollActivity();
170
+ emitVisibleRange();
143
171
  nativeRenderer.requestRender();
144
- }, [nativeRenderer, onBodyScrollActivity, virtualize]);
172
+ }, [emitVisibleRange, nativeRenderer, onBodyScrollActivity, virtualize]);
145
173
  useScrollBoxScrollActivity({
146
174
  scrollRef,
147
175
  onVerticalScroll: handleBodyScrollActivity,
148
176
  onHorizontalScroll: syncHeaderScroll,
149
177
  });
178
+ const handleBodySizeChange = useCallback(() => {
179
+ measureContentWidth();
180
+ if (virtualize) {
181
+ setScrollVersion((current) => current + 1);
182
+ }
183
+ queueMicrotask(emitVisibleRange);
184
+ }, [emitVisibleRange, measureContentWidth, virtualize]);
185
+
186
+ useEffect(() => {
187
+ emitVisibleRange();
188
+ queueMicrotask(emitVisibleRange);
189
+ }, [emitVisibleRange]);
150
190
  const focusPane = useCallback(() => {
151
191
  if (!paneInstanceId) return;
152
192
  dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
@@ -178,6 +218,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
178
218
  setScrollVersion((current) => current + 1);
179
219
  }
180
220
  syncHeaderScroll();
221
+ queueMicrotask(emitVisibleRange);
181
222
  return true;
182
223
  }, [
183
224
  appViewport.height,
@@ -185,6 +226,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
185
226
  scrollRef,
186
227
  scrollToIndex,
187
228
  scrollToIndexAlign,
229
+ emitVisibleRange,
188
230
  syncHeaderScroll,
189
231
  virtualize,
190
232
  ]);
@@ -324,7 +366,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
324
366
  focusPane();
325
367
  onTableMouseDown?.({});
326
368
  }}
327
- onSizeChange={measureContentWidth}
369
+ onSizeChange={handleBodySizeChange}
328
370
  >
329
371
  {items.length === 0 ? (
330
372
  emptyContent ?? (
@@ -33,6 +33,13 @@ export interface DataTableRowState {
33
33
  selected: boolean;
34
34
  }
35
35
 
36
+ export interface DataTableVisibleRange {
37
+ /** First visible row index. */
38
+ start: number;
39
+ /** Exclusive end of the visible row range. */
40
+ end: number;
41
+ }
42
+
36
43
  export interface DataTableProps<
37
44
  T,
38
45
  C extends DataTableColumn = DataTableColumn,
@@ -53,6 +60,9 @@ export interface DataTableProps<
53
60
  onSelect: (item: T, index: number) => void;
54
61
  onActivate?: (item: T, index: number) => void;
55
62
  onTableMouseDown?: (event: any) => void;
63
+ /** Changes when the meaning of row indexes changes without changing table geometry. */
64
+ visibleRangeKey?: string | number;
65
+ onVisibleRangeChange?: (range: DataTableVisibleRange) => void;
56
66
  onRowMouseDown?: (item: T, index: number, event: any) => boolean | void;
57
67
  onRowContextMenu?: (item: T, index: number, event: any) => void;
58
68
  rowContextMenuSurface?: boolean;
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveDataTableVisibleRange } from "./visible-range";
3
+
4
+ describe("data table visible range", () => {
5
+ test("includes partially visible desktop rows without counting the sticky header", () => {
6
+ expect(resolveDataTableVisibleRange({
7
+ itemCount: 100,
8
+ rowSize: 20,
9
+ scrollOffset: 10,
10
+ viewportSize: 180,
11
+ })).toEqual({ start: 0, end: 10 });
12
+
13
+ expect(resolveDataTableVisibleRange({
14
+ itemCount: 100,
15
+ rowSize: 20,
16
+ scrollOffset: 30,
17
+ viewportSize: 180,
18
+ })).toEqual({ start: 1, end: 11 });
19
+ });
20
+
21
+ test("clamps a terminal range to the available rows", () => {
22
+ expect(resolveDataTableVisibleRange({
23
+ itemCount: 8,
24
+ rowSize: 1,
25
+ scrollOffset: 6,
26
+ viewportSize: 5,
27
+ })).toEqual({ start: 6, end: 8 });
28
+ });
29
+ });
@@ -0,0 +1,21 @@
1
+ import type { DataTableVisibleRange } from "./types";
2
+
3
+ export function resolveDataTableVisibleRange({
4
+ itemCount,
5
+ rowSize,
6
+ scrollOffset,
7
+ viewportSize,
8
+ }: {
9
+ itemCount: number;
10
+ rowSize: number;
11
+ scrollOffset: number;
12
+ viewportSize: number;
13
+ }): DataTableVisibleRange {
14
+ const count = Math.max(0, Math.floor(itemCount));
15
+ const size = Number.isFinite(rowSize) && rowSize > 0 ? rowSize : 1;
16
+ const offset = Number.isFinite(scrollOffset) ? Math.max(0, scrollOffset) : 0;
17
+ const viewport = Number.isFinite(viewportSize) ? Math.max(0, viewportSize) : 0;
18
+ const start = Math.min(count, Math.floor(offset / size));
19
+ const end = Math.min(count, Math.max(start, Math.ceil((offset + viewport) / size)));
20
+ return { start, end };
21
+ }
@@ -3,7 +3,7 @@ import { act, createRef, useEffect, useRef, useState } from "react";
3
3
  import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
4
4
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
5
5
  import { ChoiceDialog } from "./choice-dialog";
6
- import { DataTable, type DataTableColumn } from "./data-table";
6
+ import { DataTable, type DataTableColumn, type DataTableVisibleRange } from "./data-table";
7
7
  import { TextField } from "./fields";
8
8
  import { ListView } from "./list-view";
9
9
  import { MultiSelectDialogButton, type MultiSelectDialogButtonHandle } from "./multi-select/dialog";
@@ -26,6 +26,8 @@ let setListSelection: ((index: number) => void) | null = null;
26
26
  let selectedTableRow: string | null = null;
27
27
  let activatedTableRow: string | null = null;
28
28
  let tableScrollBoxForTest: ScrollBoxRenderable | null = null;
29
+ let tableVisibleRanges: DataTableVisibleRange[] = [];
30
+ let setTableVisibleRangeKey: ((key: string) => void) | null = null;
29
31
  let closedTab: string | null = null;
30
32
  let addedTab = false;
31
33
  let resolvedChoice: string | null = null;
@@ -176,6 +178,7 @@ function DataTableHorizontalScrollHarness({
176
178
  function DataTableVirtualizationHarness() {
177
179
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
178
180
  const scrollRef = useRef<ScrollBoxRenderable>(null);
181
+ const [visibleRangeKey, setVisibleRangeKey] = useState("first");
179
182
  const rows = Array.from({ length: 100 }, (_, index) => ({
180
183
  id: `row-${index}`,
181
184
  name: `Row ${index}`,
@@ -183,6 +186,7 @@ function DataTableVirtualizationHarness() {
183
186
 
184
187
  useEffect(() => {
185
188
  tableScrollBoxForTest = scrollRef.current;
189
+ setTableVisibleRangeKey = setVisibleRangeKey;
186
190
  return () => {
187
191
  if (tableScrollBoxForTest === scrollRef.current) {
188
192
  tableScrollBoxForTest = null;
@@ -201,6 +205,8 @@ function DataTableVirtualizationHarness() {
201
205
  scrollRef={scrollRef}
202
206
  syncHeaderScroll={() => {}}
203
207
  onBodyScrollActivity={() => {}}
208
+ visibleRangeKey={visibleRangeKey}
209
+ onVisibleRangeChange={(range) => tableVisibleRanges.push(range)}
204
210
  getItemKey={(row) => row.id}
205
211
  isSelected={() => false}
206
212
  onSelect={() => {}}
@@ -287,6 +293,8 @@ afterEach(async () => {
287
293
  selectedTableRow = null;
288
294
  activatedTableRow = null;
289
295
  tableScrollBoxForTest = null;
296
+ tableVisibleRanges = [];
297
+ setTableVisibleRangeKey = null;
290
298
  closedTab = null;
291
299
  addedTab = false;
292
300
  resolvedChoice = null;
@@ -906,6 +914,16 @@ describe("shared UI kit", () => {
906
914
  expect(frame).toContain("Row 0");
907
915
  expect(frame).not.toContain("Row 99");
908
916
  expect(tableScrollBoxForTest?.scrollTop).toBe(0);
917
+ expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
918
+
919
+ const initialRangeCount = tableVisibleRanges.length;
920
+ await act(async () => {
921
+ setTableVisibleRangeKey!("second");
922
+ await Promise.resolve();
923
+ await testSetup!.renderOnce();
924
+ });
925
+ expect(tableVisibleRanges.length).toBeGreaterThan(initialRangeCount);
926
+ expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
909
927
 
910
928
  await act(async () => {
911
929
  for (let index = 0; index < 12; index++) {
@@ -919,6 +937,10 @@ describe("shared UI kit", () => {
919
937
 
920
938
  const scrollTop = tableScrollBoxForTest?.scrollTop ?? 0;
921
939
  expect(scrollTop).toBeGreaterThan(0);
940
+ expect(tableVisibleRanges.at(-1)).toEqual({
941
+ start: scrollTop,
942
+ end: Math.min(100, scrollTop + 5),
943
+ });
922
944
 
923
945
  const scrolledFrame = testSetup.captureCharFrame();
924
946
  expect(scrolledFrame).toContain(`Row ${scrollTop}`);
package/src/i18n/ja.ts CHANGED
@@ -645,6 +645,8 @@ export const ja: Record<string, string> = {
645
645
  "{count}-minute delayed options, upgrade for real-time":
646
646
  "オプションは {count} 分遅延、リアルタイムにはアップグレード",
647
647
  "real-time options": "リアルタイムオプション",
648
+ "mixed real-time and delayed options": "リアルタイムと遅延の混在オプション",
649
+ "connecting real-time options": "リアルタイムオプションに接続中",
648
650
  "options delayed fallback": "オプション遅延フォールバック",
649
651
  "News": "ニュース",
650
652
  "12h delay": "12 時間遅延",
package/src/i18n/ko.ts CHANGED
@@ -643,6 +643,8 @@ export const ko: Record<string, string> = {
643
643
  "{count}-minute delayed options, upgrade for real-time":
644
644
  "옵션 데이터 {count}분 지연, 실시간으로 업그레이드",
645
645
  "real-time options": "실시간 옵션",
646
+ "mixed real-time and delayed options": "실시간 및 지연 옵션 혼합",
647
+ "connecting real-time options": "실시간 옵션 연결 중",
646
648
  "options delayed fallback": "옵션 지연 대체 데이터",
647
649
  "News": "뉴스",
648
650
  "12h delay": "12시간 지연",
package/src/i18n/zh-cn.ts CHANGED
@@ -645,6 +645,8 @@ export const zhCN: Record<string, string> = {
645
645
  "{count}-minute delayed options, upgrade for real-time":
646
646
  "期权行情延迟 {count} 分钟,升级以获取实时数据",
647
647
  "real-time options": "实时期权",
648
+ "mixed real-time and delayed options": "实时与延迟期权混合",
649
+ "connecting real-time options": "正在连接实时期权",
648
650
  "options delayed fallback": "期权延迟备用数据",
649
651
  "News": "新闻",
650
652
  "12h delay": "延迟 12 小时",
package/src/i18n/zh-tw.ts CHANGED
@@ -645,6 +645,8 @@ export const zhTW: Record<string, string> = {
645
645
  "{count}-minute delayed options, upgrade for real-time":
646
646
  "選擇權行情延遲 {count} 分鐘,升級以取得即時資料",
647
647
  "real-time options": "即時選擇權",
648
+ "mixed real-time and delayed options": "即時與延遲選擇權混合",
649
+ "connecting real-time options": "正在連線即時選擇權",
648
650
  "options delayed fallback": "選擇權延遲備援資料",
649
651
  "News": "新聞",
650
652
  "12h delay": "延遲 12 小時",
@@ -369,10 +369,10 @@ export class MarketDataCoordinator {
369
369
  const key = buildQuoteKey(instrument);
370
370
  const current = this.quoteStore.get(key);
371
371
  const resolvedQuote = quote.stale === true ? quote : this.resolveIncomingQuote(instrument, quote);
372
- if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, resolvedQuote)) return;
373
372
  const startedAt = Date.now();
374
373
  const receivedAt = resolvedQuote.stale === true ? resolvedQuote.receivedAt : startedAt;
375
374
  const storedQuote = receivedAt == null ? resolvedQuote : { ...resolvedQuote, receivedAt };
375
+ if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, storedQuote)) return;
376
376
  const attempts = [createAttempt(resolvedQuote.providerId ?? this.dataProvider.id, startedAt, "success")];
377
377
  this.quoteStore.set(key, readyQuoteEntry(current, storedQuote, resolvedQuote.providerId ?? this.dataProvider.id, attempts));
378
378
  }
@@ -291,5 +291,6 @@ export function areStreamQuotesEquivalent(current: Quote | null | undefined, nex
291
291
  if (current[field] !== next[field]) return false;
292
292
  }
293
293
  return current.lastUpdated === next.lastUpdated
294
+ && current.receivedAt === next.receivedAt
294
295
  && JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
295
296
  }
@@ -245,6 +245,45 @@ describe("MarketDataCoordinator key subscriptions", () => {
245
245
  }
246
246
  });
247
247
 
248
+ test("advances receipt freshness for an identical live stream heartbeat", async () => {
249
+ const realDateNow = Date.now;
250
+ const { provider, emitQuote } = createProvider();
251
+ const coordinator = new MarketDataCoordinator(provider);
252
+ const option = { symbol: "AAPL260731C00110000", exchange: "OPTIONS" };
253
+ const firstTimestamp = 1_800_000_000_000;
254
+ let calls = 0;
255
+
256
+ try {
257
+ coordinator.subscribeKeys([buildQuoteKey(option)], () => { calls += 1; });
258
+ coordinator.subscribeQuotes([{ instrument: option }]);
259
+
260
+ Date.now = () => firstTimestamp;
261
+ const heartbeat = quote(option.symbol, 2.5, {
262
+ lastUpdated: firstTimestamp,
263
+ bid: 2.4,
264
+ ask: 2.6,
265
+ mark: 2.5,
266
+ dataSource: "live",
267
+ delivery: "stream",
268
+ stale: false,
269
+ });
270
+ emitQuote(option, heartbeat);
271
+ await flushCoordinator();
272
+
273
+ Date.now = () => firstTimestamp + 60_000;
274
+ emitQuote(option, heartbeat);
275
+ await flushCoordinator();
276
+
277
+ expect(calls).toBe(2);
278
+ expect(coordinator.getQuoteEntry(option).data).toMatchObject({
279
+ lastUpdated: firstTimestamp,
280
+ receivedAt: firstTimestamp + 60_000,
281
+ });
282
+ } finally {
283
+ Date.now = realDateNow;
284
+ }
285
+ });
286
+
248
287
  test("does not refresh receipt time from an explicitly stale polled quote", async () => {
249
288
  const realDateNow = Date.now;
250
289
  const { provider, emitQuote } = createProvider();
@@ -21,7 +21,7 @@ describe("options access footer", () => {
21
21
  realtimeEligible: false,
22
22
  }),
23
23
  clientPlan: "free",
24
- hasLiveQuote: false,
24
+ quoteCoverage: { status: "delayed" },
25
25
  });
26
26
  let upgrades = 0;
27
27
  const segment = buildOptionsAccessFooterSegment(state, () => {
@@ -37,7 +37,7 @@ describe("options access footer", () => {
37
37
  expect(upgrades).toBe(1);
38
38
  });
39
39
 
40
- test("claims real-time only for a Pro user with a fresh live stream", () => {
40
+ test("reports complete, mixed, connecting, and delayed Pro coverage", () => {
41
41
  expect(
42
42
  resolveOptionsAccessFooterState({
43
43
  chain: chain({
@@ -46,7 +46,7 @@ describe("options access footer", () => {
46
46
  realtimeEligible: true,
47
47
  }),
48
48
  clientPlan: "pro",
49
- hasLiveQuote: false,
49
+ quoteCoverage: { status: "delayed" },
50
50
  }),
51
51
  ).toMatchObject({
52
52
  canUpgrade: false,
@@ -62,21 +62,33 @@ describe("options access footer", () => {
62
62
  realtimeEligible: true,
63
63
  }),
64
64
  clientPlan: "pro",
65
- hasLiveQuote: false,
65
+ quoteCoverage: { status: "connecting" },
66
66
  }),
67
67
  ).toMatchObject({
68
68
  canUpgrade: false,
69
- text: "options delayed fallback",
70
- tone: "warning",
69
+ text: "connecting real-time options",
70
+ tone: "muted",
71
71
  });
72
72
 
73
73
  expect(
74
74
  resolveOptionsAccessFooterState({
75
75
  chain: chain({ realtimeEligible: false }),
76
76
  clientPlan: "pro",
77
- hasLiveQuote: true,
77
+ quoteCoverage: { status: "live" },
78
78
  }).text,
79
79
  ).toBe("real-time options");
80
+
81
+ expect(
82
+ resolveOptionsAccessFooterState({
83
+ chain: chain({ realtimeEligible: true }),
84
+ clientPlan: "pro",
85
+ quoteCoverage: { status: "mixed" },
86
+ }),
87
+ ).toMatchObject({
88
+ canUpgrade: false,
89
+ text: "mixed real-time and delayed options",
90
+ tone: "warning",
91
+ });
80
92
  });
81
93
 
82
94
  test("makes the current plan authoritative over cached chain metadata", () => {
@@ -88,7 +100,7 @@ describe("options access footer", () => {
88
100
  realtimeEligible: true,
89
101
  }),
90
102
  clientPlan: "free",
91
- hasLiveQuote: true,
103
+ quoteCoverage: { status: "live" },
92
104
  }),
93
105
  ).toMatchObject({
94
106
  canUpgrade: true,
@@ -104,7 +116,7 @@ describe("options access footer", () => {
104
116
  realtimeEligible: false,
105
117
  }),
106
118
  clientPlan: "pro",
107
- hasLiveQuote: true,
119
+ quoteCoverage: { status: "live" },
108
120
  }),
109
121
  ).toMatchObject({
110
122
  canUpgrade: false,
@@ -7,21 +7,22 @@ import type { OptionsChain } from "../../../types/financials";
7
7
  import { useRendererHost } from "../../../ui";
8
8
  import { CLOUD_UPGRADE_URL } from "../shared/cloud-upgrade";
9
9
  import { usePaneStatusFooter } from "../shared/pane-footer";
10
+ import type { OptionQuoteCoverage } from "./live-quotes";
10
11
 
11
12
  export interface OptionsAccessFooterState {
12
13
  canUpgrade: boolean;
13
14
  text: string;
14
- tone: "positive" | "warning";
15
+ tone: "muted" | "positive" | "warning";
15
16
  }
16
17
 
17
18
  export function resolveOptionsAccessFooterState({
18
19
  chain,
19
20
  clientPlan,
20
- hasLiveQuote,
21
+ quoteCoverage,
21
22
  }: {
22
23
  chain: OptionsChain | null | undefined;
23
24
  clientPlan: "free" | "pro" | null;
24
- hasLiveQuote: boolean;
25
+ quoteCoverage: Pick<OptionQuoteCoverage, "status">;
25
26
  }): OptionsAccessFooterState {
26
27
  if (clientPlan !== "pro") {
27
28
  const delayMinutes = chain?.delayMinutes && chain.delayMinutes > 0 ? chain.delayMinutes : 15;
@@ -34,13 +35,27 @@ export function resolveOptionsAccessFooterState({
34
35
  };
35
36
  }
36
37
 
37
- if (hasLiveQuote) {
38
+ if (quoteCoverage.status === "live") {
38
39
  return {
39
40
  canUpgrade: false,
40
41
  text: t("real-time options"),
41
42
  tone: "positive",
42
43
  };
43
44
  }
45
+ if (quoteCoverage.status === "mixed") {
46
+ return {
47
+ canUpgrade: false,
48
+ text: t("mixed real-time and delayed options"),
49
+ tone: "warning",
50
+ };
51
+ }
52
+ if (quoteCoverage.status === "connecting") {
53
+ return {
54
+ canUpgrade: false,
55
+ text: t("connecting real-time options"),
56
+ tone: "muted",
57
+ };
58
+ }
44
59
 
45
60
  return {
46
61
  canUpgrade: false,
@@ -64,14 +79,14 @@ export function useOptionsAccessFooter({
64
79
  chain,
65
80
  error,
66
81
  focused,
67
- hasLiveQuote,
68
82
  loading,
83
+ quoteCoverage,
69
84
  }: {
70
85
  chain: OptionsChain | null | undefined;
71
86
  error?: string | null;
72
87
  focused: boolean;
73
- hasLiveQuote: boolean;
74
88
  loading?: boolean;
89
+ quoteCoverage: Pick<OptionQuoteCoverage, "status">;
75
90
  }): OptionsAccessFooterState {
76
91
  const rendererHost = useRendererHost();
77
92
  const user = apiClient.getCurrentUser();
@@ -79,7 +94,7 @@ export function useOptionsAccessFooter({
79
94
  const state = resolveOptionsAccessFooterState({
80
95
  chain,
81
96
  clientPlan,
82
- hasLiveQuote,
97
+ quoteCoverage,
83
98
  });
84
99
  const openUpgrade = useCallback(() => {
85
100
  void rendererHost.openExternal(CLOUD_UPGRADE_URL);