gloomberb 0.9.7 → 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.
Files changed (126) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/plugins/builtin/account-management/footer.ts +2 -5
  41. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  42. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  43. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  44. package/src/plugins/builtin/account-management/model.ts +263 -3
  45. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  46. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  47. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  48. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  49. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  50. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  51. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  52. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  53. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  54. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  55. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  56. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  57. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  58. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  59. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  60. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  61. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  62. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  63. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  64. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  65. package/src/plugins/builtin/correlation/index.tsx +4 -1
  66. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  67. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  68. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  69. package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
  70. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  71. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  72. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  73. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  74. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  75. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  76. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  77. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  80. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  81. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  82. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  83. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  84. package/src/plugins/registry/context.ts +15 -0
  85. package/src/plugins/registry/index.ts +31 -0
  86. package/src/renderers/electrobun/bun/index.ts +42 -0
  87. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  88. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  89. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  90. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  91. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  92. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  93. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  94. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  95. package/src/renderers/electrobun/view/main.tsx +3 -0
  96. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  97. package/src/sources/provider-router/batches.ts +11 -3
  98. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  99. package/src/sources/provider-router/financial-routes.ts +55 -5
  100. package/src/sources/provider-router/financials.test.ts +49 -0
  101. package/src/sources/provider-router/financials.ts +40 -3
  102. package/src/sources/provider-router/index.test.ts +147 -0
  103. package/src/sources/provider-router/primary.ts +17 -3
  104. package/src/sources/provider-router/streaming.ts +6 -1
  105. package/src/sources/sec-edgar.test.ts +42 -0
  106. package/src/sources/sec-edgar.ts +1 -2
  107. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  108. package/src/sources/yahoo-finance/mappers.ts +2 -1
  109. package/src/sources/yahoo-finance/requests.ts +2 -1
  110. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  111. package/src/sources/yahoo-finance.ts +6 -2
  112. package/src/state/hooks/quote-streaming.test.ts +14 -1
  113. package/src/state/hooks/quote-streaming.ts +5 -4
  114. package/src/sync/controller.ts +296 -0
  115. package/src/sync/core-contributors.test.ts +251 -0
  116. package/src/sync/core-contributors.ts +400 -0
  117. package/src/sync/profile-analytics.ts +39 -0
  118. package/src/sync/react.ts +53 -0
  119. package/src/sync/types.ts +77 -0
  120. package/src/types/desktop-deeplink.ts +7 -0
  121. package/src/types/plugin.ts +3 -0
  122. package/src/ui/host.tsx +14 -1
  123. package/src/ui/index.tsx +1 -0
  124. package/src/utils/exchanges.test.ts +11 -1
  125. package/src/utils/exchanges.ts +5 -1
  126. 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
- export function instrumentFromTicker(ticker: TickerRecord | null | undefined, fallbackSymbol?: string | null): InstrumentRef | null {
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?.metadata.broker_contracts?.[0] ?? null;
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
 
@@ -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
- { id: "password", key: "p", label: "assword", onPress: openPasswordDialog, disabled: !!busy || !hasSession },
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 { useState, type ReactNode } from "react";
2
- import { TextField } from "../../../components";
3
- import { Box, Text, TextAttributes } from "../../../ui";
4
- import { colors, hoverBg } from "../../../theme/colors";
5
- import type { AccountFieldKey } from "./model";
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 onMouseDown={() => onFocus(fieldKey)}>
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={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 [hovered, setHovered] = useState(false);
131
- const marker = checked ? "x" : " ";
132
- const fg = active ? colors.textBright : colors.text;
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
- backgroundColor={hovered ? hoverBg() : undefined}
138
- onMouseOver={() => {
139
- setHovered(true);
140
- onFocus();
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
- <Text fg={fg} attributes={active ? TextAttributes.BOLD : 0}>
149
- {`${active ? "> " : " "}[${marker}] ${label}`}
150
- </Text>
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
- <Text fg={colors.textMuted} wrapText width={Math.max(24, width - 2)}>
153
- {description}
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 && event.name === "p") {
102
+ if (!event.targetEditable && activeField === "upgradeAction" && isPlainKey(event, "space", "enter", "return")) {
85
103
  event.preventDefault?.();
86
104
  event.stopPropagation?.();
87
- openPasswordDialog();
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 { buildPortfolioChoices, countPortfolioHoldings, NO_PORTFOLIO_VALUE } from "./model";
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 YTD % and SPY Beta on your public profile.",
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
  });