gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,14 +1,54 @@
1
- import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
-
3
- const screenerClient = createThrottledFetch({
4
- requestsPerMinute: 15,
5
- maxRetries: 2,
6
- timeoutMs: 10_000,
7
- defaultHeaders: {
8
- "User-Agent": "Gloomberb/0.4.1",
9
- Accept: "application/json",
10
- },
11
- });
1
+ import { createThrottledFetch, type ThrottledFetchTransport } from "../../../utils/throttled-fetch";
2
+
3
+ const YAHOO_FINANCE_HOSTS = [
4
+ "query2.finance.yahoo.com",
5
+ "query1.finance.yahoo.com",
6
+ ] as const;
7
+
8
+ const YAHOO_FINANCE_HEADERS = {
9
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
10
+ Accept: "application/json,text/plain,*/*",
11
+ "Accept-Language": "en-US,en;q=0.9",
12
+ Referer: "https://finance.yahoo.com/",
13
+ };
14
+
15
+ export interface YahooScreenerApi {
16
+ fetchJson<T = unknown>(path: string, params: Record<string, string | number>): Promise<T>;
17
+ }
18
+
19
+ export function createYahooScreenerApi(transport?: ThrottledFetchTransport): YahooScreenerApi {
20
+ const client = createThrottledFetch({
21
+ requestsPerMinute: 15,
22
+ maxRetries: 2,
23
+ timeoutMs: 10_000,
24
+ defaultHeaders: YAHOO_FINANCE_HEADERS,
25
+ transport,
26
+ });
27
+
28
+ return {
29
+ async fetchJson<T = unknown>(path: string, params: Record<string, string | number>): Promise<T> {
30
+ let lastError: unknown;
31
+ for (const host of YAHOO_FINANCE_HOSTS) {
32
+ const url = new URL(`https://${host}${path}`);
33
+ for (const [key, value] of Object.entries(params)) {
34
+ url.searchParams.set(key, String(value));
35
+ }
36
+
37
+ try {
38
+ return await client.fetchJson<T>(url.toString());
39
+ } catch (error) {
40
+ lastError = error;
41
+ }
42
+ }
43
+
44
+ throw lastError instanceof Error
45
+ ? lastError
46
+ : new Error("Yahoo Finance screener request failed");
47
+ },
48
+ };
49
+ }
50
+
51
+ const screenerApi = createYahooScreenerApi();
12
52
 
13
53
  export type ScreenerCategory = "day_gainers" | "day_losers" | "most_actives";
14
54
 
@@ -75,9 +115,18 @@ export function parseScreenerResponse(data: any): ScreenerQuote[] {
75
115
  }
76
116
  }
77
117
 
78
- export async function fetchScreener(category: ScreenerCategory, count = 25): Promise<ScreenerQuote[]> {
79
- const url = `https://query2.finance.yahoo.com/v1/finance/screener/predefined/saved?scrIds=${category}&count=${count}`;
80
- const data = await screenerClient.fetchJson(url);
118
+ export async function fetchScreener(
119
+ category: ScreenerCategory,
120
+ count = 25,
121
+ api: YahooScreenerApi = screenerApi,
122
+ ): Promise<ScreenerQuote[]> {
123
+ const data = await api.fetchJson("/v1/finance/screener/predefined/saved", {
124
+ formatted: "false",
125
+ lang: "en-US",
126
+ region: "US",
127
+ scrIds: category,
128
+ count,
129
+ });
81
130
  return parseScreenerResponse(data);
82
131
  }
83
132
 
@@ -96,9 +145,13 @@ export function parseTrendingResponse(data: any): TrendingSymbol[] {
96
145
  }
97
146
  }
98
147
 
99
- export async function fetchTrending(count = 25): Promise<TrendingSymbol[]> {
100
- const url = `https://query2.finance.yahoo.com/v1/finance/trending/US?count=${count}`;
101
- const data = await screenerClient.fetchJson(url);
148
+ export async function fetchTrending(
149
+ count = 25,
150
+ api: YahooScreenerApi = screenerApi,
151
+ ): Promise<TrendingSymbol[]> {
152
+ const data = await api.fetchJson("/v1/finance/trending/US", {
153
+ count,
154
+ });
102
155
  return parseTrendingResponse(data);
103
156
  }
104
157
 
@@ -1,7 +1,7 @@
1
1
  import { Box } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { PaneProps } from "../../../types/plugin";
4
- import { useNewsArticles } from "../../../news/hooks";
4
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
5
5
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
6
6
  import { Spinner } from "../../../components";
7
7
  import type { MarketNewsItem } from "../../../types/news-source";
@@ -41,7 +41,8 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
41
41
  const [aiRunning, setAiRunning] = useState(false);
42
42
  const [spinFrame, setSpinFrame] = useState(0);
43
43
  const processingRef = useRef(false);
44
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
44
+ const loadNewsStory = useLoadNewsStory();
45
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
45
46
  const { readArticleIds, markArticleRead } = useNewsReadState();
46
47
 
47
48
  useEffect(() => {
@@ -2,7 +2,7 @@ import { Box } from "../../../ui";
2
2
  import { useEffect, useMemo } from "react";
3
3
  import type { PaneProps } from "../../../types/plugin";
4
4
  import type { MarketNewsItem } from "../../../types/news-source";
5
- import { useNewsArticles } from "../../../news/hooks";
5
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
6
6
  import type { NewsQueryPhase } from "../../../news/types";
7
7
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
8
8
  import { Spinner, Tabs } from "../../../components";
@@ -43,7 +43,8 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
43
43
  const [sortPreference, setSortPreference] = usePluginPaneState<NewsSortPreference>("industry:sort", DEFAULT_SORT);
44
44
  const { articles, allArticles, phase } = useIndustryArticles(category);
45
45
  const loading = phase === "loading" || (phase === "refreshing" && articles.length === 0);
46
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
46
+ const loadNewsStory = useLoadNewsStory();
47
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
47
48
  const { readArticleIds, markArticleRead } = useNewsReadState();
48
49
  const counts = useMemo(() => {
49
50
  const next: Record<string, number> = { all: allArticles.length };
@@ -64,21 +65,6 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
64
65
  setSelectedArticleId(null);
65
66
  }, [category, setSelectedArticleId]);
66
67
 
67
- const handleRootKeyDown = (event: {
68
- name?: string;
69
- preventDefault?: () => void;
70
- stopPropagation?: () => void;
71
- }) => {
72
- if (event.name !== "left" && event.name !== "right" && event.name !== "h" && event.name !== "l") return;
73
- event.stopPropagation?.();
74
- event.preventDefault?.();
75
- const index = SECTOR_TABS.indexOf(category);
76
- const delta = event.name === "left" || event.name === "h" ? -1 : 1;
77
- const nextIndex = Math.max(0, Math.min(SECTOR_TABS.length - 1, index + delta));
78
- setCategory(SECTOR_TABS[nextIndex]!);
79
- return true;
80
- };
81
-
82
68
  useNewsArticleFooter({
83
69
  registrationId: "news-wire:industry",
84
70
  focused,
@@ -93,6 +79,7 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
93
79
  onSelect={(value) => setCategory(value as SectorNewsSelection)}
94
80
  compact
95
81
  variant="bare"
82
+ focused={focused}
96
83
  />
97
84
  </Box>
98
85
  );
@@ -126,7 +113,6 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
126
113
  detailContent={detailContent}
127
114
  detailTitle={detailArticle?.title}
128
115
  rootBefore={rootBefore}
129
- onRootKeyDown={handleRootKeyDown}
130
116
  columns={["time", "source", "title", "tickers", "categories"]}
131
117
  emptyContent={loading && articles.length === 0 ? (
132
118
  <Box width="100%" paddingX={1} paddingY={1}>
@@ -10,9 +10,27 @@ import { collectNewsDisplayTickers } from "../../../news/ticker-symbols";
10
10
  import { useInlineTickers } from "../../../state/use-inline-tickers";
11
11
  import { wrapTextLines } from "../../../utils/text-wrap";
12
12
 
13
- export function useNewsArticleDetail(articles: MarketNewsItem[]) {
13
+ function hasStoryItems(article: MarketNewsItem | null): boolean {
14
+ return (article?.items?.length ?? 0) > 0;
15
+ }
16
+
17
+ function mergeLoadedArticle(base: MarketNewsItem, loaded: MarketNewsItem | null | undefined): MarketNewsItem {
18
+ if (!loaded) return base;
19
+ return {
20
+ ...base,
21
+ ...loaded,
22
+ items: hasStoryItems(loaded) ? loaded.items : base.items,
23
+ };
24
+ }
25
+
26
+ export function useNewsArticleDetail(
27
+ articles: MarketNewsItem[],
28
+ loadArticleDetail?: (articleId: string) => Promise<MarketNewsItem | null>,
29
+ ) {
14
30
  const [detailArticleId, setDetailArticleId] = useState<string | null>(null);
15
- const detailArticle = useMemo(
31
+ const [loadedArticles, setLoadedArticles] = useState<Map<string, MarketNewsItem>>(() => new Map());
32
+ const requestedArticleIds = useRef<Set<string>>(new Set());
33
+ const baseDetailArticle = useMemo(
16
34
  () => (
17
35
  detailArticleId
18
36
  ? articles.find((article) => article.id === detailArticleId) ?? null
@@ -20,12 +38,36 @@ export function useNewsArticleDetail(articles: MarketNewsItem[]) {
20
38
  ),
21
39
  [articles, detailArticleId],
22
40
  );
41
+ const detailArticle = useMemo(() => (
42
+ baseDetailArticle && detailArticleId
43
+ ? mergeLoadedArticle(baseDetailArticle, loadedArticles.get(detailArticleId))
44
+ : baseDetailArticle
45
+ ), [baseDetailArticle, detailArticleId, loadedArticles]);
23
46
 
24
47
  useEffect(() => {
25
- if (detailArticleId && !detailArticle) {
48
+ if (detailArticleId && !baseDetailArticle) {
26
49
  setDetailArticleId(null);
27
50
  }
28
- }, [detailArticle, detailArticleId]);
51
+ }, [baseDetailArticle, detailArticleId]);
52
+
53
+ useEffect(() => {
54
+ if (!detailArticleId || !baseDetailArticle || hasStoryItems(detailArticle)) return;
55
+ if (!loadArticleDetail || requestedArticleIds.current.has(detailArticleId)) return;
56
+
57
+ requestedArticleIds.current.add(detailArticleId);
58
+ void loadArticleDetail(detailArticleId)
59
+ .then((loadedArticle) => {
60
+ if (!loadedArticle) return;
61
+ setLoadedArticles((current) => {
62
+ const next = new Map(current);
63
+ next.set(detailArticleId, loadedArticle);
64
+ return next;
65
+ });
66
+ })
67
+ .catch(() => {
68
+ requestedArticleIds.current.delete(detailArticleId);
69
+ });
70
+ }, [baseDetailArticle, detailArticle, detailArticleId, loadArticleDetail]);
29
71
 
30
72
  const openArticle = useCallback((article: MarketNewsItem) => {
31
73
  setDetailArticleId(article.id);
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../ui";
2
2
  import type { NewsQuery } from "../../../news/types";
3
- import { useNewsArticles } from "../../../news/hooks";
3
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
4
4
  import type { PaneProps } from "../../../types/plugin";
5
5
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
6
6
  import { Spinner } from "../../../components";
@@ -45,7 +45,8 @@ export function NewsPresetPane({
45
45
  `${paneKey}:sort`,
46
46
  defaultSort,
47
47
  );
48
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
48
+ const loadNewsStory = useLoadNewsStory();
49
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
49
50
  const { readArticleIds, markArticleRead } = useNewsReadState();
50
51
 
51
52
  useNewsArticleFooter({
@@ -473,6 +473,7 @@ function createQuickNotesPane(notesFiles: NotesFiles) {
473
473
  variant="pill"
474
474
  closeMode="active"
475
475
  onAdd={addTab}
476
+ focused={focused && !editing && !renaming}
476
477
  />
477
478
  </Box>
478
479
  {/* Rename input */}
@@ -246,23 +246,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
246
246
  return true;
247
247
  }
248
248
 
249
- if (!interactive) return false;
250
-
251
- const numExp = chain?.expirationDates.length ?? 0;
252
- if (event.name === "h" || event.name === "left") {
253
- event.preventDefault?.();
254
- event.stopPropagation?.();
255
- setExpIdx((i) => Math.max(i - 1, 0));
256
- return true;
257
- }
258
- if (event.name === "l" || event.name === "right") {
259
- event.preventDefault?.();
260
- event.stopPropagation?.();
261
- setExpIdx((i) => Math.min(i + 1, Math.max(numExp - 1, 0)));
262
- return true;
263
- }
264
249
  return false;
265
- }, [chain?.expirationDates.length, enterInteractive, exitInteractive, interactive, strikes.length]);
250
+ }, [enterInteractive, exitInteractive, interactive, strikes.length]);
266
251
 
267
252
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view options.</Text>;
268
253
  if (loading && !chain) return <Spinner label="Loading options chain..." />;
@@ -299,6 +284,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
299
284
  }}
300
285
  compact
301
286
  variant="bare"
287
+ focused={focused && interactive}
302
288
  />
303
289
  </Box>
304
290
  {loading && <Spinner />}
@@ -282,7 +282,6 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
282
282
  [tickers, financialsMap, activeSort, columnContext, columns],
283
283
  );
284
284
 
285
- const currentTabIdx = visibleCollections.findIndex((collection) => collection.id === activeCollectionId);
286
285
  const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
287
286
  const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
288
287
 
@@ -359,35 +358,15 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
359
358
  return true;
360
359
  }
361
360
 
362
- if (showCollectionTabs && (key === "h" || key === "left")) {
363
- event.preventDefault?.();
364
- event.stopPropagation?.();
365
- const previousCollection = visibleCollections[Math.max(currentTabIdx - 1, 0)];
366
- if (previousCollection) handleCollectionSelect(previousCollection.id);
367
- return true;
368
- }
369
-
370
- if (showCollectionTabs && (key === "l" || key === "right")) {
371
- event.preventDefault?.();
372
- event.stopPropagation?.();
373
- const nextCollection = visibleCollections[Math.min(currentTabIdx + 1, visibleCollections.length - 1)];
374
- if (nextCollection) handleCollectionSelect(nextCollection.id);
375
- return true;
376
- }
377
-
378
361
  return false;
379
362
  }, [
380
363
  cashDrawerExpanded,
381
- currentTabIdx,
382
364
  focused,
383
365
  openTickerFloating,
384
366
  safeSelectedIdx,
385
367
  setCashDrawerExpanded,
386
- handleCollectionSelect,
387
368
  showCashDrawer,
388
- showCollectionTabs,
389
369
  sortedTickers,
390
- visibleCollections,
391
370
  ]);
392
371
 
393
372
  useEffect(() => {
@@ -471,32 +450,29 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
471
450
  if (queue.length === 0) return;
472
451
 
473
452
  let cancelled = false;
474
- const runNext = async (): Promise<void> => {
475
- if (cancelled) return;
476
- const nextTicker = queue.shift();
477
- if (!nextTicker) return;
478
-
479
- const key = `${nextTicker.metadata.ticker}:${nextTicker.metadata.exchange ?? ""}`;
480
- const instrument = instrumentFromTicker(nextTicker, nextTicker.metadata.ticker);
481
- warmupInFlightRef.current.add(key);
482
- warmupAttemptRef.current.set(key, nowTimestamp);
453
+ const runBatch = async (): Promise<void> => {
454
+ const entries = queue.flatMap((ticker) => {
455
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
456
+ if (!instrument) return [];
457
+ const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
458
+ warmupInFlightRef.current.add(key);
459
+ warmupAttemptRef.current.set(key, nowTimestamp);
460
+ return [{ key, instrument }];
461
+ });
462
+ if (entries.length === 0) return;
483
463
  try {
484
- if (instrument) {
485
- await sharedCoordinator.loadSnapshot(instrument);
486
- }
464
+ await sharedCoordinator.loadSnapshotsBatch(entries.map((entry) => entry.instrument));
487
465
  } catch {
488
466
  // Best-effort warmup for visible rows only.
489
467
  } finally {
490
- warmupInFlightRef.current.delete(key);
491
- }
492
-
493
- if (mountedRef.current && !cancelled) {
494
- await runNext();
468
+ for (const entry of entries) {
469
+ warmupInFlightRef.current.delete(entry.key);
470
+ }
495
471
  }
496
472
  };
497
473
 
498
474
  const timeoutId = setTimeout(() => {
499
- void runNext();
475
+ if (!cancelled && mountedRef.current) void runBatch();
500
476
  }, VISIBLE_FINANCIAL_WARMUP_DELAY_MS);
501
477
 
502
478
  return () => {
@@ -590,6 +566,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
590
566
  activeValue={activeCollectionId}
591
567
  onSelect={handleCollectionSelect}
592
568
  compact
569
+ focused={focused}
593
570
  />
594
571
  </Box>
595
572
  </Box>
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AnalystRatingRecord } from "../../../types/financials";
3
+ import {
4
+ nextRatingSortPreference,
5
+ sortRatingRows,
6
+ type RatingSortPreference,
7
+ } from "./index";
8
+
9
+ const ratings: AnalystRatingRecord[] = [
10
+ {
11
+ date: "2026-05-06",
12
+ firm: "Beta Capital",
13
+ action: "Raises",
14
+ current: "Neutral",
15
+ prior: "Neutral",
16
+ currentPriceTarget: 385,
17
+ priorPriceTarget: 270,
18
+ },
19
+ {
20
+ date: "2026-05-07",
21
+ firm: "Alpha Research",
22
+ action: "Downgrade",
23
+ current: "Hold",
24
+ prior: "Buy",
25
+ currentPriceTarget: 340,
26
+ priorPriceTarget: 335,
27
+ },
28
+ {
29
+ date: "2026-05-06",
30
+ firm: "Zenith",
31
+ action: "Upgrade",
32
+ current: "Buy",
33
+ prior: "Neutral",
34
+ currentPriceTarget: 525,
35
+ priorPriceTarget: 265,
36
+ },
37
+ {
38
+ date: "2026-05-05",
39
+ firm: "No Target",
40
+ action: "Reiterates",
41
+ current: "Buy",
42
+ prior: "Buy",
43
+ },
44
+ ];
45
+
46
+ describe("analyst rating sorting", () => {
47
+ test("sorts date newest first by default", () => {
48
+ const preference: RatingSortPreference = { columnId: "date", direction: "desc" };
49
+
50
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
51
+ "Alpha Research",
52
+ "Beta Capital",
53
+ "Zenith",
54
+ "No Target",
55
+ ]);
56
+ });
57
+
58
+ test("sorts target by current target value with missing targets last", () => {
59
+ const preference: RatingSortPreference = { columnId: "target", direction: "desc" };
60
+
61
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
62
+ "Zenith",
63
+ "Beta Capital",
64
+ "Alpha Research",
65
+ "No Target",
66
+ ]);
67
+ });
68
+
69
+ test("sorts text columns alphabetically with recent dates as a tie-breaker", () => {
70
+ const preference: RatingSortPreference = { columnId: "firm", direction: "asc" };
71
+
72
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
73
+ "Alpha Research",
74
+ "Beta Capital",
75
+ "No Target",
76
+ "Zenith",
77
+ ]);
78
+ });
79
+
80
+ test("uses sensible first-click directions per column", () => {
81
+ expect(nextRatingSortPreference({ columnId: "date", direction: "desc" }, "date")).toEqual({
82
+ columnId: "date",
83
+ direction: "asc",
84
+ });
85
+ expect(nextRatingSortPreference({ columnId: "date", direction: "desc" }, "target")).toEqual({
86
+ columnId: "target",
87
+ direction: "desc",
88
+ });
89
+ expect(nextRatingSortPreference({ columnId: "target", direction: "desc" }, "firm")).toEqual({
90
+ columnId: "firm",
91
+ direction: "asc",
92
+ });
93
+ });
94
+ });