gloomberb 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +63 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +6 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -1,14 +1,54 @@
|
|
|
1
|
-
import { createThrottledFetch } from "../../../utils/throttled-fetch";
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 && !
|
|
48
|
+
if (detailArticleId && !baseDetailArticle) {
|
|
26
49
|
setDetailArticleId(null);
|
|
27
50
|
}
|
|
28
|
-
}, [
|
|
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
|
|
48
|
+
const loadNewsStory = useLoadNewsStory();
|
|
49
|
+
const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
|
|
49
50
|
const { readArticleIds, markArticleRead } = useNewsReadState();
|
|
50
51
|
|
|
51
52
|
useNewsArticleFooter({
|
|
@@ -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
|
-
}, [
|
|
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
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|