gloomberb 0.9.7 → 0.9.9

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 (276) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.test.ts +3 -3
  4. package/src/api-client/index.ts +48 -6
  5. package/src/api-client/request.test.ts +63 -0
  6. package/src/api-client/request.ts +53 -5
  7. package/src/api-client/types.ts +45 -2
  8. package/src/app/pane-runtime/layout-placement.ts +0 -1
  9. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  10. package/src/app/runtime/desktop-deeplink.ts +496 -0
  11. package/src/app.tsx +34 -0
  12. package/src/brokers/account-cache.ts +2 -10
  13. package/src/brokers/broker-portfolio-sync.ts +0 -1
  14. package/src/brokers/broker-ticker-sync.ts +20 -16
  15. package/src/brokers/remote-broker-adapter.ts +3 -0
  16. package/src/capabilities/registry.test.ts +25 -0
  17. package/src/capabilities/registry.ts +22 -5
  18. package/src/cli/commands/automation.ts +1 -3
  19. package/src/cli/commands/command-utils.ts +0 -7
  20. package/src/cli/commands/overview.ts +2 -2
  21. package/src/cli/commands/plugins.ts +34 -17
  22. package/src/cli/index.ts +2 -2
  23. package/src/cli.test.ts +0 -157
  24. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  25. package/src/components/chart/bar-chart-renderer.ts +111 -55
  26. package/src/components/chart/comparison/renderer.ts +0 -1
  27. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  28. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  29. package/src/components/chart/core/index.test.ts +0 -24
  30. package/src/components/chart/core/renderer.ts +0 -4
  31. package/src/components/chart/core/resolution.test.ts +0 -17
  32. package/src/components/chart/core/resolution.ts +0 -26
  33. package/src/components/chart/core/scroll.test.ts +0 -6
  34. package/src/components/chart/core/scroll.ts +0 -22
  35. package/src/components/chart/core/viewport.test.ts +2 -23
  36. package/src/components/chart/core/viewport.ts +0 -27
  37. package/src/components/chart/market-session.ts +1 -1
  38. package/src/components/chart/native/surface/hooks.ts +10 -3
  39. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  40. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  41. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  42. package/src/components/chart/stock-chart/footer.ts +68 -13
  43. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  44. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  45. package/src/components/command-bar/helpers.test.ts +1 -80
  46. package/src/components/command-bar/helpers.ts +1 -36
  47. package/src/components/command-bar/pane-settings.ts +1 -1
  48. package/src/components/command-bar/pane-templates/items.ts +11 -3
  49. package/src/components/command-bar/panel/runtime.ts +0 -2
  50. package/src/components/command-bar/routes/root/results.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  52. package/src/components/command-bar/surface/index.test.tsx +52 -16
  53. package/src/components/command-bar/surface/index.tsx +0 -1
  54. package/src/components/command-bar/workflow/ops.ts +81 -20
  55. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  56. package/src/components/feed-data-table/stack-view.tsx +0 -1
  57. package/src/components/index.ts +1 -0
  58. package/src/components/layout/shell/index.test.tsx +2 -113
  59. package/src/components/layout/shell/index.tsx +0 -1
  60. package/src/components/layout/shell/menu.ts +0 -1
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  62. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  63. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  64. package/src/components/price-selector-dialog.tsx +3 -3
  65. package/src/components/ticker/badge/text.tsx +0 -1
  66. package/src/components/toggle-list.tsx +29 -73
  67. package/src/components/ui/button.tsx +4 -1
  68. package/src/components/ui/checkbox.tsx +76 -0
  69. package/src/components/ui/choice-dialog.tsx +15 -3
  70. package/src/components/ui/frame.tsx +1 -1
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/message-composer.tsx +4 -0
  73. package/src/components/ui/native-select.tsx +4 -2
  74. package/src/components/ui/page-stack-view.tsx +1 -1
  75. package/src/components/ui/ui.test.tsx +0 -78
  76. package/src/core/state/app/layout-reducer.ts +0 -1
  77. package/src/data/config/layout.ts +3 -3
  78. package/src/data/resource-store.ts +6 -0
  79. package/src/data/sqlite/cache.test.ts +5 -0
  80. package/src/data/ticker-store.ts +0 -7
  81. package/src/market-data/coordinator/auxiliary.ts +1 -44
  82. package/src/market-data/coordinator/entries.ts +0 -1
  83. package/src/market-data/coordinator/events.test.ts +49 -0
  84. package/src/market-data/coordinator/events.ts +25 -4
  85. package/src/market-data/coordinator/index.test.ts +223 -10
  86. package/src/market-data/coordinator/index.ts +41 -21
  87. package/src/market-data/coordinator/quotes.ts +13 -11
  88. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  89. package/src/market-data/hooks.tsx +17 -13
  90. package/src/market-data/market/freshness.ts +58 -1
  91. package/src/market-data/market/status.ts +19 -1
  92. package/src/market-data/query-store.ts +0 -4
  93. package/src/market-data/quotes/freshness.test.ts +49 -1
  94. package/src/market-data/quotes/freshness.ts +8 -0
  95. package/src/market-data/quotes/resolution.test.ts +185 -0
  96. package/src/market-data/quotes/resolution.ts +96 -10
  97. package/src/market-data/request-types.test.ts +61 -1
  98. package/src/market-data/request-types.ts +42 -8
  99. package/src/market-data/result-types.ts +0 -11
  100. package/src/market-data/selectors.ts +1 -5
  101. package/src/news/aggregator.ts +5 -1
  102. package/src/pane-settings.test.ts +0 -18
  103. package/src/plugins/builtin/account-management/footer.ts +2 -5
  104. package/src/plugins/builtin/account-management/form-components.tsx +175 -59
  105. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  106. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  107. package/src/plugins/builtin/account-management/model.ts +259 -23
  108. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  109. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  110. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  111. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  112. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  113. package/src/plugins/builtin/buildout/format.ts +2 -11
  114. package/src/plugins/builtin/chat/chat.test.tsx +127 -1
  115. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  116. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  117. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  118. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  119. package/src/plugins/builtin/chat/content/index.tsx +153 -16
  120. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  121. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  122. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  123. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  124. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  125. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  126. package/src/plugins/builtin/chat/content/transcript.tsx +0 -5
  127. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  128. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  129. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  131. package/src/plugins/builtin/chat/sidebar.test.tsx +83 -6
  132. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  133. package/src/plugins/builtin/chat/test-harness.tsx +22 -12
  134. package/src/plugins/builtin/cloud/plugin.tsx +8 -1
  135. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  136. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  137. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  138. package/src/plugins/builtin/correlation/index.tsx +4 -1
  139. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  140. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  141. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  142. package/src/plugins/builtin/earnings/index.tsx +0 -9
  143. package/src/plugins/builtin/econ/index.tsx +1 -5
  144. package/src/plugins/builtin/help/index.tsx +2 -2
  145. package/src/plugins/builtin/holders/pane.tsx +0 -5
  146. package/src/plugins/builtin/insider/index.tsx +1 -8
  147. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  148. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  149. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  150. package/src/plugins/builtin/news/index.tsx +0 -1
  151. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  152. package/src/plugins/builtin/options/view.test.tsx +1 -38
  153. package/src/plugins/builtin/plugin-groups.ts +17 -3
  154. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  155. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -25
  156. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  157. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  158. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  159. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  160. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  161. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  162. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  163. package/src/plugins/builtin/sec/index.tsx +0 -2
  164. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  165. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  166. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  167. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +42 -6
  168. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  169. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  170. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  171. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  172. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  173. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  174. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  175. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  176. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  177. package/src/plugins/builtin/world-indices/model.ts +0 -7
  178. package/src/plugins/ibkr/account-cache.ts +1 -1
  179. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  180. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  181. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  182. package/src/plugins/ibkr/trade/utils.ts +1 -7
  183. package/src/plugins/loader.ts +2 -1
  184. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  185. package/src/plugins/registry/context.ts +15 -0
  186. package/src/plugins/registry/contributions.ts +22 -11
  187. package/src/plugins/registry/index.test.ts +44 -0
  188. package/src/plugins/registry/index.ts +75 -26
  189. package/src/remote/controller.ts +1 -7
  190. package/src/remote/json-patch.ts +15 -1
  191. package/src/remote/server.ts +16 -6
  192. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  193. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  194. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  195. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  196. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  197. package/src/renderers/electrobun/bun/index.ts +42 -0
  198. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  199. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  201. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  202. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  203. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  204. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  205. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  206. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  207. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  208. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  209. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  210. package/src/renderers/electrobun/view/main.tsx +3 -0
  211. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  212. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  213. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  214. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  215. package/src/renderers/opentui/host.tsx +11 -3
  216. package/src/renderers/opentui/input-host.tsx +5 -1
  217. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  218. package/src/sources/gloomberb-cloud/index.ts +1 -30
  219. package/src/sources/gloomberb-cloud/news.ts +0 -12
  220. package/src/sources/provider-router/batches.ts +11 -3
  221. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  222. package/src/sources/provider-router/financial-routes.ts +77 -5
  223. package/src/sources/provider-router/financials.test.ts +49 -0
  224. package/src/sources/provider-router/financials.ts +40 -3
  225. package/src/sources/provider-router/index.test.ts +314 -0
  226. package/src/sources/provider-router/primary.ts +17 -3
  227. package/src/sources/provider-router/search.ts +5 -0
  228. package/src/sources/provider-router/streaming.ts +6 -1
  229. package/src/sources/provider-router/supplemental.ts +4 -3
  230. package/src/sources/sec-edgar.test.ts +42 -0
  231. package/src/sources/sec-edgar.ts +1 -2
  232. package/src/sources/yahoo-finance/history.ts +5 -1
  233. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  234. package/src/sources/yahoo-finance/mappers.ts +2 -1
  235. package/src/sources/yahoo-finance/options.ts +59 -14
  236. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  237. package/src/sources/yahoo-finance/requests.ts +2 -1
  238. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  239. package/src/sources/yahoo-finance.test.ts +40 -21
  240. package/src/sources/yahoo-finance.ts +19 -10
  241. package/src/state/app/context/index.test.tsx +0 -81
  242. package/src/state/app/context/selector.test.tsx +6 -2
  243. package/src/state/hooks/quote-streaming.test.ts +14 -1
  244. package/src/state/hooks/quote-streaming.ts +5 -4
  245. package/src/sync/controller.test.ts +31 -0
  246. package/src/sync/controller.ts +300 -0
  247. package/src/sync/core-contributors.test.ts +251 -0
  248. package/src/sync/core-contributors.ts +400 -0
  249. package/src/sync/profile-analytics.ts +39 -0
  250. package/src/sync/react.ts +53 -0
  251. package/src/sync/types.ts +77 -0
  252. package/src/test-support/data-provider.ts +0 -1
  253. package/src/types/desktop-deeplink.ts +7 -0
  254. package/src/types/plugin.ts +3 -0
  255. package/src/ui/host.tsx +15 -1
  256. package/src/ui/index.tsx +2 -0
  257. package/src/updater.test.ts +0 -21
  258. package/src/updater.ts +20 -16
  259. package/src/utils/async-deadline.ts +26 -0
  260. package/src/utils/exchanges.test.ts +11 -1
  261. package/src/utils/exchanges.ts +5 -1
  262. package/src/utils/selection-clipboard.ts +2 -2
  263. package/src/version.ts +1 -1
  264. package/src/components/layout/pane/header.test.tsx +0 -39
  265. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  266. package/src/plugins/builtin/chat/index.tsx +0 -4
  267. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  268. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  269. package/src/plugins/builtin/help/index.test.tsx +0 -242
  270. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  271. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  272. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  273. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  274. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  275. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  276. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -1,20 +1,33 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { Button, ChoiceDialog } from "../../../components";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Button, ChoiceDialog, ConfirmDialog, Tabs } from "../../../components";
3
3
  import { useAppSelector } from "../../../state/app/context";
4
- import { colors } from "../../../theme/colors";
4
+ import { useChartQueries, useFxRatesMap, useTickerFinancialsMap } from "../../../market-data/hooks";
5
+ import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
6
+ import { blendHex, colors } from "../../../theme/colors";
5
7
  import type { PaneProps } from "../../../types/plugin";
6
- import { Box, ScrollBox, Text, Textarea, TextAttributes, type TextareaRenderable } from "../../../ui";
8
+ import { Box, ScrollBox, Text, Textarea, TextAttributes, type TextareaRenderable, useRendererHost, useUiHost } from "../../../ui";
7
9
  import { useDialog, type AlertContext, type PromptContext } from "../../../ui/dialog";
10
+ import { openNativeSelect, type NativeSelectElement } from "../../../components/ui/native-select";
8
11
  import { apiClient, type AccountProfile } from "../../../api-client";
9
12
  import { chatController } from "../chat/controller";
10
13
  import { CloudAuthNotice } from "../cloud/auth-actions";
11
- import { AccountTextField, CheckboxRow, FieldRow, PickerRow } from "./form-components";
12
14
  import {
13
- BASE_FIELD_ORDER,
15
+ AccountTextField,
16
+ CheckboxRow,
17
+ FieldRow,
18
+ PublicAnalyticsGroup,
19
+ accountFieldLabelWidth,
20
+ } from "./form-components";
21
+ import {
14
22
  NO_PORTFOLIO_VALUE,
23
+ buildPublishedProfileAnalyticsPreview,
24
+ buildProfileAnalyticsPreview,
15
25
  buildPortfolioChoices,
26
+ computeCumulativeReturn,
16
27
  countPortfolioHoldings,
17
28
  emptyToNull,
29
+ formatPlan,
30
+ getPortfolioPositionTickers,
18
31
  portfolioOptionIds,
19
32
  profileToDraft,
20
33
  selectedPortfolioLabel,
@@ -24,11 +37,255 @@ import {
24
37
  import { PasswordChangeDialog } from "./password-dialog";
25
38
  import { useAccountManagementFooter } from "./footer";
26
39
  import { useAccountManagementKeyboard } from "./keyboard";
40
+ import { buildTrackedCurrencies } from "../analytics/sector-model";
41
+ import {
42
+ buildBenchmarkReturnSeries,
43
+ buildPortfolioChartTargets,
44
+ buildPortfolioReturnSeries,
45
+ } from "../analytics/pane-model";
46
+ import { computeDatedBeta } from "../analytics/metrics";
47
+ import { useCloudSyncStatus } from "../../../sync/react";
48
+ import { cloudSyncController } from "../../../sync/controller";
49
+ import { setSyncedProfileAnalytics } from "../../../sync/profile-analytics";
50
+
51
+ type AccountBusy = "profile" | "password" | "alerts" | "billing" | "delete" | null;
52
+ type AccountTab = "profile" | "emails" | "pro" | "advanced";
53
+
54
+ const CLOUD_UPGRADE_URL = "https://gloom.sh/cloud?upgrade=pro";
55
+
56
+ const ACCOUNT_TABS: Array<{ label: string; value: AccountTab }> = [
57
+ { label: "Profile", value: "profile" },
58
+ { label: "Emails", value: "emails" },
59
+ { label: "Pro", value: "pro" },
60
+ { label: "Advanced", value: "advanced" },
61
+ ];
62
+
63
+ const ACCOUNT_TAB_FIELD_ORDER: Record<AccountTab, AccountFieldKey[]> = {
64
+ profile: [
65
+ "profilePublic",
66
+ "acceptUnknownDms",
67
+ "username",
68
+ "name",
69
+ "company",
70
+ "title",
71
+ "publicEmail",
72
+ "xAccount",
73
+ "bio",
74
+ "sharedPortfolioId",
75
+ ],
76
+ emails: ["weeklyRoundupEnabled", "positionAlertsEnabled", "emailAlertsOffAction"],
77
+ pro: ["upgradeAction"],
78
+ advanced: ["passwordAction", "deleteAccountAction"],
79
+ };
80
+
81
+ const PLAN_COMPARISON_ROWS = [
82
+ { capability: "Market data", free: "Delayed", pro: "Real-time", proTone: "positive" },
83
+ { capability: "News", free: "12h delay", pro: "Real-time wire", proTone: "positive" },
84
+ { capability: "Cloud sync", free: "Included", pro: "Included", proTone: "neutral" },
85
+ { capability: "X data", free: "No", pro: "Included", proTone: "positive" },
86
+ { capability: "AI Screener", free: "No", pro: "Soon", proTone: "muted" },
87
+ ] as const;
88
+
89
+ function PlanComparison({
90
+ width,
91
+ activePlan,
92
+ upgradeButton,
93
+ }: {
94
+ width: number;
95
+ activePlan: "free" | "pro";
96
+ upgradeButton: ReactNode;
97
+ }) {
98
+ const isDesktop = useUiHost().kind === "desktop-web";
99
+ const comparisonWidth = Math.min(width, 58);
100
+ const capabilityWidth = Math.max(13, Math.min(16, Math.floor(comparisonWidth * 0.32)));
101
+ const valueWidth = Math.max(10, Math.floor((comparisonWidth - capabilityWidth - 2) / 2));
102
+ const rowWidth = capabilityWidth + valueWidth * 2 + 2;
103
+ const proCellBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.42 : 0.26);
104
+ const freeFg = activePlan === "free" ? colors.text : colors.textDim;
105
+ const proHeadingFg = activePlan === "pro" ? colors.textBright : colors.text;
106
+ const proValueColor = (tone: typeof PLAN_COMPARISON_ROWS[number]["proTone"]) => {
107
+ if (tone === "positive") return colors.positive;
108
+ if (tone === "muted") return colors.textMuted;
109
+ return colors.text;
110
+ };
111
+
112
+ if (isDesktop) {
113
+ const gridStyle: CSSProperties = {
114
+ display: "grid",
115
+ gridTemplateColumns: width >= 86
116
+ ? "minmax(220px, 1fr) minmax(120px, 0.42fr) minmax(300px, 1.05fr)"
117
+ : "minmax(150px, 0.95fr) minmax(86px, 0.42fr) minmax(180px, 1fr)",
118
+ columnGap: width >= 86 ? "clamp(30px, 5vw, 72px)" : "22px",
119
+ alignItems: "center",
120
+ width: "100%",
121
+ };
122
+ const desktopProBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.34 : 0.24);
123
+ const desktopProAltBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.39 : 0.28);
124
+ const desktopText = {
125
+ lineHeight: "22px",
126
+ fontSize: "15px",
127
+ } satisfies CSSProperties;
128
+ return (
129
+ <Box
130
+ flexDirection="column"
131
+ width="100%"
132
+ maxWidth={width >= 86 ? "980px" : "100%"}
133
+ style={{
134
+ marginTop: 18,
135
+ paddingLeft: width >= 86 ? 12 : 4,
136
+ paddingRight: width >= 86 ? 10 : 4,
137
+ }}
138
+ >
139
+ <Box
140
+ style={{
141
+ ...gridStyle,
142
+ marginBottom: 12,
143
+ }}
144
+ >
145
+ <Text fg={colors.textDim} style={{ ...desktopText, fontWeight: 650 }}>
146
+ Capability
147
+ </Text>
148
+ <Text
149
+ fg={activePlan === "free" ? colors.textBright : colors.textDim}
150
+ attributes={activePlan === "free" ? TextAttributes.BOLD : 0}
151
+ style={{ ...desktopText, fontWeight: activePlan === "free" ? 700 : 650 }}
152
+ >
153
+ Free
154
+ </Text>
155
+ <Box flexDirection="row" alignItems="baseline" gap={1}>
156
+ <Text
157
+ fg={colors.borderFocused}
158
+ attributes={TextAttributes.BOLD}
159
+ style={{ ...desktopText, fontWeight: 750 }}
160
+ >
161
+ Pro
162
+ </Text>
163
+ <Text fg={colors.textBright} style={desktopText}>
164
+ $49/mo
165
+ </Text>
166
+ </Box>
167
+ </Box>
168
+ {PLAN_COMPARISON_ROWS.map((row, index) => {
169
+ const first = index === 0;
170
+ const last = index === PLAN_COMPARISON_ROWS.length - 1;
171
+ return (
172
+ <Box
173
+ key={row.capability}
174
+ style={{
175
+ ...gridStyle,
176
+ minHeight: 48,
177
+ }}
178
+ >
179
+ <Box flexDirection="row" alignItems="center" style={{ minWidth: 0 }}>
180
+ <Box
181
+ aria-hidden="true"
182
+ style={{
183
+ width: 8,
184
+ height: 8,
185
+ borderRadius: 999,
186
+ marginRight: 18,
187
+ flexShrink: 0,
188
+ backgroundColor: colors.borderFocused,
189
+ boxShadow: `0 0 0 2px ${blendHex(colors.bg, colors.borderFocused, 0.14)}`,
190
+ }}
191
+ />
192
+ <Text fg={colors.textBright} style={{ ...desktopText, fontWeight: 520 }}>
193
+ {row.capability}
194
+ </Text>
195
+ </Box>
196
+ <Text fg={freeFg} style={desktopText}>
197
+ {row.free}
198
+ </Text>
199
+ <Box
200
+ flexDirection="row"
201
+ alignItems="center"
202
+ backgroundColor={index % 2 === 0 ? desktopProBg : desktopProAltBg}
203
+ style={{
204
+ minHeight: 48,
205
+ paddingLeft: width >= 86 ? 24 : 16,
206
+ paddingRight: width >= 86 ? 24 : 14,
207
+ borderTopLeftRadius: first ? 2 : 0,
208
+ borderTopRightRadius: first ? 2 : 0,
209
+ borderBottomLeftRadius: last ? 2 : 0,
210
+ borderBottomRightRadius: last ? 2 : 0,
211
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.025)",
212
+ }}
213
+ >
214
+ <Text
215
+ fg={proValueColor(row.proTone)}
216
+ attributes={row.proTone === "positive" ? TextAttributes.BOLD : 0}
217
+ style={{
218
+ ...desktopText,
219
+ fontWeight: row.proTone === "positive" ? 720 : 560,
220
+ }}
221
+ >
222
+ {row.pro}
223
+ </Text>
224
+ </Box>
225
+ </Box>
226
+ );
227
+ })}
228
+ <Box
229
+ style={{
230
+ ...gridStyle,
231
+ marginTop: 14,
232
+ }}
233
+ >
234
+ <Box />
235
+ <Box />
236
+ <Box flexDirection="row" justifyContent="center">
237
+ {upgradeButton}
238
+ </Box>
239
+ </Box>
240
+ </Box>
241
+ );
242
+ }
243
+
244
+ return (
245
+ <Box flexDirection="column" width={rowWidth}>
246
+ <Box height={1} flexDirection="row" gap={1}>
247
+ <Text width={capabilityWidth} fg={colors.textDim}>Capability</Text>
248
+ <Text width={valueWidth} fg={activePlan === "free" ? colors.textBright : colors.textDim} attributes={activePlan === "free" ? TextAttributes.BOLD : 0}>
249
+ Free
250
+ </Text>
251
+ <Box width={valueWidth} backgroundColor={proCellBg} paddingX={1}>
252
+ <Text fg={proHeadingFg} attributes={TextAttributes.BOLD}>
253
+ Pro $49/mo
254
+ </Text>
255
+ </Box>
256
+ </Box>
257
+ {PLAN_COMPARISON_ROWS.map((row) => (
258
+ <Box key={row.capability} height={1} flexDirection="row" gap={1}>
259
+ <Text width={capabilityWidth} fg={colors.textDim}>{row.capability}</Text>
260
+ <Text width={valueWidth} fg={freeFg}>{row.free}</Text>
261
+ <Box width={valueWidth} backgroundColor={proCellBg} paddingX={1}>
262
+ <Text fg={proValueColor(row.proTone)} attributes={row.proTone === "positive" ? TextAttributes.BOLD : 0}>
263
+ {row.pro}
264
+ </Text>
265
+ </Box>
266
+ </Box>
267
+ ))}
268
+ <Box height={1} flexDirection="row" gap={1}>
269
+ <Box width={capabilityWidth} />
270
+ <Box width={valueWidth} />
271
+ <Box width={valueWidth} flexDirection="row" paddingLeft={1}>
272
+ {upgradeButton}
273
+ </Box>
274
+ </Box>
275
+ </Box>
276
+ );
277
+ }
27
278
 
28
279
  export function AccountManagementPane({ focused, width, height }: PaneProps) {
29
280
  const dialog = useDialog();
30
- const portfolios = useAppSelector((state) => state.config.portfolios);
281
+ const renderer = useRendererHost();
282
+ const isDesktop = useUiHost().kind === "desktop-web";
283
+ const config = useAppSelector((state) => state.config);
284
+ const portfolios = config.portfolios;
285
+ const baseCurrency = config.baseCurrency;
31
286
  const tickers = useAppSelector((state) => state.tickers);
287
+ const cachedFinancials = useAppSelector((state) => state.financials);
288
+ const cachedExchangeRates = useAppSelector((state) => state.exchangeRates);
32
289
  const [sessionMarker, setSessionMarker] = useState(() => {
33
290
  const snapshot = chatController.getSnapshot();
34
291
  return `${apiClient.getSessionToken() ?? ""}:${snapshot.user?.id ?? ""}:${snapshot.user?.username ?? ""}`;
@@ -38,23 +295,163 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
38
295
  const [draft, setDraft] = useState<AccountDraft>(() => profileToDraft(null));
39
296
  const [activeField, setActiveField] = useState<AccountFieldKey>("username");
40
297
  const [message, setMessage] = useState<{ tone: "info" | "success" | "error"; text: string } | null>(null);
41
- const [busy, setBusy] = useState<"profile" | "password" | null>(null);
298
+ const [busy, setBusy] = useState<AccountBusy>(null);
299
+ const [activeTab, setActiveTab] = useState<AccountTab>("profile");
300
+ const syncStatus = useCloudSyncStatus();
42
301
  const bioRef = useRef<TextareaRenderable | null>(null);
302
+ const portfolioNativeSelectRef = useRef<NativeSelectElement | null>(null);
303
+ const refreshedSyncRevisionRef = useRef<number | null>(null);
43
304
  const draftRef = useRef(draft);
44
305
  draftRef.current = draft;
45
306
 
46
307
  const formWidth = Math.max(24, Math.min(70, width - 2));
308
+ const contentWidth = activeTab === "pro" && isDesktop ? Math.max(formWidth, width - 2) : formWidth;
47
309
  const twoColumns = formWidth >= 60;
48
310
  const fieldWidth = twoColumns ? Math.max(22, Math.floor((formWidth - 3) / 2)) : Math.max(18, Math.min(46, formWidth - 2));
49
- const fullFieldWidth = Math.max(18, Math.min(54, formWidth - 2));
311
+ const formLabelWidth = accountFieldLabelWidth(formWidth);
50
312
  const bodyHeight = Math.max(5, height);
51
- const fieldOrder = BASE_FIELD_ORDER;
313
+ const fieldOrder = ACCOUNT_TAB_FIELD_ORDER[activeTab];
52
314
 
53
315
  const portfolioHoldingCounts = useMemo(() => countPortfolioHoldings(tickers), [tickers]);
54
316
  const portfolioChoices = useMemo(
55
317
  () => buildPortfolioChoices(portfolios, portfolioHoldingCounts),
56
318
  [portfolioHoldingCounts, portfolios],
57
319
  );
320
+ const selectedAnalyticsPortfolio = useMemo(
321
+ () => portfolios.find((portfolio) => portfolio.id === draft.sharedPortfolioId) ?? null,
322
+ [draft.sharedPortfolioId, portfolios],
323
+ );
324
+ const portfolioTickers = useMemo(
325
+ () => draft.sharedPortfolioId ? getPortfolioPositionTickers(tickers, draft.sharedPortfolioId) : [],
326
+ [draft.sharedPortfolioId, tickers],
327
+ );
328
+ const marketFinancials = useTickerFinancialsMap(portfolioTickers);
329
+ const financials = useMemo(() => {
330
+ const merged = new Map(cachedFinancials);
331
+ for (const [symbol, data] of marketFinancials) {
332
+ merged.set(symbol, data);
333
+ }
334
+ return merged;
335
+ }, [cachedFinancials, marketFinancials]);
336
+ const trackedCurrencies = useMemo(
337
+ () => buildTrackedCurrencies(portfolioTickers, financials, baseCurrency),
338
+ [baseCurrency, financials, portfolioTickers],
339
+ );
340
+ const fetchedExchangeRates = useFxRatesMap(trackedCurrencies);
341
+ const effectiveExchangeRates = selectEffectiveExchangeRates(fetchedExchangeRates, cachedExchangeRates);
342
+ const chartTargets = useMemo(
343
+ () => buildPortfolioChartTargets(portfolioTickers),
344
+ [portfolioTickers],
345
+ );
346
+ const chartRequests = useMemo(
347
+ () => chartTargets.map((target) => target.request),
348
+ [chartTargets],
349
+ );
350
+ const chartEntries = useChartQueries(chartRequests);
351
+ const spyRequest = useMemo(
352
+ () => ({
353
+ instrument: { symbol: "SPY", exchange: "" },
354
+ bufferRange: "1Y" as const,
355
+ granularity: "range" as const,
356
+ }),
357
+ [],
358
+ );
359
+ const spyChartRequests = useMemo(
360
+ () => portfolioTickers.length > 0 ? [spyRequest] : [],
361
+ [portfolioTickers.length, spyRequest],
362
+ );
363
+ const spyChartEntries = useChartQueries(spyChartRequests);
364
+ const columnContext = useMemo(() => ({
365
+ activeTab: draft.sharedPortfolioId || undefined,
366
+ baseCurrency,
367
+ exchangeRates: effectiveExchangeRates,
368
+ now: Date.now(),
369
+ }), [baseCurrency, draft.sharedPortfolioId, effectiveExchangeRates]);
370
+ const portfolioReturnSeries = useMemo(
371
+ () => buildPortfolioReturnSeries({
372
+ chartTargets,
373
+ chartEntries,
374
+ financials,
375
+ columnContext,
376
+ }),
377
+ [chartEntries, chartTargets, columnContext, financials],
378
+ );
379
+ const spyReturnSeries = useMemo(
380
+ () => buildBenchmarkReturnSeries(spyRequest, spyChartEntries),
381
+ [spyChartEntries, spyRequest],
382
+ );
383
+ const oneYearReturn = useMemo(
384
+ () => portfolioReturnSeries ? computeCumulativeReturn(portfolioReturnSeries) : null,
385
+ [portfolioReturnSeries],
386
+ );
387
+ const beta = useMemo(
388
+ () => (portfolioReturnSeries && spyReturnSeries ? computeDatedBeta(portfolioReturnSeries, spyReturnSeries) : null),
389
+ [portfolioReturnSeries, spyReturnSeries],
390
+ );
391
+ const localAnalyticsPreview = useMemo(
392
+ () => buildProfileAnalyticsPreview({
393
+ beta,
394
+ portfolio: selectedAnalyticsPortfolio,
395
+ portfolioTickers,
396
+ selectedPortfolioId: draft.sharedPortfolioId,
397
+ oneYearReturn,
398
+ }),
399
+ [
400
+ beta,
401
+ draft.sharedPortfolioId,
402
+ portfolioTickers,
403
+ selectedAnalyticsPortfolio,
404
+ oneYearReturn,
405
+ ],
406
+ );
407
+ useEffect(() => {
408
+ const portfolioId = selectedAnalyticsPortfolio?.id;
409
+ if (!portfolioId) return;
410
+ const changed = setSyncedProfileAnalytics(portfolioId, localAnalyticsPreview.publicAnalytics);
411
+ if (changed) cloudSyncController.schedulePush("profile-analytics");
412
+ }, [
413
+ localAnalyticsPreview.publicAnalytics?.oneYearReturn,
414
+ localAnalyticsPreview.publicAnalytics?.spyBeta,
415
+ selectedAnalyticsPortfolio?.id,
416
+ ]);
417
+ const publicAnalyticsPreview = useMemo(
418
+ () => buildPublishedProfileAnalyticsPreview({
419
+ analytics: profile?.portfolioAnalytics ?? null,
420
+ draftProfilePublic: draft.profilePublic,
421
+ portfolio: selectedAnalyticsPortfolio,
422
+ profileLoaded: !!profile,
423
+ savedProfilePublic: profile?.profilePublic === true,
424
+ savedSharedPortfolioId: profile?.sharedPortfolioId ?? "",
425
+ selectedPortfolioId: draft.sharedPortfolioId,
426
+ syncing: syncStatus.phase === "syncing",
427
+ }),
428
+ [
429
+ draft.profilePublic,
430
+ draft.sharedPortfolioId,
431
+ profile,
432
+ selectedAnalyticsPortfolio,
433
+ syncStatus.phase,
434
+ ],
435
+ );
436
+ const profileAnalyticsDetail = useMemo(() => {
437
+ if (!draft.sharedPortfolioId) return "No public portfolio analytics";
438
+ if (!profile) return "Loading published metrics";
439
+ if (
440
+ draft.profilePublic !== profile.profilePublic
441
+ || draft.sharedPortfolioId !== (profile.sharedPortfolioId ?? "")
442
+ ) {
443
+ return "Save profile to update published metrics";
444
+ }
445
+ if (profile.profilePublic !== true) return "Public profile is off";
446
+ if (syncStatus.phase === "syncing") return "Syncing published metrics";
447
+ if (!profile.portfolioAnalytics) return "Waiting for published metrics";
448
+ return "Published public metrics";
449
+ }, [
450
+ draft.profilePublic,
451
+ draft.sharedPortfolioId,
452
+ profile,
453
+ syncStatus.phase,
454
+ ]);
58
455
 
59
456
  useEffect(() => {
60
457
  const unsubscribe = chatController.subscribe((snapshot) => {
@@ -90,9 +487,26 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
90
487
  void loadProfile();
91
488
  }, [loadProfile, sessionMarker]);
92
489
 
490
+ useEffect(() => {
491
+ if (!hasSession || !apiClient.getSessionToken()) return;
492
+ if (syncStatus.phase !== "synced" || syncStatus.revision == null) return;
493
+ if (refreshedSyncRevisionRef.current === syncStatus.revision) return;
494
+ refreshedSyncRevisionRef.current = syncStatus.revision;
495
+ let cancelled = false;
496
+ void (async () => {
497
+ const nextProfile = await apiClient.getAccountProfile().catch(() => null);
498
+ if (cancelled || !nextProfile) return;
499
+ setProfile(nextProfile);
500
+ await chatController.refreshSession().catch(() => {});
501
+ })();
502
+ return () => {
503
+ cancelled = true;
504
+ };
505
+ }, [hasSession, syncStatus.phase, syncStatus.revision]);
506
+
93
507
  useEffect(() => {
94
508
  if (!fieldOrder.includes(activeField)) {
95
- setActiveField("username");
509
+ setActiveField(fieldOrder[0] ?? "username");
96
510
  }
97
511
  }, [activeField, fieldOrder]);
98
512
 
@@ -100,6 +514,12 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
100
514
  setDraft((current) => ({ ...current, [key]: value }));
101
515
  }, []);
102
516
 
517
+ const selectTab = useCallback((tab: string) => {
518
+ const nextTab = tab as AccountTab;
519
+ setActiveTab(nextTab);
520
+ setActiveField(ACCOUNT_TAB_FIELD_ORDER[nextTab][0] ?? "username");
521
+ }, []);
522
+
103
523
  const openPasswordDialog = useCallback(() => {
104
524
  if (busy) return;
105
525
  setActiveField("passwordAction");
@@ -137,7 +557,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
137
557
  content: (context: PromptContext<string>) => (
138
558
  <ChoiceDialog
139
559
  {...context}
140
- title="Shared Portfolio"
560
+ title="Public Stats"
141
561
  choices={portfolioChoices}
142
562
  selectedChoiceId={currentPortfolioChoiceId}
143
563
  />
@@ -147,6 +567,15 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
147
567
  setDraftValue("sharedPortfolioId", selected === NO_PORTFOLIO_VALUE ? "" : selected);
148
568
  }, [dialog, portfolioChoices, setDraftValue]);
149
569
 
570
+ const openPortfolioPicker = useCallback(async () => {
571
+ setActiveField("sharedPortfolioId");
572
+ if (portfolioNativeSelectRef.current) {
573
+ openNativeSelect(portfolioNativeSelectRef.current);
574
+ return;
575
+ }
576
+ await openPortfolioDialog();
577
+ }, [openPortfolioDialog]);
578
+
150
579
  const cycleField = useCallback((delta: number) => {
151
580
  setActiveField((current) => {
152
581
  const index = fieldOrder.indexOf(current);
@@ -186,6 +615,8 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
186
615
  xAccount: emptyToNull(current.xAccount),
187
616
  sharedPortfolioId: emptyToNull(current.sharedPortfolioId),
188
617
  acceptUnknownDms: current.acceptUnknownDms,
618
+ weeklyRoundupEnabled: current.weeklyRoundupEnabled,
619
+ positionAlertsEnabled: current.positionAlertsEnabled,
189
620
  });
190
621
  setProfile(nextProfile);
191
622
  setDraft(profileToDraft(nextProfile));
@@ -201,12 +632,92 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
201
632
  }
202
633
  }, []);
203
634
 
635
+ const turnOffEmailAlerts = useCallback(async () => {
636
+ setActiveField("emailAlertsOffAction");
637
+ setBusy("alerts");
638
+ setMessage({ tone: "info", text: "Turning off email alerts..." });
639
+ try {
640
+ const settings = await apiClient.updateSyncSettings({
641
+ weeklyRoundupEnabled: false,
642
+ positionAlertsEnabled: false,
643
+ });
644
+ setDraft((current) => ({
645
+ ...current,
646
+ weeklyRoundupEnabled: settings.weeklyRoundupEnabled,
647
+ positionAlertsEnabled: settings.positionAlertsEnabled,
648
+ }));
649
+ const nextProfile = await apiClient.getAccountProfile().catch(() => null);
650
+ if (nextProfile) setProfile(nextProfile);
651
+ setMessage({ tone: "success", text: "Email alerts are off." });
652
+ } catch (error) {
653
+ setMessage({
654
+ tone: "error",
655
+ text: error instanceof Error ? error.message : "Failed to update alert settings.",
656
+ });
657
+ } finally {
658
+ setBusy(null);
659
+ }
660
+ }, []);
661
+
662
+ const openUpgrade = useCallback(() => {
663
+ setActiveField("upgradeAction");
664
+ setBusy("billing");
665
+ setMessage({ tone: "info", text: "Opening Pro upgrade..." });
666
+ void renderer.openExternal(CLOUD_UPGRADE_URL)
667
+ .then(() => setMessage(null))
668
+ .catch((error) => {
669
+ setMessage({
670
+ tone: "error",
671
+ text: error instanceof Error ? error.message : "Failed to open upgrade page.",
672
+ });
673
+ })
674
+ .finally(() => setBusy(null));
675
+ }, [renderer]);
676
+
677
+ const deleteAccount = useCallback(async () => {
678
+ setActiveField("deleteAccountAction");
679
+ if (busy) return;
680
+ const confirmed = await dialog.prompt<boolean>({
681
+ closeOnClickOutside: false,
682
+ content: (context: PromptContext<boolean>) => (
683
+ <ConfirmDialog
684
+ {...context}
685
+ title="Delete Account"
686
+ body={[
687
+ "Delete your Gloom Cloud account?",
688
+ "This removes cloud profile, chat, sync, and billing-linked account data.",
689
+ ]}
690
+ confirmLabel="Delete Account"
691
+ confirmVariant="danger"
692
+ />
693
+ ),
694
+ }).catch(() => false);
695
+ if (!confirmed) return;
696
+
697
+ setBusy("delete");
698
+ setMessage({ tone: "info", text: "Deleting account..." });
699
+ try {
700
+ await apiClient.deleteAccount();
701
+ setProfile(null);
702
+ setDraft(profileToDraft(null));
703
+ setHasSession(false);
704
+ await chatController.refreshSession().catch(() => {});
705
+ setMessage({ tone: "success", text: "Account deleted." });
706
+ } catch (error) {
707
+ setMessage({
708
+ tone: "error",
709
+ text: error instanceof Error ? error.message : "Failed to delete account.",
710
+ });
711
+ } finally {
712
+ setBusy(null);
713
+ }
714
+ }, [busy, dialog]);
715
+
204
716
  useAccountManagementFooter({
205
717
  busy,
206
718
  draft,
207
719
  hasSession,
208
720
  message,
209
- openPasswordDialog,
210
721
  profile,
211
722
  saveProfile,
212
723
  });
@@ -215,12 +726,15 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
215
726
  activeField,
216
727
  cycleField,
217
728
  cyclePortfolio,
729
+ deleteAccount,
218
730
  draftRef,
219
731
  focused,
220
732
  openPasswordDialog,
221
- openPortfolioDialog,
733
+ openPortfolioDialog: openPortfolioPicker,
734
+ openUpgrade,
222
735
  saveProfile,
223
736
  setDraftValue,
737
+ turnOffEmailAlerts,
224
738
  });
225
739
 
226
740
  if (!hasSession && !apiClient.getSessionToken()) {
@@ -232,149 +746,264 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
232
746
  }
233
747
 
234
748
  return (
235
- <Box flexDirection="column" width={width} height={height} paddingX={1}>
236
- <ScrollBox height={bodyHeight} scrollY focusable={false}>
237
- <Box flexDirection="column" width={formWidth} gap={1}>
238
- <FieldRow twoColumns={twoColumns}>
239
- <AccountTextField
240
- fieldKey="username"
241
- label="Username"
242
- value={draft.username}
243
- placeholder="username"
244
- activeField={activeField}
245
- focused={focused}
246
- width={fieldWidth}
247
- onFocus={setActiveField}
248
- onChange={(value) => setDraftValue("username", value)}
249
- onSubmit={() => { void saveProfile(); }}
250
- />
251
- <AccountTextField
252
- fieldKey="name"
253
- label="Full Name"
254
- value={draft.name}
255
- placeholder="Full name"
256
- activeField={activeField}
257
- focused={focused}
258
- width={fieldWidth}
259
- onFocus={setActiveField}
260
- onChange={(value) => setDraftValue("name", value)}
261
- onSubmit={() => { void saveProfile(); }}
262
- />
263
- </FieldRow>
264
-
265
- <FieldRow twoColumns={twoColumns}>
266
- <AccountTextField
267
- fieldKey="company"
268
- label="Company"
269
- value={draft.company}
270
- placeholder="Company"
271
- activeField={activeField}
272
- focused={focused}
273
- width={fieldWidth}
274
- onFocus={setActiveField}
275
- onChange={(value) => setDraftValue("company", value)}
276
- onSubmit={() => { void saveProfile(); }}
277
- />
278
- <AccountTextField
279
- fieldKey="title"
280
- label="Title"
281
- value={draft.title}
282
- placeholder="Title"
283
- activeField={activeField}
284
- focused={focused}
285
- width={fieldWidth}
286
- onFocus={setActiveField}
287
- onChange={(value) => setDraftValue("title", value)}
288
- onSubmit={() => { void saveProfile(); }}
289
- />
290
- </FieldRow>
291
-
292
- <FieldRow twoColumns={twoColumns}>
293
- <AccountTextField
294
- fieldKey="publicEmail"
295
- label="Public Email"
296
- value={draft.publicEmail}
297
- placeholder="public@example.com"
298
- activeField={activeField}
299
- focused={focused}
300
- width={fieldWidth}
301
- onFocus={setActiveField}
302
- onChange={(value) => setDraftValue("publicEmail", value)}
303
- onSubmit={() => { void saveProfile(); }}
304
- />
305
- <AccountTextField
306
- fieldKey="xAccount"
307
- label="X Account"
308
- value={draft.xAccount}
309
- placeholder="handle"
310
- activeField={activeField}
311
- focused={focused}
312
- width={fieldWidth}
313
- onFocus={setActiveField}
314
- onChange={(value) => setDraftValue("xAccount", value)}
315
- onSubmit={() => { void saveProfile(); }}
316
- />
317
- </FieldRow>
318
-
319
- <Box flexDirection="column" onMouseDown={() => setActiveField("bio")}>
320
- <Text fg={activeField === "bio" ? colors.textBright : colors.textDim} attributes={activeField === "bio" ? TextAttributes.BOLD : 0}>
321
- {activeField === "bio" ? "> Bio" : " Bio"}
322
- </Text>
323
- <Box height={3} width={fullFieldWidth} border borderColor={activeField === "bio" ? colors.borderFocused : colors.border} backgroundColor={colors.panel}>
324
- <Textarea
325
- key={`bio:${profile?.updatedAt ?? "empty"}`}
326
- ref={bioRef}
327
- initialValue={draft.bio}
328
- placeholder="Short profile bio"
329
- focused={focused && activeField === "bio"}
330
- textColor={colors.text}
331
- placeholderColor={colors.textDim}
332
- backgroundColor={colors.panel}
333
- flexGrow={1}
334
- wrapText
335
- onInput={(value: string) => setDraftValue("bio", value)}
749
+ <Box flexDirection="column" width={width} height={height} paddingX={1} gap={1}>
750
+ <Tabs
751
+ tabs={ACCOUNT_TABS}
752
+ activeValue={activeTab}
753
+ onSelect={selectTab}
754
+ focused={focused}
755
+ variant="pill"
756
+ compact
757
+ keyboardNavigation={false}
758
+ />
759
+ <ScrollBox height={Math.max(3, bodyHeight - 2)} scrollY focusable={false}>
760
+ <Box flexDirection="column" width={contentWidth} gap={1}>
761
+ {activeTab === "profile" ? (
762
+ <>
763
+ <FieldRow twoColumns={twoColumns}>
764
+ <CheckboxRow
765
+ label="Public Profile"
766
+ checked={draft.profilePublic}
767
+ active={activeField === "profilePublic"}
768
+ width={fieldWidth}
769
+ onFocus={() => setActiveField("profilePublic")}
770
+ onChange={(checked) => setDraftValue("profilePublic", checked)}
771
+ />
772
+ <CheckboxRow
773
+ label="Incoming DMs"
774
+ checked={draft.acceptUnknownDms}
775
+ active={activeField === "acceptUnknownDms"}
776
+ width={fieldWidth}
777
+ onFocus={() => setActiveField("acceptUnknownDms")}
778
+ onChange={(checked) => setDraftValue("acceptUnknownDms", checked)}
779
+ />
780
+ </FieldRow>
781
+
782
+ <FieldRow twoColumns={twoColumns}>
783
+ <AccountTextField
784
+ fieldKey="username"
785
+ label="Username"
786
+ value={draft.username}
787
+ placeholder="username"
788
+ activeField={activeField}
789
+ focused={focused}
790
+ width={fieldWidth}
791
+ onFocus={setActiveField}
792
+ onChange={(value) => setDraftValue("username", value)}
793
+ onSubmit={() => { void saveProfile(); }}
794
+ />
795
+ <AccountTextField
796
+ fieldKey="name"
797
+ label="Full Name"
798
+ value={draft.name}
799
+ placeholder="Full name"
800
+ activeField={activeField}
801
+ focused={focused}
802
+ width={fieldWidth}
803
+ onFocus={setActiveField}
804
+ onChange={(value) => setDraftValue("name", value)}
805
+ onSubmit={() => { void saveProfile(); }}
806
+ />
807
+ </FieldRow>
808
+
809
+ <FieldRow twoColumns={twoColumns}>
810
+ <AccountTextField
811
+ fieldKey="company"
812
+ label="Company"
813
+ value={draft.company}
814
+ placeholder="Company"
815
+ activeField={activeField}
816
+ focused={focused}
817
+ width={fieldWidth}
818
+ onFocus={setActiveField}
819
+ onChange={(value) => setDraftValue("company", value)}
820
+ onSubmit={() => { void saveProfile(); }}
821
+ />
822
+ <AccountTextField
823
+ fieldKey="title"
824
+ label="Title"
825
+ value={draft.title}
826
+ placeholder="Title"
827
+ activeField={activeField}
828
+ focused={focused}
829
+ width={fieldWidth}
830
+ onFocus={setActiveField}
831
+ onChange={(value) => setDraftValue("title", value)}
832
+ onSubmit={() => { void saveProfile(); }}
833
+ />
834
+ </FieldRow>
835
+
836
+ <FieldRow twoColumns={twoColumns}>
837
+ <AccountTextField
838
+ fieldKey="publicEmail"
839
+ label="Public Email"
840
+ value={draft.publicEmail}
841
+ placeholder="public@example.com"
842
+ activeField={activeField}
843
+ focused={focused}
844
+ width={fieldWidth}
845
+ onFocus={setActiveField}
846
+ onChange={(value) => setDraftValue("publicEmail", value)}
847
+ onSubmit={() => { void saveProfile(); }}
848
+ />
849
+ <AccountTextField
850
+ fieldKey="xAccount"
851
+ label="X Account"
852
+ value={draft.xAccount}
853
+ placeholder="handle"
854
+ activeField={activeField}
855
+ focused={focused}
856
+ width={fieldWidth}
857
+ onFocus={setActiveField}
858
+ onChange={(value) => setDraftValue("xAccount", value)}
859
+ onSubmit={() => { void saveProfile(); }}
860
+ />
861
+ </FieldRow>
862
+
863
+ <Box
864
+ flexDirection="row"
865
+ width={formWidth}
866
+ gap={1}
867
+ onMouseDown={() => setActiveField("bio")}
868
+ >
869
+ <Text
870
+ width={formLabelWidth}
871
+ fg={activeField === "bio" ? colors.textBright : colors.textDim}
872
+ attributes={activeField === "bio" ? TextAttributes.BOLD : 0}
873
+ >
874
+ {activeField === "bio" ? "> Bio" : " Bio"}
875
+ </Text>
876
+ <Box
877
+ height={3}
878
+ width={Math.max(18, formWidth - formLabelWidth - 1)}
879
+ border
880
+ borderColor={activeField === "bio" ? colors.borderFocused : colors.border}
881
+ backgroundColor={colors.panel}
882
+ style={{
883
+ borderRadius: 6,
884
+ overflow: "hidden",
885
+ }}
886
+ >
887
+ <Textarea
888
+ key={`bio:${profile?.updatedAt ?? "empty"}`}
889
+ ref={bioRef}
890
+ initialValue={draft.bio}
891
+ placeholder="Short profile bio"
892
+ focused={focused && activeField === "bio"}
893
+ textColor={colors.text}
894
+ placeholderColor={colors.textDim}
895
+ backgroundColor={colors.panel}
896
+ flexGrow={1}
897
+ wrapText
898
+ onInput={(value: string) => setDraftValue("bio", value)}
899
+ />
900
+ </Box>
901
+ </Box>
902
+
903
+ <PublicAnalyticsGroup
904
+ preview={publicAnalyticsPreview}
905
+ choices={portfolioChoices}
906
+ value={draft.sharedPortfolioId || NO_PORTFOLIO_VALUE}
907
+ label={selectedPortfolioLabel(portfolios, draft.sharedPortfolioId)}
908
+ detail={profileAnalyticsDetail}
909
+ active={activeField === "sharedPortfolioId"}
910
+ width={formWidth}
911
+ disclaimer={draft.sharedPortfolioId ? "Only 1Y return and SPY Beta are shared. Positions are not shared." : null}
912
+ selectRef={(element) => {
913
+ portfolioNativeSelectRef.current = element;
914
+ }}
915
+ onFocus={() => setActiveField("sharedPortfolioId")}
916
+ onSelect={(selected) => {
917
+ setActiveField("sharedPortfolioId");
918
+ setDraftValue("sharedPortfolioId", selected === NO_PORTFOLIO_VALUE ? "" : selected);
919
+ }}
920
+ onOpen={() => { void openPortfolioPicker(); }}
336
921
  />
337
- </Box>
338
- </Box>
339
922
 
340
- <FieldRow twoColumns={twoColumns}>
341
- <CheckboxRow
342
- label="Public Profile"
343
- checked={draft.profilePublic}
344
- active={activeField === "profilePublic"}
345
- width={fieldWidth}
346
- onFocus={() => setActiveField("profilePublic")}
347
- onChange={(checked) => setDraftValue("profilePublic", checked)}
348
- />
349
- <CheckboxRow
350
- label="Incoming DMs"
351
- checked={draft.acceptUnknownDms}
352
- active={activeField === "acceptUnknownDms"}
353
- width={fieldWidth}
354
- onFocus={() => setActiveField("acceptUnknownDms")}
355
- onChange={(checked) => setDraftValue("acceptUnknownDms", checked)}
356
- />
357
- </FieldRow>
358
-
359
- <PickerRow
360
- label="Profile Analytics"
361
- value={selectedPortfolioLabel(portfolios, draft.sharedPortfolioId)}
362
- detail={draft.sharedPortfolioId ? "Shares portfolio YTD % + SPY Beta" : "No public portfolio analytics"}
363
- active={activeField === "sharedPortfolioId"}
364
- width={formWidth}
365
- onFocus={() => setActiveField("sharedPortfolioId")}
366
- onOpen={() => { void openPortfolioDialog(); }}
367
- />
368
-
369
- <Box flexDirection="row" gap={1}>
370
- <Button label={busy === "profile" ? "Saving..." : "Save Profile"} variant="primary" onPress={() => { void saveProfile(); }} disabled={!!busy} />
371
- <Button
372
- label="Change Password"
373
- active={activeField === "passwordAction"}
374
- onPress={openPasswordDialog}
375
- disabled={!!busy}
376
- />
377
- </Box>
923
+ <Box flexDirection="row" gap={1}>
924
+ <Button label={busy === "profile" ? "Saving..." : "Save Profile"} variant="primary" onPress={() => { void saveProfile(); }} disabled={!!busy} />
925
+ </Box>
926
+ </>
927
+ ) : null}
928
+
929
+ {activeTab === "emails" ? (
930
+ <>
931
+ <CheckboxRow
932
+ label="Weekly Roundup"
933
+ checked={draft.weeklyRoundupEnabled}
934
+ active={activeField === "weeklyRoundupEnabled"}
935
+ description="Friday after market close."
936
+ width={formWidth}
937
+ onFocus={() => setActiveField("weeklyRoundupEnabled")}
938
+ onChange={(checked) => setDraftValue("weeklyRoundupEnabled", checked)}
939
+ />
940
+ <CheckboxRow
941
+ label="Smart Alerts"
942
+ checked={draft.positionAlertsEnabled}
943
+ active={activeField === "positionAlertsEnabled"}
944
+ description="Unusual portfolio or watchlist moves."
945
+ width={formWidth}
946
+ onFocus={() => setActiveField("positionAlertsEnabled")}
947
+ onChange={(checked) => setDraftValue("positionAlertsEnabled", checked)}
948
+ />
949
+ <Box flexDirection="row" gap={1} style={{ marginTop: 8 }}>
950
+ <Button
951
+ label={busy === "alerts" ? "Turning Off..." : "Turn Off Both"}
952
+ active={activeField === "emailAlertsOffAction"}
953
+ onPress={() => { void turnOffEmailAlerts(); }}
954
+ disabled={!!busy || (!draft.weeklyRoundupEnabled && !draft.positionAlertsEnabled)}
955
+ />
956
+ <Button label={busy === "profile" ? "Saving..." : "Save"} variant="primary" onPress={() => { void saveProfile(); }} disabled={!!busy} />
957
+ </Box>
958
+ </>
959
+ ) : null}
960
+
961
+ {activeTab === "pro" ? (
962
+ <>
963
+ <Box flexDirection="row" gap={1}>
964
+ <Text fg={colors.textDim}>Status</Text>
965
+ <Text fg={profile?.plan === "pro" ? colors.positive : colors.textBright} attributes={TextAttributes.BOLD}>
966
+ {formatPlan(profile?.plan)}
967
+ </Text>
968
+ {profile?.email ? <Text fg={colors.textMuted}>{profile.email}</Text> : null}
969
+ </Box>
970
+ <PlanComparison
971
+ width={contentWidth}
972
+ activePlan={profile?.plan === "pro" ? "pro" : "free"}
973
+ upgradeButton={(
974
+ <Button
975
+ label={profile?.plan === "pro" ? "Manage Pro" : busy === "billing" ? "Opening..." : "Upgrade to Pro"}
976
+ variant={profile?.plan === "pro" ? "secondary" : "primary"}
977
+ width={isDesktop ? 28 : undefined}
978
+ height={isDesktop ? "28px" : undefined}
979
+ active={activeField === "upgradeAction"}
980
+ onPress={openUpgrade}
981
+ disabled={!!busy}
982
+ />
983
+ )}
984
+ />
985
+ </>
986
+ ) : null}
987
+
988
+ {activeTab === "advanced" ? (
989
+ <>
990
+ <Box flexDirection="row" gap={1}>
991
+ <Button
992
+ label="Change Password"
993
+ active={activeField === "passwordAction"}
994
+ onPress={openPasswordDialog}
995
+ disabled={!!busy}
996
+ />
997
+ <Button
998
+ label={busy === "delete" ? "Deleting..." : "Delete Account"}
999
+ variant="danger"
1000
+ active={activeField === "deleteAccountAction"}
1001
+ onPress={() => { void deleteAccount(); }}
1002
+ disabled={!!busy}
1003
+ />
1004
+ </Box>
1005
+ </>
1006
+ ) : null}
378
1007
  <Box height={1} />
379
1008
  </Box>
380
1009
  </ScrollBox>