gloomberb 0.9.6 → 0.9.8
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/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/config.ts +1 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -12,6 +12,10 @@ export interface InstrumentRef {
|
|
|
12
12
|
instrument?: BrokerContractRef | null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface TickerInstrumentOptions {
|
|
16
|
+
portfolioId?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
type ChartGranularity = "range" | "detail" | "resolution";
|
|
16
20
|
|
|
17
21
|
export interface ChartRequest {
|
|
@@ -39,10 +43,44 @@ export interface SecFilingsRequest {
|
|
|
39
43
|
count?: number;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
function brokerContractForTicker(
|
|
47
|
+
ticker: TickerRecord | null | undefined,
|
|
48
|
+
options: TickerInstrumentOptions = {},
|
|
49
|
+
): BrokerContractRef | null {
|
|
50
|
+
const contracts = ticker?.metadata.broker_contracts ?? [];
|
|
51
|
+
if (contracts.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
const portfolioId = options.portfolioId;
|
|
54
|
+
if (portfolioId) {
|
|
55
|
+
const positions = ticker?.metadata.positions.filter((position) => position.portfolio === portfolioId) ?? [];
|
|
56
|
+
for (const position of positions) {
|
|
57
|
+
const matchingContract = contracts.find((contract) => (
|
|
58
|
+
(position.brokerContractId == null || contract.conId === position.brokerContractId)
|
|
59
|
+
&& (!position.brokerInstanceId || contract.brokerInstanceId === position.brokerInstanceId)
|
|
60
|
+
&& (!position.broker || contract.brokerId === position.broker)
|
|
61
|
+
));
|
|
62
|
+
if (matchingContract) return matchingContract;
|
|
63
|
+
}
|
|
64
|
+
for (const position of positions) {
|
|
65
|
+
const matchingContract = contracts.find((contract) => (
|
|
66
|
+
(!position.brokerInstanceId || contract.brokerInstanceId === position.brokerInstanceId)
|
|
67
|
+
&& (!position.broker || contract.brokerId === position.broker)
|
|
68
|
+
));
|
|
69
|
+
if (matchingContract) return matchingContract;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return contracts[0] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function instrumentFromTicker(
|
|
77
|
+
ticker: TickerRecord | null | undefined,
|
|
78
|
+
fallbackSymbol?: string | null,
|
|
79
|
+
options: TickerInstrumentOptions = {},
|
|
80
|
+
): InstrumentRef | null {
|
|
43
81
|
const symbol = ticker?.metadata.ticker ?? fallbackSymbol ?? null;
|
|
44
82
|
if (!symbol) return null;
|
|
45
|
-
const instrument = ticker
|
|
83
|
+
const instrument = brokerContractForTicker(ticker, options);
|
|
46
84
|
return {
|
|
47
85
|
symbol,
|
|
48
86
|
exchange: ticker?.metadata.exchange ?? "",
|
|
@@ -56,8 +94,9 @@ export function quoteSubscriptionTargetFromTicker(
|
|
|
56
94
|
ticker: TickerRecord | null | undefined,
|
|
57
95
|
fallbackSymbol?: string | null,
|
|
58
96
|
route: QuoteSubscriptionTarget["route"] = "auto",
|
|
97
|
+
options: TickerInstrumentOptions = {},
|
|
59
98
|
): QuoteSubscriptionTarget | null {
|
|
60
|
-
const instrument = instrumentFromTicker(ticker, fallbackSymbol);
|
|
99
|
+
const instrument = instrumentFromTicker(ticker, fallbackSymbol, options);
|
|
61
100
|
return quoteSubscriptionTargetFromInstrument(instrument, route);
|
|
62
101
|
}
|
|
63
102
|
|
|
@@ -10,6 +10,7 @@ describe("pane settings helpers", () => {
|
|
|
10
10
|
|
|
11
11
|
expect(portfolioPane?.settings).toMatchObject({
|
|
12
12
|
collectionScope: "all",
|
|
13
|
+
viewMode: "table",
|
|
13
14
|
});
|
|
14
15
|
expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
15
16
|
expect(detailPane?.settings).toEqual({
|
|
@@ -8,22 +8,19 @@ export function useAccountManagementFooter({
|
|
|
8
8
|
draft,
|
|
9
9
|
hasSession,
|
|
10
10
|
message,
|
|
11
|
-
openPasswordDialog,
|
|
12
11
|
profile,
|
|
13
12
|
saveProfile,
|
|
14
13
|
}: {
|
|
15
|
-
busy: "profile" | "password" | null;
|
|
14
|
+
busy: "profile" | "password" | "alerts" | "billing" | "delete" | null;
|
|
16
15
|
draft: AccountDraft;
|
|
17
16
|
hasSession: boolean;
|
|
18
17
|
message: { tone: "info" | "success" | "error"; text: string } | null;
|
|
19
|
-
openPasswordDialog: () => void;
|
|
20
18
|
profile: AccountProfile | null;
|
|
21
19
|
saveProfile: () => Promise<void>;
|
|
22
20
|
}) {
|
|
23
21
|
const footerHints = useMemo<PaneHint[]>(() => [
|
|
24
22
|
{ id: "save", key: "Ctrl+S", label: "save", onPress: () => { void saveProfile(); }, disabled: !!busy || !hasSession },
|
|
25
|
-
|
|
26
|
-
], [busy, hasSession, openPasswordDialog, saveProfile]);
|
|
23
|
+
], [busy, hasSession, saveProfile]);
|
|
27
24
|
|
|
28
25
|
usePaneFooter("account-management", () => ({
|
|
29
26
|
info: [
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { TextField } from "../../../components";
|
|
3
|
-
import { Box, Text, TextAttributes } from "../../../ui";
|
|
4
|
-
import { colors
|
|
5
|
-
import type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { Button, Checkbox, TextField, type ChoiceDialogChoice } from "../../../components";
|
|
3
|
+
import { Box, Text, TextAttributes, useUiHost } from "../../../ui";
|
|
4
|
+
import { colors } from "../../../theme/colors";
|
|
5
|
+
import { NativeSelect, type NativeSelectElement } from "../../../components/ui/native-select";
|
|
6
|
+
import type { AccountFieldKey, ProfileAnalyticsPreview } from "./model";
|
|
6
7
|
import { truncate } from "./model";
|
|
7
8
|
|
|
8
9
|
export function AccountTextField({
|
|
@@ -31,14 +32,30 @@ export function AccountTextField({
|
|
|
31
32
|
onSubmit?: () => void;
|
|
32
33
|
}) {
|
|
33
34
|
const active = activeField === fieldKey;
|
|
35
|
+
const labelWidth = accountFieldLabelWidth(width);
|
|
36
|
+
const inputWidth = Math.max(8, width - labelWidth - 1);
|
|
37
|
+
const labelText = `${active ? "> " : " "}${label}`;
|
|
34
38
|
return (
|
|
35
|
-
<Box
|
|
39
|
+
<Box
|
|
40
|
+
height={1}
|
|
41
|
+
width={width}
|
|
42
|
+
flexDirection="row"
|
|
43
|
+
alignItems="center"
|
|
44
|
+
gap={1}
|
|
45
|
+
onMouseDown={() => onFocus(fieldKey)}
|
|
46
|
+
>
|
|
47
|
+
<Text
|
|
48
|
+
width={labelWidth}
|
|
49
|
+
fg={active ? colors.textBright : colors.textDim}
|
|
50
|
+
attributes={active ? TextAttributes.BOLD : 0}
|
|
51
|
+
>
|
|
52
|
+
{truncate(labelText, labelWidth)}
|
|
53
|
+
</Text>
|
|
36
54
|
<TextField
|
|
37
|
-
label={`${active ? "> " : " "}${label}`}
|
|
38
55
|
value={value}
|
|
39
56
|
placeholder={placeholder}
|
|
40
57
|
focused={focused && active}
|
|
41
|
-
width={
|
|
58
|
+
width={inputWidth}
|
|
42
59
|
type={type}
|
|
43
60
|
onChange={onChange}
|
|
44
61
|
onSubmit={onSubmit}
|
|
@@ -48,6 +65,11 @@ export function AccountTextField({
|
|
|
48
65
|
);
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
export function accountFieldLabelWidth(width: number) {
|
|
69
|
+
const preferred = width >= 28 ? 16 : Math.max(10, Math.floor(width * 0.42));
|
|
70
|
+
return Math.max(8, Math.min(preferred, width - 9));
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
export function FieldRow({
|
|
52
74
|
twoColumns,
|
|
53
75
|
children,
|
|
@@ -127,30 +149,244 @@ export function CheckboxRow({
|
|
|
127
149
|
onFocus: () => void;
|
|
128
150
|
onChange: (checked: boolean) => void;
|
|
129
151
|
}) {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
const labelText = `${active ? "> " : " "}${label}`;
|
|
153
|
+
const labelWidth = Math.min(
|
|
154
|
+
width - 9,
|
|
155
|
+
Math.max(accountFieldLabelWidth(width), Math.min(labelText.length, 34)),
|
|
156
|
+
);
|
|
157
|
+
const controlWidth = Math.max(8, width - labelWidth - 1);
|
|
134
158
|
return (
|
|
135
159
|
<Box
|
|
136
160
|
flexDirection="column"
|
|
137
|
-
|
|
138
|
-
onMouseOver={
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
onMouseOut={() => setHovered(false)}
|
|
143
|
-
onMouseDown={() => {
|
|
161
|
+
width={width}
|
|
162
|
+
onMouseOver={onFocus}
|
|
163
|
+
onMouseDown={(event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
164
|
+
event?.preventDefault?.();
|
|
165
|
+
event?.stopPropagation?.();
|
|
144
166
|
onFocus();
|
|
145
167
|
onChange(!checked);
|
|
146
168
|
}}
|
|
147
169
|
>
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
<Box height={1} flexDirection="row" alignItems="center" gap={1}>
|
|
171
|
+
<Text
|
|
172
|
+
width={labelWidth}
|
|
173
|
+
fg={active ? colors.textBright : colors.textDim}
|
|
174
|
+
attributes={active ? TextAttributes.BOLD : 0}
|
|
175
|
+
>
|
|
176
|
+
{truncate(labelText, labelWidth)}
|
|
177
|
+
</Text>
|
|
178
|
+
<Checkbox
|
|
179
|
+
label={label}
|
|
180
|
+
displayLabel={checked ? "On" : "Off"}
|
|
181
|
+
checked={checked}
|
|
182
|
+
active={false}
|
|
183
|
+
width={controlWidth}
|
|
184
|
+
variant="desktop"
|
|
185
|
+
onChange={(nextChecked) => {
|
|
186
|
+
onFocus();
|
|
187
|
+
onChange(nextChecked);
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
</Box>
|
|
151
191
|
{description ? (
|
|
152
|
-
<
|
|
153
|
-
{
|
|
192
|
+
<Box height={1} flexDirection="row" gap={1}>
|
|
193
|
+
<Box width={labelWidth} />
|
|
194
|
+
<Text fg={colors.textMuted} wrapText width={controlWidth}>
|
|
195
|
+
{description}
|
|
196
|
+
</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
) : null}
|
|
199
|
+
</Box>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function metricColor(tone: ProfileAnalyticsPreview["metrics"][number]["tone"]): string {
|
|
204
|
+
if (tone === "positive") return colors.positive;
|
|
205
|
+
if (tone === "negative") return colors.negative;
|
|
206
|
+
if (tone === "muted") return colors.textMuted;
|
|
207
|
+
return colors.text;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function AccountAnalyticsPreview({
|
|
211
|
+
preview,
|
|
212
|
+
width,
|
|
213
|
+
disclaimer,
|
|
214
|
+
surface = "panel",
|
|
215
|
+
}: {
|
|
216
|
+
preview: ProfileAnalyticsPreview;
|
|
217
|
+
width: number;
|
|
218
|
+
disclaimer?: string | null;
|
|
219
|
+
surface?: "panel" | "plain";
|
|
220
|
+
}) {
|
|
221
|
+
const horizontalPadding = surface === "panel" ? 1 : 0;
|
|
222
|
+
const contentWidth = Math.max(1, width - horizontalPadding * 2);
|
|
223
|
+
const metricWidth = Math.max(14, Math.min(28, Math.floor((contentWidth - 2) / 2)));
|
|
224
|
+
const subtitleWidth = Math.max(1, contentWidth - preview.title.length - 2);
|
|
225
|
+
const showHeader = surface === "panel";
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Box
|
|
229
|
+
flexDirection="column"
|
|
230
|
+
width={width}
|
|
231
|
+
backgroundColor={surface === "panel" ? colors.panel : undefined}
|
|
232
|
+
paddingX={horizontalPadding}
|
|
233
|
+
>
|
|
234
|
+
{showHeader ? (
|
|
235
|
+
<Box height={1} flexDirection="row">
|
|
236
|
+
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>
|
|
237
|
+
{truncate(preview.title, contentWidth)}
|
|
238
|
+
</Text>
|
|
239
|
+
{preview.subtitle ? (
|
|
240
|
+
<>
|
|
241
|
+
<Box flexGrow={1} />
|
|
242
|
+
<Text fg={colors.textMuted}>{truncate(preview.subtitle, subtitleWidth)}</Text>
|
|
243
|
+
</>
|
|
244
|
+
) : null}
|
|
245
|
+
</Box>
|
|
246
|
+
) : null}
|
|
247
|
+
{preview.metrics.length > 0 ? (
|
|
248
|
+
<Box flexDirection="row" flexWrap="wrap" gap={2}>
|
|
249
|
+
{preview.metrics.map((metric) => {
|
|
250
|
+
const labelWidth = Math.max(1, Math.min(metric.label.length, metricWidth - 2));
|
|
251
|
+
const valueWidth = Math.max(1, metricWidth - labelWidth - 1);
|
|
252
|
+
const detailWidth = Math.max(0, metricWidth - labelWidth - metric.value.length - 2);
|
|
253
|
+
return (
|
|
254
|
+
<Box key={metric.id} width={metricWidth} height={1} flexDirection="row" gap={1}>
|
|
255
|
+
<Text fg={colors.textDim}>{truncate(metric.label, labelWidth)}</Text>
|
|
256
|
+
<Text fg={metricColor(metric.tone)} attributes={TextAttributes.BOLD}>
|
|
257
|
+
{truncate(metric.value, valueWidth)}
|
|
258
|
+
</Text>
|
|
259
|
+
{metric.detail ? (
|
|
260
|
+
<Text fg={colors.textMuted}>
|
|
261
|
+
{truncate(metric.detail, detailWidth)}
|
|
262
|
+
</Text>
|
|
263
|
+
) : null}
|
|
264
|
+
</Box>
|
|
265
|
+
);
|
|
266
|
+
})}
|
|
267
|
+
</Box>
|
|
268
|
+
) : preview.subtitle ? (
|
|
269
|
+
<Text fg={colors.textMuted} wrapText width={contentWidth}>
|
|
270
|
+
{preview.subtitle}
|
|
271
|
+
</Text>
|
|
272
|
+
) : null}
|
|
273
|
+
{disclaimer ? (
|
|
274
|
+
<Text fg={colors.textMuted} wrapText width={contentWidth}>
|
|
275
|
+
{disclaimer}
|
|
276
|
+
</Text>
|
|
277
|
+
) : null}
|
|
278
|
+
</Box>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function PublicAnalyticsGroup({
|
|
283
|
+
preview,
|
|
284
|
+
choices,
|
|
285
|
+
value,
|
|
286
|
+
label,
|
|
287
|
+
detail,
|
|
288
|
+
active,
|
|
289
|
+
width,
|
|
290
|
+
disclaimer,
|
|
291
|
+
selectRef,
|
|
292
|
+
onFocus,
|
|
293
|
+
onSelect,
|
|
294
|
+
onOpen,
|
|
295
|
+
}: {
|
|
296
|
+
preview: ProfileAnalyticsPreview;
|
|
297
|
+
choices: ChoiceDialogChoice[];
|
|
298
|
+
value: string;
|
|
299
|
+
label: string;
|
|
300
|
+
detail?: string;
|
|
301
|
+
active: boolean;
|
|
302
|
+
width: number;
|
|
303
|
+
disclaimer?: string | null;
|
|
304
|
+
selectRef?: (element: NativeSelectElement | null) => void;
|
|
305
|
+
onFocus: () => void;
|
|
306
|
+
onSelect: (value: string) => void;
|
|
307
|
+
onOpen: () => void;
|
|
308
|
+
}) {
|
|
309
|
+
const isDesktop = useUiHost().kind === "desktop-web";
|
|
310
|
+
const contentWidth = Math.max(1, width - 2);
|
|
311
|
+
const labelText = active ? "> Public Stats:" : " Public Stats:";
|
|
312
|
+
const labelWidth = Math.min(accountFieldLabelWidth(width), Math.max(1, contentWidth));
|
|
313
|
+
const nativeButtonWidth = Math.max(14, Math.min(24, Math.floor(contentWidth * 0.3)));
|
|
314
|
+
const terminalMaxButtonWidth = Math.max(8, Math.min(24, contentWidth - labelWidth - 1));
|
|
315
|
+
const buttonLabel = truncate(label, Math.max(1, terminalMaxButtonWidth - 4));
|
|
316
|
+
const buttonWidth = buttonLabel.length + 4;
|
|
317
|
+
const layoutButtonWidth = isDesktop ? nativeButtonWidth : buttonWidth;
|
|
318
|
+
const nativeSelectWidth = nativeButtonWidth * 8;
|
|
319
|
+
const normalizedDetail = (detail ?? "").replace(/\.+$/, "");
|
|
320
|
+
const displayPreview = (
|
|
321
|
+
preview.metrics.length === 0
|
|
322
|
+
&& normalizedDetail
|
|
323
|
+
&& preview.subtitle.replace(/\.+$/, "") === normalizedDetail
|
|
324
|
+
) ? { ...preview, subtitle: "" } : preview;
|
|
325
|
+
const metrics = displayPreview.metrics.slice(0, 2);
|
|
326
|
+
const metricAreaWidth = Math.max(0, contentWidth - labelWidth - layoutButtonWidth - 2);
|
|
327
|
+
const metricWidth = metrics.length > 0 ? Math.max(8, Math.floor((metricAreaWidth - (metrics.length - 1)) / metrics.length)) : 0;
|
|
328
|
+
const detailWidth = Math.max(0, metricAreaWidth);
|
|
329
|
+
return (
|
|
330
|
+
<Box
|
|
331
|
+
flexDirection="column"
|
|
332
|
+
width={width}
|
|
333
|
+
onMouseOver={onFocus}
|
|
334
|
+
>
|
|
335
|
+
<Box height={isDesktop ? "24px" : 1} flexDirection="row" gap={1} alignItems="center">
|
|
336
|
+
<Text fg={active ? colors.textBright : colors.textDim} attributes={active ? TextAttributes.BOLD : 0}>
|
|
337
|
+
{truncate(labelText, labelWidth)}
|
|
338
|
+
</Text>
|
|
339
|
+
{isDesktop ? (
|
|
340
|
+
<NativeSelect
|
|
341
|
+
value={value}
|
|
342
|
+
options={choices.map((choice) => ({
|
|
343
|
+
value: choice.id,
|
|
344
|
+
label: choice.label,
|
|
345
|
+
disabled: choice.disabled,
|
|
346
|
+
}))}
|
|
347
|
+
width={nativeSelectWidth}
|
|
348
|
+
height={22}
|
|
349
|
+
selectRef={selectRef}
|
|
350
|
+
onFocus={onFocus}
|
|
351
|
+
onChange={onSelect}
|
|
352
|
+
/>
|
|
353
|
+
) : (
|
|
354
|
+
<Button
|
|
355
|
+
label={`${buttonLabel} v`}
|
|
356
|
+
variant="secondary"
|
|
357
|
+
width={buttonWidth}
|
|
358
|
+
active={active}
|
|
359
|
+
onPress={() => {
|
|
360
|
+
onFocus();
|
|
361
|
+
onOpen();
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
{metrics.length > 0 ? metrics.map((metric) => {
|
|
366
|
+
const labelTextWidth = Math.max(1, Math.min(metric.label.length, metricWidth - 2));
|
|
367
|
+
const valueWidth = Math.max(1, metricWidth - labelTextWidth - 1);
|
|
368
|
+
return (
|
|
369
|
+
<Box key={metric.id} width={metricWidth} height={1} flexDirection="row" gap={1}>
|
|
370
|
+
<Text fg={colors.textDim}>{truncate(metric.label, labelTextWidth)}</Text>
|
|
371
|
+
<Text fg={metricColor(metric.tone)} attributes={TextAttributes.BOLD}>
|
|
372
|
+
{truncate(metric.value, valueWidth)}
|
|
373
|
+
</Text>
|
|
374
|
+
</Box>
|
|
375
|
+
);
|
|
376
|
+
}) : detail ? (
|
|
377
|
+
<Text fg={colors.textMuted}>
|
|
378
|
+
{truncate(detail, detailWidth)}
|
|
379
|
+
</Text>
|
|
380
|
+
) : null}
|
|
381
|
+
</Box>
|
|
382
|
+
{metrics.length === 0 && displayPreview.subtitle ? (
|
|
383
|
+
<Text fg={colors.textMuted} wrapText width={contentWidth}>
|
|
384
|
+
{displayPreview.subtitle}
|
|
385
|
+
</Text>
|
|
386
|
+
) : null}
|
|
387
|
+
{disclaimer ? (
|
|
388
|
+
<Text fg={colors.textMuted} wrapText width={contentWidth}>
|
|
389
|
+
{disclaimer}
|
|
154
390
|
</Text>
|
|
155
391
|
) : null}
|
|
156
392
|
</Box>
|
|
@@ -10,8 +10,11 @@ export function useAccountManagementKeyboard({
|
|
|
10
10
|
focused,
|
|
11
11
|
openPasswordDialog,
|
|
12
12
|
openPortfolioDialog,
|
|
13
|
+
openUpgrade,
|
|
13
14
|
saveProfile,
|
|
14
15
|
setDraftValue,
|
|
16
|
+
deleteAccount,
|
|
17
|
+
turnOffEmailAlerts,
|
|
15
18
|
}: {
|
|
16
19
|
activeField: AccountFieldKey;
|
|
17
20
|
cycleField: (delta: number) => void;
|
|
@@ -20,8 +23,11 @@ export function useAccountManagementKeyboard({
|
|
|
20
23
|
focused: boolean;
|
|
21
24
|
openPasswordDialog: () => void;
|
|
22
25
|
openPortfolioDialog: () => Promise<void>;
|
|
26
|
+
openUpgrade: () => void;
|
|
23
27
|
saveProfile: () => Promise<void>;
|
|
24
28
|
setDraftValue: <K extends keyof AccountDraft>(key: K, value: AccountDraft[K]) => void;
|
|
29
|
+
deleteAccount: () => Promise<void>;
|
|
30
|
+
turnOffEmailAlerts: () => Promise<void>;
|
|
25
31
|
}) {
|
|
26
32
|
useShortcut((event) => {
|
|
27
33
|
if (!focused) return;
|
|
@@ -57,6 +63,18 @@ export function useAccountManagementKeyboard({
|
|
|
57
63
|
setDraftValue("acceptUnknownDms", !draftRef.current.acceptUnknownDms);
|
|
58
64
|
return;
|
|
59
65
|
}
|
|
66
|
+
if (!event.targetEditable && activeField === "weeklyRoundupEnabled" && isPlainKey(event, "space", "enter", "return")) {
|
|
67
|
+
event.preventDefault?.();
|
|
68
|
+
event.stopPropagation?.();
|
|
69
|
+
setDraftValue("weeklyRoundupEnabled", !draftRef.current.weeklyRoundupEnabled);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!event.targetEditable && activeField === "positionAlertsEnabled" && isPlainKey(event, "space", "enter", "return")) {
|
|
73
|
+
event.preventDefault?.();
|
|
74
|
+
event.stopPropagation?.();
|
|
75
|
+
setDraftValue("positionAlertsEnabled", !draftRef.current.positionAlertsEnabled);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
60
78
|
if (!event.targetEditable && activeField === "sharedPortfolioId" && isPlainKey(event, "left", "h", "[")) {
|
|
61
79
|
event.preventDefault?.();
|
|
62
80
|
event.stopPropagation?.();
|
|
@@ -81,10 +99,22 @@ export function useAccountManagementKeyboard({
|
|
|
81
99
|
openPasswordDialog();
|
|
82
100
|
return;
|
|
83
101
|
}
|
|
84
|
-
if (!event.targetEditable &&
|
|
102
|
+
if (!event.targetEditable && activeField === "upgradeAction" && isPlainKey(event, "space", "enter", "return")) {
|
|
85
103
|
event.preventDefault?.();
|
|
86
104
|
event.stopPropagation?.();
|
|
87
|
-
|
|
105
|
+
openUpgrade();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!event.targetEditable && activeField === "deleteAccountAction" && isPlainKey(event, "space", "enter", "return")) {
|
|
109
|
+
event.preventDefault?.();
|
|
110
|
+
event.stopPropagation?.();
|
|
111
|
+
void deleteAccount();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!event.targetEditable && activeField === "emailAlertsOffAction" && isPlainKey(event, "space", "enter", "return")) {
|
|
115
|
+
event.preventDefault?.();
|
|
116
|
+
event.stopPropagation?.();
|
|
117
|
+
void turnOffEmailAlerts();
|
|
88
118
|
}
|
|
89
119
|
}, { allowEditable: true });
|
|
90
120
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { Portfolio, TickerRecord } from "../../../types/ticker";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildPublishedProfileAnalyticsPreview,
|
|
5
|
+
buildPortfolioChoices,
|
|
6
|
+
buildProfileAnalyticsPreview,
|
|
7
|
+
computeCumulativeReturn,
|
|
8
|
+
countPortfolioHoldings,
|
|
9
|
+
getPortfolioPositionTickers,
|
|
10
|
+
NO_PORTFOLIO_VALUE,
|
|
11
|
+
} from "./model";
|
|
4
12
|
|
|
5
|
-
function makeTicker(symbol: string, portfolios: string[]): TickerRecord {
|
|
13
|
+
function makeTicker(symbol: string, portfolios: string[], positionedPortfolios: string[] = []): TickerRecord {
|
|
6
14
|
return {
|
|
7
15
|
metadata: {
|
|
8
16
|
ticker: symbol,
|
|
@@ -11,7 +19,13 @@ function makeTicker(symbol: string, portfolios: string[]): TickerRecord {
|
|
|
11
19
|
name: symbol,
|
|
12
20
|
portfolios,
|
|
13
21
|
watchlists: [],
|
|
14
|
-
positions:
|
|
22
|
+
positions: positionedPortfolios.map((portfolio) => ({
|
|
23
|
+
portfolio,
|
|
24
|
+
shares: 10,
|
|
25
|
+
avgCost: 90,
|
|
26
|
+
currency: "USD",
|
|
27
|
+
broker: "manual",
|
|
28
|
+
})),
|
|
15
29
|
custom: {},
|
|
16
30
|
tags: [],
|
|
17
31
|
},
|
|
@@ -47,8 +61,89 @@ describe("account management model", () => {
|
|
|
47
61
|
id: "main",
|
|
48
62
|
label: "Main Portfolio",
|
|
49
63
|
detail: "2 tickers",
|
|
50
|
-
description: "Shares this portfolio's
|
|
64
|
+
description: "Shares this portfolio's 1Y return and SPY Beta on your public profile.",
|
|
51
65
|
},
|
|
52
66
|
]);
|
|
53
67
|
});
|
|
68
|
+
|
|
69
|
+
test("filters shared analytics tickers by actual portfolio positions", () => {
|
|
70
|
+
const tickers = new Map([
|
|
71
|
+
["AAPL", makeTicker("AAPL", ["main"], ["main"])],
|
|
72
|
+
["MSFT", makeTicker("MSFT", ["main"], [])],
|
|
73
|
+
["NVDA", makeTicker("NVDA", ["other"], ["other"])],
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
expect(getPortfolioPositionTickers(tickers, "main").map((ticker) => ticker.metadata.ticker)).toEqual(["AAPL"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("compounds the selected one-year return series", () => {
|
|
80
|
+
expect(computeCumulativeReturn([
|
|
81
|
+
{ dateKey: "2025-06-01", value: 0.1 },
|
|
82
|
+
{ dateKey: "2025-06-02", value: 0.05 },
|
|
83
|
+
{ dateKey: "2025-06-03", value: -0.02 },
|
|
84
|
+
])).toBeCloseTo(0.1319, 5);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("builds a real public analytics preview snapshot", () => {
|
|
88
|
+
const portfolio: Portfolio = { id: "main", name: "Main Portfolio", currency: "USD" };
|
|
89
|
+
const ticker = makeTicker("AAPL", ["main"], ["main"]);
|
|
90
|
+
const preview = buildProfileAnalyticsPreview({
|
|
91
|
+
beta: 1.1,
|
|
92
|
+
portfolio,
|
|
93
|
+
portfolioTickers: [ticker],
|
|
94
|
+
selectedPortfolioId: "main",
|
|
95
|
+
oneYearReturn: 0.1,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(preview.status).toBe("ready");
|
|
99
|
+
expect(preview.subtitle).toBe("");
|
|
100
|
+
expect(preview.metrics).toHaveLength(2);
|
|
101
|
+
expect(preview.metrics[0]).toMatchObject({ label: "1Y", value: "+10.00%", tone: "positive" });
|
|
102
|
+
expect(preview.metrics[1]).toMatchObject({ label: "SPY Beta", value: "1.10" });
|
|
103
|
+
expect(preview.publicAnalytics).toEqual({
|
|
104
|
+
oneYearReturn: 0.1,
|
|
105
|
+
spyBeta: 1.1,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("builds the visible profile analytics card from published account data", () => {
|
|
110
|
+
const portfolio: Portfolio = { id: "main", name: "Main Portfolio", currency: "USD" };
|
|
111
|
+
const preview = buildPublishedProfileAnalyticsPreview({
|
|
112
|
+
analytics: { oneYearReturn: 0.15, spyBeta: 1.25 },
|
|
113
|
+
draftProfilePublic: true,
|
|
114
|
+
portfolio,
|
|
115
|
+
profileLoaded: true,
|
|
116
|
+
savedProfilePublic: true,
|
|
117
|
+
savedSharedPortfolioId: "main",
|
|
118
|
+
selectedPortfolioId: "main",
|
|
119
|
+
syncing: false,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(preview.status).toBe("ready");
|
|
123
|
+
expect(preview.title).toBe("Main Portfolio");
|
|
124
|
+
expect(preview.metrics).toEqual([
|
|
125
|
+
{ id: "one-year", label: "1Y", value: "+15.00%", tone: "positive" },
|
|
126
|
+
{ id: "beta", label: "SPY Beta", value: "1.25" },
|
|
127
|
+
]);
|
|
128
|
+
expect(preview.publicAnalytics).toEqual({ oneYearReturn: 0.15, spyBeta: 1.25 });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("does not show stale published analytics when the selected profile portfolio is unsaved", () => {
|
|
132
|
+
const portfolio: Portfolio = { id: "new", name: "New Portfolio", currency: "USD" };
|
|
133
|
+
const preview = buildPublishedProfileAnalyticsPreview({
|
|
134
|
+
analytics: { oneYearReturn: 0.15, spyBeta: 1.25 },
|
|
135
|
+
draftProfilePublic: true,
|
|
136
|
+
portfolio,
|
|
137
|
+
profileLoaded: true,
|
|
138
|
+
savedProfilePublic: true,
|
|
139
|
+
savedSharedPortfolioId: "old",
|
|
140
|
+
selectedPortfolioId: "new",
|
|
141
|
+
syncing: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(preview.status).toBe("pending");
|
|
145
|
+
expect(preview.metrics).toEqual([]);
|
|
146
|
+
expect(preview.subtitle).toBe("Save profile to update published metrics.");
|
|
147
|
+
expect(preview.publicAnalytics).toBeNull();
|
|
148
|
+
});
|
|
54
149
|
});
|