gloomberb 0.7.2 → 0.7.4
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/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -127,6 +127,23 @@ describe("MarketDataCoordinator", () => {
|
|
|
127
127
|
expect(calls).toBe(2);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("serves USD exchange rates without hitting the provider", async () => {
|
|
131
|
+
let calls = 0;
|
|
132
|
+
const provider = createProvider({
|
|
133
|
+
getExchangeRate: async () => {
|
|
134
|
+
calls += 1;
|
|
135
|
+
return 1;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
139
|
+
|
|
140
|
+
const entry = await coordinator.loadFxRate("usd");
|
|
141
|
+
|
|
142
|
+
expect(entry.data).toBe(1);
|
|
143
|
+
expect(entry.source).toBe("static");
|
|
144
|
+
expect(calls).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
130
147
|
it("reuses fresh empty tab query results instead of refetching on reopen", async () => {
|
|
131
148
|
let newsCalls = 0;
|
|
132
149
|
let optionsCalls = 0;
|
|
@@ -553,6 +570,50 @@ describe("MarketDataCoordinator", () => {
|
|
|
553
570
|
}
|
|
554
571
|
});
|
|
555
572
|
|
|
573
|
+
it("drops a stale stream quote when there is no fresh quote to preserve", () => {
|
|
574
|
+
const fixedNow = Date.parse("2026-05-13T21:00:00Z");
|
|
575
|
+
const realDateNow = Date.now;
|
|
576
|
+
Date.now = () => fixedNow;
|
|
577
|
+
try {
|
|
578
|
+
let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
|
|
579
|
+
const provider = createProvider({
|
|
580
|
+
subscribeQuotes: (_targets, onQuote) => {
|
|
581
|
+
streamed = onQuote as typeof streamed;
|
|
582
|
+
return () => {};
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
586
|
+
const instrument = { symbol: "2337", exchange: "TWSE" };
|
|
587
|
+
|
|
588
|
+
coordinator.subscribeQuotes([{ instrument }]);
|
|
589
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
590
|
+
if (!onStreamed) throw new Error("expected streaming callback");
|
|
591
|
+
onStreamed(
|
|
592
|
+
{ symbol: "2337", exchange: "TWSE" },
|
|
593
|
+
{
|
|
594
|
+
symbol: "2337",
|
|
595
|
+
providerId: "gloomberb-cloud",
|
|
596
|
+
dataSource: "delayed",
|
|
597
|
+
price: 150,
|
|
598
|
+
currency: "TWD",
|
|
599
|
+
change: 0,
|
|
600
|
+
changePercent: 0,
|
|
601
|
+
lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
|
|
602
|
+
listingExchangeName: "TWSE",
|
|
603
|
+
marketState: "CLOSED",
|
|
604
|
+
sessionConfidence: "explicit",
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
const entry = coordinator.getQuoteEntry(instrument);
|
|
609
|
+
expect(entry.data).toBeNull();
|
|
610
|
+
expect(entry.lastGoodData).toBeNull();
|
|
611
|
+
expect(coordinator.getTickerFinancialsSync(instrument)?.quote).toBeUndefined();
|
|
612
|
+
} finally {
|
|
613
|
+
Date.now = realDateNow;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
556
617
|
it("hydrates ticker financials synchronously from primed cached data", () => {
|
|
557
618
|
const coordinator = new MarketDataCoordinator(createProvider());
|
|
558
619
|
const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
@@ -642,7 +703,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
642
703
|
currency: "USD",
|
|
643
704
|
change: 0,
|
|
644
705
|
changePercent: 0,
|
|
645
|
-
lastUpdated:
|
|
706
|
+
lastUpdated: Date.now(),
|
|
646
707
|
}),
|
|
647
708
|
getQuotesBatch: async (targets) => {
|
|
648
709
|
batchTargets.push(targets);
|
|
@@ -654,7 +715,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
654
715
|
currency: "USD",
|
|
655
716
|
change: 0,
|
|
656
717
|
changePercent: 0,
|
|
657
|
-
lastUpdated:
|
|
718
|
+
lastUpdated: Date.now(),
|
|
658
719
|
},
|
|
659
720
|
}));
|
|
660
721
|
},
|
|
@@ -672,6 +733,65 @@ describe("MarketDataCoordinator", () => {
|
|
|
672
733
|
expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
|
|
673
734
|
});
|
|
674
735
|
|
|
736
|
+
it("refreshes primed quote cache when the quote is stale for the current session", async () => {
|
|
737
|
+
const fixedNow = Date.parse("2026-05-13T21:00:00Z");
|
|
738
|
+
const realDateNow = Date.now;
|
|
739
|
+
Date.now = () => fixedNow;
|
|
740
|
+
try {
|
|
741
|
+
const batchTargets: QuoteSubscriptionTarget[][] = [];
|
|
742
|
+
const provider = createProvider({
|
|
743
|
+
getQuotesBatch: async (targets) => {
|
|
744
|
+
batchTargets.push(targets);
|
|
745
|
+
return targets.map((target) => ({
|
|
746
|
+
target,
|
|
747
|
+
quote: {
|
|
748
|
+
symbol: target.symbol,
|
|
749
|
+
price: 6970,
|
|
750
|
+
currency: "JPY",
|
|
751
|
+
change: 370,
|
|
752
|
+
changePercent: 5.61,
|
|
753
|
+
lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
|
|
754
|
+
marketState: "CLOSED",
|
|
755
|
+
listingExchangeName: "JPX",
|
|
756
|
+
providerId: "yahoo",
|
|
757
|
+
},
|
|
758
|
+
}));
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
762
|
+
const instrument = { symbol: "6324.T", exchange: "JPX" };
|
|
763
|
+
|
|
764
|
+
coordinator.primeCachedFinancials([{
|
|
765
|
+
instrument,
|
|
766
|
+
financials: {
|
|
767
|
+
quote: {
|
|
768
|
+
symbol: "6324.T",
|
|
769
|
+
price: 6230,
|
|
770
|
+
currency: "JPY",
|
|
771
|
+
change: 520,
|
|
772
|
+
changePercent: 9.11,
|
|
773
|
+
lastUpdated: Date.parse("2026-05-08T06:24:00Z"),
|
|
774
|
+
marketState: "CLOSED",
|
|
775
|
+
listingExchangeName: "JPX",
|
|
776
|
+
providerId: "gloomberb-cloud",
|
|
777
|
+
},
|
|
778
|
+
annualStatements: [],
|
|
779
|
+
quarterlyStatements: [],
|
|
780
|
+
priceHistory: [],
|
|
781
|
+
},
|
|
782
|
+
}]);
|
|
783
|
+
|
|
784
|
+
await coordinator.loadQuotesBatch([instrument]);
|
|
785
|
+
|
|
786
|
+
expect(batchTargets).toHaveLength(1);
|
|
787
|
+
expect(batchTargets[0]?.map((target) => target.symbol)).toEqual(["6324.T"]);
|
|
788
|
+
expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(6970);
|
|
789
|
+
expect(coordinator.getQuoteEntry(instrument).data?.changePercent).toBe(5.61);
|
|
790
|
+
} finally {
|
|
791
|
+
Date.now = realDateNow;
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
675
795
|
it("batch loads snapshots through provider batch support", async () => {
|
|
676
796
|
const batchSymbols: string[][] = [];
|
|
677
797
|
const provider = createProvider({
|
|
@@ -23,9 +23,9 @@ import {
|
|
|
23
23
|
toMarketDataContext,
|
|
24
24
|
} from "./selectors";
|
|
25
25
|
import { traceMarketData } from "./trace";
|
|
26
|
-
import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
26
|
+
import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
27
27
|
import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
|
|
28
|
-
import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
|
|
28
|
+
import { isIntradayResolution, TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
|
|
29
29
|
import { measurePerf } from "../utils/perf-marks";
|
|
30
30
|
|
|
31
31
|
const EMPTY_MESSAGE = "No data available";
|
|
@@ -57,6 +57,29 @@ function getTimeRangeIndex(range: ChartRequest["bufferRange"]): number {
|
|
|
57
57
|
return TIME_RANGE_INDEX.get(range) ?? 0;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function isCurrentHistoryWindow(endDate?: Date): boolean {
|
|
61
|
+
if (!endDate) return true;
|
|
62
|
+
const endMs = endDate.getTime();
|
|
63
|
+
return Number.isFinite(endMs) && Date.now() - endMs < 60 * 60_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isIntradayChartRequest(request: ChartRequest): boolean {
|
|
67
|
+
const granularity = getChartGranularity(request);
|
|
68
|
+
if (granularity === "detail") return isCurrentHistoryWindow(request.endDate);
|
|
69
|
+
if (granularity === "resolution") {
|
|
70
|
+
return request.resolution ? isIntradayResolution(request.resolution) : false;
|
|
71
|
+
}
|
|
72
|
+
return request.bufferRange === "1D" || request.bufferRange === "1W" || request.bufferRange === "1M" || request.bufferRange === "3M";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeFreshChartData(points: PricePoint[] | null | undefined, request: ChartRequest): PricePoint[] {
|
|
76
|
+
const normalized = normalizePriceHistory(points ?? []);
|
|
77
|
+
if (isIntradayChartRequest(request) && isPriceHistoryStaleForCurrentWindow(normalized)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
function isSeedableChartRequest(
|
|
61
84
|
target: ChartRequest,
|
|
62
85
|
candidate: ChartRequest,
|
|
@@ -88,6 +111,20 @@ function hasFreshReadyEntry<T>(entry: QueryEntry<T>, ttlMs: number, now = Date.n
|
|
|
88
111
|
return entry.phase === "ready" && entry.fetchedAt != null && now - entry.fetchedAt < ttlMs;
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
function hasFreshQuoteEntry(
|
|
115
|
+
entry: QueryEntry<Quote>,
|
|
116
|
+
instrument: InstrumentRef,
|
|
117
|
+
ttlMs: number,
|
|
118
|
+
now = Date.now(),
|
|
119
|
+
): boolean {
|
|
120
|
+
const quote = resolveEntryData(entry);
|
|
121
|
+
if (!quote || entry.fetchedAt == null || now - entry.fetchedAt >= ttlMs) return false;
|
|
122
|
+
const quoteForFreshness = instrument.exchange && !quote.listingExchangeName && !quote.exchangeName
|
|
123
|
+
? { ...quote, listingExchangeName: instrument.exchange }
|
|
124
|
+
: quote;
|
|
125
|
+
return !isQuoteStaleForCurrentSession(quoteForFreshness, now);
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
function createAttempt(
|
|
92
129
|
providerId: string,
|
|
93
130
|
startedAt: number,
|
|
@@ -142,11 +179,9 @@ function readyQuoteEntry(
|
|
|
142
179
|
source: string,
|
|
143
180
|
attempts: ProviderAttempt[],
|
|
144
181
|
): QueryEntry<Quote> {
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
) {
|
|
149
|
-
return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: true });
|
|
182
|
+
if (isQuoteStaleForCurrentSession(quote)) {
|
|
183
|
+
const keepFreshQuote = hasFreshQuoteForCurrentSession([current.data, current.lastGoodData]);
|
|
184
|
+
return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: keepFreshQuote });
|
|
150
185
|
}
|
|
151
186
|
return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
|
|
152
187
|
}
|
|
@@ -189,19 +224,69 @@ const STREAM_QUOTE_FIELDS: Array<keyof Quote> = [
|
|
|
189
224
|
"dataSource",
|
|
190
225
|
];
|
|
191
226
|
|
|
192
|
-
function quoteTimestampMinute(quote: Quote): number | null {
|
|
193
|
-
return Number.isFinite(quote.lastUpdated) ? Math.floor(quote.lastUpdated / 60_000) : null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
227
|
function areStreamQuotesEquivalent(current: Quote | null | undefined, next: Quote): boolean {
|
|
197
228
|
if (!current) return false;
|
|
198
229
|
for (const field of STREAM_QUOTE_FIELDS) {
|
|
199
230
|
if (current[field] !== next[field]) return false;
|
|
200
231
|
}
|
|
201
|
-
return
|
|
232
|
+
return current.lastUpdated === next.lastUpdated
|
|
202
233
|
&& JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
|
|
203
234
|
}
|
|
204
235
|
|
|
236
|
+
function hasCachedSnapshotData(financials: TickerFinancials): boolean {
|
|
237
|
+
return !!financials.profile
|
|
238
|
+
|| Object.keys(financials.fundamentals ?? {}).length > 0
|
|
239
|
+
|| financials.annualStatements.length > 0
|
|
240
|
+
|| financials.quarterlyStatements.length > 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "surface" | "visible" | "selected" | "weight">;
|
|
244
|
+
|
|
245
|
+
interface QuoteSubscriptionEntry {
|
|
246
|
+
target: QuoteSubscriptionTarget;
|
|
247
|
+
targets: Map<number, QuoteSubscriptionTarget>;
|
|
248
|
+
removeTimer: ReturnType<typeof setTimeout> | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function quoteSubscriptionPriorityScore(target: QuoteSubscriptionTarget): number {
|
|
252
|
+
let score = Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0;
|
|
253
|
+
if (target.selected) score += 10_000;
|
|
254
|
+
if (target.visible) score += 5_000;
|
|
255
|
+
if (target.surface === "detail" || target.surface === "monitor") score += 4_000;
|
|
256
|
+
if (target.surface === "portfolio" || target.surface === "watchlist") score += 1_000;
|
|
257
|
+
if (target.surface === "screener") score += 700;
|
|
258
|
+
if (target.surface === "inline") score += 200;
|
|
259
|
+
return score;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function mergeQuoteSubscriptionTargets(targets: Iterable<QuoteSubscriptionTarget>): QuoteSubscriptionTarget | null {
|
|
263
|
+
let selectedTarget: QuoteSubscriptionTarget | null = null;
|
|
264
|
+
let selectedScore = -1;
|
|
265
|
+
let visible = false;
|
|
266
|
+
let selected = false;
|
|
267
|
+
let weight = 0;
|
|
268
|
+
|
|
269
|
+
for (const target of targets) {
|
|
270
|
+
const score = quoteSubscriptionPriorityScore(target);
|
|
271
|
+
if (!selectedTarget || score > selectedScore) {
|
|
272
|
+
selectedTarget = target;
|
|
273
|
+
selectedScore = score;
|
|
274
|
+
}
|
|
275
|
+
visible ||= target.visible === true;
|
|
276
|
+
selected ||= target.selected === true;
|
|
277
|
+
weight = Math.max(weight, Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return selectedTarget
|
|
281
|
+
? {
|
|
282
|
+
...selectedTarget,
|
|
283
|
+
visible,
|
|
284
|
+
selected,
|
|
285
|
+
weight,
|
|
286
|
+
}
|
|
287
|
+
: null;
|
|
288
|
+
}
|
|
289
|
+
|
|
205
290
|
function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
|
|
206
291
|
return {
|
|
207
292
|
...current,
|
|
@@ -224,12 +309,10 @@ export class MarketDataCoordinator {
|
|
|
224
309
|
private readonly keyVersions = new Map<string, number>();
|
|
225
310
|
private readonly inFlight = new Map<string, Promise<unknown>>();
|
|
226
311
|
private readonly chartRequests = new Map<string, ChartRequest>();
|
|
227
|
-
private readonly quoteSubscriptions = new Map<string,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
removeTimer: ReturnType<typeof setTimeout> | null;
|
|
232
|
-
}>();
|
|
312
|
+
private readonly quoteSubscriptions = new Map<string, QuoteSubscriptionEntry>();
|
|
313
|
+
private nextQuoteSubscriptionId = 1;
|
|
314
|
+
private quoteSubscriptionDispose: (() => void) | null = null;
|
|
315
|
+
private quoteSubscriptionSignature = "";
|
|
233
316
|
private pendingQuoteSubscriptionKeys = new Set<string>();
|
|
234
317
|
|
|
235
318
|
private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
|
|
@@ -365,7 +448,7 @@ export class MarketDataCoordinator {
|
|
|
365
448
|
const fundamentalsKey = buildFundamentalsKey(instrument);
|
|
366
449
|
const statementsKey = buildStatementsKey(instrument);
|
|
367
450
|
|
|
368
|
-
if (this.snapshotStore.get(snapshotKey).phase === "idle") {
|
|
451
|
+
if (hasCachedSnapshotData(normalized) && this.snapshotStore.get(snapshotKey).phase === "idle") {
|
|
369
452
|
this.snapshotStore.set(
|
|
370
453
|
snapshotKey,
|
|
371
454
|
readyEntry(this.snapshotStore.get(snapshotKey), normalized, source, [], { keepLastGoodOnEmpty: true }),
|
|
@@ -445,11 +528,15 @@ export class MarketDataCoordinator {
|
|
|
445
528
|
};
|
|
446
529
|
}
|
|
447
530
|
|
|
448
|
-
private quoteTargetFromInstrument(
|
|
531
|
+
private quoteTargetFromInstrument(
|
|
532
|
+
instrument: InstrumentRef,
|
|
533
|
+
priority: QuoteSubscriptionPriority = {},
|
|
534
|
+
): QuoteSubscriptionTarget {
|
|
449
535
|
return {
|
|
450
536
|
symbol: instrument.symbol,
|
|
451
537
|
exchange: instrument.exchange ?? "",
|
|
452
538
|
context: toMarketDataContext(instrument),
|
|
539
|
+
...priority,
|
|
453
540
|
};
|
|
454
541
|
}
|
|
455
542
|
|
|
@@ -516,19 +603,37 @@ export class MarketDataCoordinator {
|
|
|
516
603
|
request: ChartRequest,
|
|
517
604
|
current: QueryEntry<PricePoint[]>,
|
|
518
605
|
): QueryEntry<PricePoint[]> {
|
|
519
|
-
|
|
520
|
-
|
|
606
|
+
const currentData = normalizeFreshChartData(resolveEntryData(current), request);
|
|
607
|
+
if (currentData.length) {
|
|
608
|
+
return loadingEntry({
|
|
609
|
+
...current,
|
|
610
|
+
data: currentData,
|
|
611
|
+
lastGoodData: currentData,
|
|
612
|
+
});
|
|
521
613
|
}
|
|
522
614
|
|
|
523
615
|
const seed = this.findChartSeedEntry(key, request);
|
|
524
616
|
if (!seed) {
|
|
525
|
-
return loadingEntry(
|
|
617
|
+
return loadingEntry({
|
|
618
|
+
...current,
|
|
619
|
+
data: null,
|
|
620
|
+
lastGoodData: null,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const seedData = normalizeFreshChartData(seed.data, request);
|
|
625
|
+
if (!seedData.length) {
|
|
626
|
+
return loadingEntry({
|
|
627
|
+
...current,
|
|
628
|
+
data: null,
|
|
629
|
+
lastGoodData: null,
|
|
630
|
+
});
|
|
526
631
|
}
|
|
527
632
|
|
|
528
633
|
return loadingEntry({
|
|
529
634
|
...current,
|
|
530
|
-
data:
|
|
531
|
-
lastGoodData:
|
|
635
|
+
data: seedData,
|
|
636
|
+
lastGoodData: seedData,
|
|
532
637
|
source: seed.entry.source,
|
|
533
638
|
fetchedAt: seed.entry.fetchedAt,
|
|
534
639
|
staleAt: seed.entry.staleAt,
|
|
@@ -661,7 +766,7 @@ export class MarketDataCoordinator {
|
|
|
661
766
|
for (const instrument of uniqueInstruments) {
|
|
662
767
|
const key = buildQuoteKey(instrument);
|
|
663
768
|
const current = this.quoteStore.get(key);
|
|
664
|
-
if (!options.forceRefresh &&
|
|
769
|
+
if (!options.forceRefresh && hasFreshQuoteEntry(current, instrument, SNAPSHOT_CACHE_TTL_MS)) {
|
|
665
770
|
results.set(key, current);
|
|
666
771
|
} else {
|
|
667
772
|
misses.push(instrument);
|
|
@@ -699,7 +804,13 @@ export class MarketDataCoordinator {
|
|
|
699
804
|
const key = buildChartKey(request);
|
|
700
805
|
this.chartRequests.set(key, request);
|
|
701
806
|
const current = this.chartStore.get(key);
|
|
702
|
-
|
|
807
|
+
const currentData = normalizeFreshChartData(resolveEntryData(current), request);
|
|
808
|
+
if (!options.forceRefresh && currentData.length > 0 && hasFreshEntryData(current, CHART_CACHE_TTL_MS)) {
|
|
809
|
+
if (currentData !== resolveEntryData(current)) {
|
|
810
|
+
return this.chartStore.update(key, (entry) =>
|
|
811
|
+
readyEntry(entry, currentData, entry.source ?? this.dataProvider.id, entry.attempts, { keepLastGoodOnEmpty: true })
|
|
812
|
+
);
|
|
813
|
+
}
|
|
703
814
|
return current;
|
|
704
815
|
}
|
|
705
816
|
const flightKey = options.forceRefresh ? `${key}|refresh` : key;
|
|
@@ -898,16 +1009,22 @@ export class MarketDataCoordinator {
|
|
|
898
1009
|
}
|
|
899
1010
|
|
|
900
1011
|
async loadFxRate(currency: string): Promise<QueryEntry<number>> {
|
|
901
|
-
const
|
|
1012
|
+
const normalizedCurrency = currency.trim().toUpperCase();
|
|
1013
|
+
const key = buildFxKey(normalizedCurrency);
|
|
902
1014
|
const current = this.fxStore.get(key);
|
|
903
1015
|
if (hasFreshEntryData(current, FX_CACHE_TTL_MS)) {
|
|
904
1016
|
return current;
|
|
905
1017
|
}
|
|
1018
|
+
if (normalizedCurrency === "USD") {
|
|
1019
|
+
const startedAt = Date.now();
|
|
1020
|
+
const attempts = [createAttempt("static", startedAt, "success")];
|
|
1021
|
+
return this.fxStore.update(key, (current) => readyEntry(current, 1, "static", attempts, { keepLastGoodOnEmpty: true }));
|
|
1022
|
+
}
|
|
906
1023
|
return this.runSingleFlight(key, async () => {
|
|
907
1024
|
this.fxStore.update(key, loadingEntry);
|
|
908
1025
|
const startedAt = Date.now();
|
|
909
1026
|
try {
|
|
910
|
-
const rate = await this.dataProvider.getExchangeRate(
|
|
1027
|
+
const rate = await this.dataProvider.getExchangeRate(normalizedCurrency);
|
|
911
1028
|
const attempts = [createAttempt(this.dataProvider.id, startedAt, "success")];
|
|
912
1029
|
return this.fxStore.update(key, (current) => readyEntry(current, rate, this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
|
|
913
1030
|
} catch (error) {
|
|
@@ -918,18 +1035,22 @@ export class MarketDataCoordinator {
|
|
|
918
1035
|
});
|
|
919
1036
|
}
|
|
920
1037
|
|
|
921
|
-
subscribeQuotes(targets: Array<{ instrument: InstrumentRef }>): () => void {
|
|
1038
|
+
subscribeQuotes(targets: Array<{ instrument: InstrumentRef; priority?: QuoteSubscriptionPriority }>): () => void {
|
|
922
1039
|
if (!this.dataProvider.subscribeQuotes || targets.length === 0) {
|
|
923
1040
|
return () => {};
|
|
924
1041
|
}
|
|
925
1042
|
|
|
1043
|
+
const subscriptionId = this.nextQuoteSubscriptionId++;
|
|
926
1044
|
const subscribedKeys = new Set<string>();
|
|
927
|
-
for (const { instrument } of targets) {
|
|
1045
|
+
for (const { instrument, priority } of targets) {
|
|
928
1046
|
const key = buildQuoteKey(instrument);
|
|
929
1047
|
subscribedKeys.add(key);
|
|
1048
|
+
const target = this.quoteTargetFromInstrument(instrument, priority);
|
|
930
1049
|
const existing = this.quoteSubscriptions.get(key);
|
|
931
1050
|
if (existing) {
|
|
932
|
-
existing.
|
|
1051
|
+
existing.targets.set(subscriptionId, target);
|
|
1052
|
+
existing.target = mergeQuoteSubscriptionTargets(existing.targets.values()) ?? target;
|
|
1053
|
+
this.pendingQuoteSubscriptionKeys.add(key);
|
|
933
1054
|
if (existing.removeTimer) {
|
|
934
1055
|
clearTimeout(existing.removeTimer);
|
|
935
1056
|
existing.removeTimer = null;
|
|
@@ -937,9 +1058,8 @@ export class MarketDataCoordinator {
|
|
|
937
1058
|
continue;
|
|
938
1059
|
}
|
|
939
1060
|
this.quoteSubscriptions.set(key, {
|
|
940
|
-
target
|
|
941
|
-
|
|
942
|
-
unsubscribe: null,
|
|
1061
|
+
target,
|
|
1062
|
+
targets: new Map([[subscriptionId, target]]),
|
|
943
1063
|
removeTimer: null,
|
|
944
1064
|
});
|
|
945
1065
|
this.pendingQuoteSubscriptionKeys.add(key);
|
|
@@ -950,44 +1070,68 @@ export class MarketDataCoordinator {
|
|
|
950
1070
|
for (const key of subscribedKeys) {
|
|
951
1071
|
const existing = this.quoteSubscriptions.get(key);
|
|
952
1072
|
if (!existing) continue;
|
|
953
|
-
existing.
|
|
954
|
-
|
|
1073
|
+
existing.targets.delete(subscriptionId);
|
|
1074
|
+
const mergedTarget = mergeQuoteSubscriptionTargets(existing.targets.values());
|
|
1075
|
+
if (mergedTarget) {
|
|
1076
|
+
existing.target = mergedTarget;
|
|
1077
|
+
this.pendingQuoteSubscriptionKeys.add(key);
|
|
1078
|
+
this.flushQuoteSubscriptions();
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (existing.removeTimer) continue;
|
|
955
1082
|
existing.removeTimer = setTimeout(() => {
|
|
956
1083
|
const current = this.quoteSubscriptions.get(key);
|
|
957
|
-
if (!current || current.
|
|
958
|
-
current.unsubscribe?.();
|
|
1084
|
+
if (!current || current.targets.size > 0) return;
|
|
959
1085
|
this.quoteSubscriptions.delete(key);
|
|
960
1086
|
this.pendingQuoteSubscriptionKeys.delete(key);
|
|
1087
|
+
this.flushQuoteSubscriptions();
|
|
961
1088
|
}, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
|
|
962
1089
|
}
|
|
963
1090
|
};
|
|
964
1091
|
}
|
|
965
1092
|
|
|
966
1093
|
private flushQuoteSubscriptions(): void {
|
|
967
|
-
|
|
1094
|
+
if (!this.dataProvider.subscribeQuotes) return;
|
|
968
1095
|
this.pendingQuoteSubscriptionKeys = new Set();
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1096
|
+
|
|
1097
|
+
const activeEntries = [...this.quoteSubscriptions.entries()]
|
|
1098
|
+
.filter(([, entry]) => entry.targets.size > 0 || entry.removeTimer)
|
|
1099
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
1100
|
+
const nextSignature = activeEntries.map(([key, entry]) => [
|
|
1101
|
+
key,
|
|
1102
|
+
entry.target.surface ?? "",
|
|
1103
|
+
entry.target.visible ? "visible" : "",
|
|
1104
|
+
entry.target.selected ? "selected" : "",
|
|
1105
|
+
Number.isFinite(entry.target.weight) ? entry.target.weight : "",
|
|
1106
|
+
].join(":")).join("|");
|
|
1107
|
+
if (nextSignature === this.quoteSubscriptionSignature) return;
|
|
1108
|
+
|
|
1109
|
+
this.quoteSubscriptionDispose?.();
|
|
1110
|
+
this.quoteSubscriptionDispose = null;
|
|
1111
|
+
this.quoteSubscriptionSignature = nextSignature;
|
|
1112
|
+
|
|
1113
|
+
const targets = activeEntries.map(([, entry]) => entry.target);
|
|
1114
|
+
if (targets.length === 0) return;
|
|
1115
|
+
|
|
1116
|
+
this.quoteSubscriptionDispose = this.dataProvider.subscribeQuotes(targets, (target, quote) => {
|
|
1117
|
+
const instrument: InstrumentRef = {
|
|
1118
|
+
symbol: target.symbol,
|
|
1119
|
+
exchange: target.exchange ?? "",
|
|
1120
|
+
brokerId: target.context?.brokerId,
|
|
1121
|
+
brokerInstanceId: target.context?.brokerInstanceId,
|
|
1122
|
+
instrument: target.context?.instrument ?? null,
|
|
1123
|
+
};
|
|
1124
|
+
this.applyStreamQuote(instrument, quote);
|
|
1125
|
+
});
|
|
983
1126
|
}
|
|
984
1127
|
|
|
985
1128
|
private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
|
|
986
1129
|
const key = buildQuoteKey(instrument);
|
|
987
1130
|
const current = this.quoteStore.get(key);
|
|
988
1131
|
if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
|
|
989
|
-
const
|
|
990
|
-
|
|
1132
|
+
const receivedAt = Date.now();
|
|
1133
|
+
const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, receivedAt, "success")];
|
|
1134
|
+
this.quoteStore.set(key, readyQuoteEntry(current, { ...quote, receivedAt }, quote.providerId ?? this.dataProvider.id, attempts));
|
|
991
1135
|
}
|
|
992
1136
|
}
|
|
993
1137
|
|
|
@@ -73,6 +73,12 @@ function HooksHarness() {
|
|
|
73
73
|
return <text>{String(tick)}</text>;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function FxRatesHarness() {
|
|
77
|
+
latestFxRates = useFxRatesMap(["usd", "EUR", null]);
|
|
78
|
+
|
|
79
|
+
return <text>fx</text>;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
function ChartQueriesHarness({
|
|
77
83
|
initialRequests,
|
|
78
84
|
debounceMs,
|
|
@@ -147,6 +153,31 @@ describe("market-data hooks", () => {
|
|
|
147
153
|
expect(latestFinancialsMap).toBe(initialFinancialsMap);
|
|
148
154
|
});
|
|
149
155
|
|
|
156
|
+
test("does not load USD exchange rates from providers", async () => {
|
|
157
|
+
const loadedFxCurrencies: string[] = [];
|
|
158
|
+
const coordinator = {
|
|
159
|
+
subscribe: () => () => {},
|
|
160
|
+
getVersion: () => 1,
|
|
161
|
+
getFxEntry: (currency: string) => (currency === "EUR" ? readyEurEntry : readyUsdEntry),
|
|
162
|
+
loadFxRate: async (currency: string) => {
|
|
163
|
+
loadedFxCurrencies.push(currency);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
167
|
+
|
|
168
|
+
testSetup = await testRender(<FxRatesHarness />, {
|
|
169
|
+
width: 20,
|
|
170
|
+
height: 1,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await testSetup!.renderOnce();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(latestFxRates?.get("USD")).toBe(1);
|
|
178
|
+
expect(loadedFxCurrencies).toEqual(["EUR"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
150
181
|
test("debounces chart query batches and cancels superseded schedules", async () => {
|
|
151
182
|
const loadedRanges: string[] = [];
|
|
152
183
|
const idleChartEntry = createIdleEntry<PricePoint[]>();
|
|
@@ -283,6 +283,7 @@ export function useFxRatesMap(currencies: Array<string | null | undefined>): Map
|
|
|
283
283
|
const coordinator = getSharedMarketDataCoordinator();
|
|
284
284
|
if (!coordinator) return;
|
|
285
285
|
for (const currency of normalizedCurrencies) {
|
|
286
|
+
if (currency === "USD") continue;
|
|
286
287
|
void coordinator.loadFxRate(currency);
|
|
287
288
|
}
|
|
288
289
|
}, [normalizedCurrencyKey]);
|
|
@@ -312,4 +312,45 @@ describe("NewsService", () => {
|
|
|
312
312
|
expect(detail?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
|
313
313
|
expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
it("keeps story detail identity when a duplicate feed item wins ranking", async () => {
|
|
317
|
+
const url = "https://detail.example.com/duplicate-story";
|
|
318
|
+
const cloudArticle = makeItem({
|
|
319
|
+
id: "story-1",
|
|
320
|
+
url,
|
|
321
|
+
importance: 60,
|
|
322
|
+
publishedAt: new Date("2026-04-01T10:00:00.000Z"),
|
|
323
|
+
items: [],
|
|
324
|
+
});
|
|
325
|
+
const feedDuplicate = makeItem({
|
|
326
|
+
id: "rss-1",
|
|
327
|
+
url,
|
|
328
|
+
importance: 95,
|
|
329
|
+
publishedAt: new Date("2026-04-01T10:05:00.000Z"),
|
|
330
|
+
});
|
|
331
|
+
const detailArticle = makeItem({
|
|
332
|
+
...cloudArticle,
|
|
333
|
+
items: [{
|
|
334
|
+
id: "item-1",
|
|
335
|
+
sourceKey: "wire-a",
|
|
336
|
+
sourceName: "Wire A",
|
|
337
|
+
title: "Original",
|
|
338
|
+
url: "https://detail.example.com/original",
|
|
339
|
+
publishedAt: new Date("2026-04-01T10:00:00.000Z"),
|
|
340
|
+
}],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
agg.register({ ...makeStorySource("cloud", [cloudArticle], detailArticle), priority: 10 });
|
|
344
|
+
agg.register({ ...makeSource("rss", [feedDuplicate]), priority: 2000 });
|
|
345
|
+
|
|
346
|
+
const initial = await agg.load({ feed: "top", limit: 10 });
|
|
347
|
+
|
|
348
|
+
expect(initial.articles[0]?.id).toBe("story-1");
|
|
349
|
+
expect(initial.articles[0]?.importance).toBe(95);
|
|
350
|
+
|
|
351
|
+
await agg.loadStory(initial.articles[0]!.id);
|
|
352
|
+
const state = agg.getQueryState({ feed: "top", limit: 10 });
|
|
353
|
+
|
|
354
|
+
expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-1"]);
|
|
355
|
+
});
|
|
315
356
|
});
|