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 +1 -1
- package/src/components/index.ts +1 -1
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/ja.ts +2 -0
- package/src/i18n/ko.ts +2 -0
- package/src/i18n/zh-cn.ts +2 -0
- package/src/i18n/zh-tw.ts +2 -0
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +1 -0
- package/src/market-data/coordinator/subscriptions.test.ts +39 -0
- package/src/plugins/builtin/options/footer.test.ts +21 -9
- package/src/plugins/builtin/options/footer.ts +22 -7
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +46 -9
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/state/hooks/quote-streaming.test.tsx +58 -1
- package/src/state/hooks/quote-streaming.ts +5 -2
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -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";
|
|
@@ -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={
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
quoteCoverage: { status: "connecting" },
|
|
66
66
|
}),
|
|
67
67
|
).toMatchObject({
|
|
68
68
|
canUpgrade: false,
|
|
69
|
-
text: "
|
|
70
|
-
tone: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
quoteCoverage,
|
|
21
22
|
}: {
|
|
22
23
|
chain: OptionsChain | null | undefined;
|
|
23
24
|
clientPlan: "free" | "pro" | null;
|
|
24
|
-
|
|
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 (
|
|
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
|
-
|
|
97
|
+
quoteCoverage,
|
|
83
98
|
});
|
|
84
99
|
const openUpgrade = useCallback(() => {
|
|
85
100
|
void rendererHost.openExternal(CLOUD_UPGRADE_URL);
|