gloomberb 0.18.4 → 0.18.5

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 (282) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +32 -1
  5. package/src/account/auth-model.ts +7 -3
  6. package/src/api-client/auth.ts +9 -4
  7. package/src/api-client/catalysts.ts +2 -1
  8. package/src/api-client/data.ts +14 -0
  9. package/src/api-client/index.ts +160 -11
  10. package/src/api-client/live-scan.ts +91 -0
  11. package/src/api-client/members.ts +12 -0
  12. package/src/api-client/request.ts +222 -41
  13. package/src/api-client/research-activity.ts +3 -10
  14. package/src/api-client/types.ts +6 -0
  15. package/src/app/keybindings/index.ts +3 -0
  16. package/src/app/keybindings/labels.ts +18 -1
  17. package/src/app/keybindings/resolve.ts +14 -0
  18. package/src/app/runtime/broker-import.ts +43 -26
  19. package/src/architecture/core-builtin-allowlist.json +1 -1
  20. package/src/cli/commands/fred-series.ts +5 -0
  21. package/src/cli/commands/keybindings.ts +23 -0
  22. package/src/cli/commands/market.ts +68 -22
  23. package/src/cli/commands/options-chain.ts +59 -3
  24. package/src/cli/commands/overview.ts +119 -31
  25. package/src/cli/commands/search.ts +18 -2
  26. package/src/cli/commands/ticker.ts +2 -1
  27. package/src/cli/errors.ts +2 -2
  28. package/src/cli/export-source.ts +29 -1
  29. package/src/cli/glossary.ts +49 -0
  30. package/src/cli/history-rows.ts +30 -7
  31. package/src/cli/known-gaps.ts +5 -2
  32. package/src/cli/listing-arg.ts +16 -5
  33. package/src/cli/pane-functions/freshness.ts +25 -13
  34. package/src/cli/pane-functions/function-help.ts +10 -3
  35. package/src/cli/pane-functions/headless.ts +39 -5
  36. package/src/cli/pane-functions/index.ts +10 -3
  37. package/src/cli/pane-functions/market-session.ts +15 -5
  38. package/src/cli/pane-functions/report.ts +5 -1
  39. package/src/cli/pane-functions/resolver.ts +18 -2
  40. package/src/cli/quote-answer.ts +10 -4
  41. package/src/cli/report-tables.ts +18 -7
  42. package/src/cli/restore-plugins.ts +13 -1
  43. package/src/cli/result.ts +57 -5
  44. package/src/components/chart/composite/composite-chart.tsx +22 -4
  45. package/src/components/chart/composite/format.ts +27 -0
  46. package/src/components/chart/composite/levels.ts +2 -0
  47. package/src/components/chart/composite/panel-series.ts +1 -0
  48. package/src/components/chart/composite/panel-surface.tsx +9 -2
  49. package/src/components/chart/composite/range-statistics.ts +62 -0
  50. package/src/components/chart/composite/scene.ts +7 -3
  51. package/src/components/chart/composite/series-axis-tags.tsx +16 -9
  52. package/src/components/chart/composite/types.ts +4 -0
  53. package/src/components/command-bar/panel/prompt-binding.ts +0 -8
  54. package/src/components/command-bar/workflow/alert.ts +1 -1
  55. package/src/components/layout/header.tsx +26 -40
  56. package/src/components/layout/pane/footer/index.tsx +1 -0
  57. package/src/components/layout/pane/footer/model.ts +27 -0
  58. package/src/components/onboarding/onboarding-frame.tsx +1 -1
  59. package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
  60. package/src/components/ui/display.tsx +5 -1
  61. package/src/core/state/app/layout.ts +2 -0
  62. package/src/core/state/app/state.ts +9 -0
  63. package/src/core/state/app/types.ts +3 -0
  64. package/src/data/saved-copies.ts +55 -16
  65. package/src/docs/snapshot.json +361 -84
  66. package/src/i18n/es.ts +1 -1
  67. package/src/i18n/ja.ts +1 -1
  68. package/src/i18n/ko.ts +1 -1
  69. package/src/i18n/zh-cn.ts +1 -1
  70. package/src/i18n/zh-tw.ts +1 -1
  71. package/src/market-data/coordinator/financials.ts +10 -2
  72. package/src/market-data/coordinator/index.ts +3 -1
  73. package/src/market-data/coordinator/quotes.ts +4 -2
  74. package/src/market-data/market/change-basis.ts +33 -5
  75. package/src/market-data/market/dated-previous-close.ts +203 -0
  76. package/src/market-data/market/futures-week.ts +68 -0
  77. package/src/market-data/market/status.ts +16 -4
  78. package/src/market-data/options-alternatives.ts +60 -0
  79. package/src/market-data/quotes/contributions.ts +20 -2
  80. package/src/market-data/quotes/resolution.ts +3 -0
  81. package/src/market-data/selectors.ts +3 -0
  82. package/src/notifications/notification-log.ts +6 -4
  83. package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
  84. package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
  85. package/src/plugins/builtin/alerts/cli.ts +59 -20
  86. package/src/plugins/builtin/alerts/command.ts +7 -0
  87. package/src/plugins/builtin/alerts/constants.ts +1 -1
  88. package/src/plugins/builtin/alerts/fired-log.ts +45 -4
  89. package/src/plugins/builtin/alerts/format.ts +8 -2
  90. package/src/plugins/builtin/alerts/headless.ts +10 -5
  91. package/src/plugins/builtin/alerts/history-rows.ts +5 -2
  92. package/src/plugins/builtin/alerts/history.ts +2 -0
  93. package/src/plugins/builtin/alerts/index.tsx +66 -16
  94. package/src/plugins/builtin/alerts/live.ts +3 -3
  95. package/src/plugins/builtin/alerts/metric-command.ts +61 -0
  96. package/src/plugins/builtin/alerts/metric-data.ts +70 -0
  97. package/src/plugins/builtin/alerts/metric-model.ts +175 -0
  98. package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
  99. package/src/plugins/builtin/alerts/pane.tsx +42 -29
  100. package/src/plugins/builtin/alerts/storage.ts +33 -10
  101. package/src/plugins/builtin/alerts/types.ts +3 -0
  102. package/src/plugins/builtin/backtest/headless.ts +10 -5
  103. package/src/plugins/builtin/backtest/pane.tsx +9 -4
  104. package/src/plugins/builtin/backtest/presets.ts +29 -3
  105. package/src/plugins/builtin/broker-manager/index.tsx +3 -2
  106. package/src/plugins/builtin/broker-manager/model.ts +6 -1
  107. package/src/plugins/builtin/broker-trading/controller.ts +22 -12
  108. package/src/plugins/builtin/broker-trading/index.tsx +13 -1
  109. package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
  110. package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
  111. package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
  112. package/src/plugins/builtin/catalysts/model.ts +2 -1
  113. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
  114. package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
  115. package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
  116. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
  117. package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
  118. package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
  119. package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
  120. package/src/plugins/builtin/chart-composer/formula.ts +71 -18
  121. package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
  122. package/src/plugins/builtin/chart-composer/headless.ts +13 -2
  123. package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
  124. package/src/plugins/builtin/chart-composer/presets.ts +50 -15
  125. package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
  126. package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
  127. package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
  128. package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
  129. package/src/plugins/builtin/chat/content/composer.tsx +13 -6
  130. package/src/plugins/builtin/chat/content/footer.tsx +6 -3
  131. package/src/plugins/builtin/chat/content/index.tsx +50 -21
  132. package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
  133. package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
  134. package/src/plugins/builtin/chat/index.tsx +23 -0
  135. package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
  136. package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
  137. package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
  138. package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
  139. package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
  140. package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
  141. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
  142. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  143. package/src/plugins/builtin/econ/calendar-model.ts +14 -9
  144. package/src/plugins/builtin/econ/figures.ts +72 -0
  145. package/src/plugins/builtin/econ/index.tsx +33 -3
  146. package/src/plugins/builtin/econ/types.ts +2 -0
  147. package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
  148. package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
  149. package/src/plugins/builtin/econ-statistics/view.ts +3 -0
  150. package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
  151. package/src/plugins/builtin/exposure/charts.tsx +2 -2
  152. package/src/plugins/builtin/exposure/model.ts +13 -1
  153. package/src/plugins/builtin/exposure/pane.tsx +4 -4
  154. package/src/plugins/builtin/futures/index.tsx +1 -0
  155. package/src/plugins/builtin/futures/model.ts +2 -1
  156. package/src/plugins/builtin/futures/table.tsx +2 -2
  157. package/src/plugins/builtin/fx-matrix/client.ts +6 -1
  158. package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
  159. package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
  160. package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
  161. package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
  162. package/src/plugins/builtin/iv-history/headless.ts +3 -0
  163. package/src/plugins/builtin/macro-day/pane.tsx +7 -5
  164. package/src/plugins/builtin/market-movers/footer.ts +2 -2
  165. package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
  166. package/src/plugins/builtin/members/client.ts +19 -4
  167. package/src/plugins/builtin/members/headless.ts +20 -7
  168. package/src/plugins/builtin/members/index.tsx +9 -9
  169. package/src/plugins/builtin/members/model.ts +23 -4
  170. package/src/plugins/builtin/members/pane.tsx +25 -15
  171. package/src/plugins/builtin/options/analytics.ts +26 -2
  172. package/src/plugins/builtin/options/model-spot.ts +5 -2
  173. package/src/plugins/builtin/options/strike-window.ts +2 -1
  174. package/src/plugins/builtin/options/table.ts +1 -7
  175. package/src/plugins/builtin/options/view.tsx +25 -8
  176. package/src/plugins/builtin/options-positioning/client.ts +19 -0
  177. package/src/plugins/builtin/options-positioning/headless.ts +54 -14
  178. package/src/plugins/builtin/options-positioning/index.tsx +3 -3
  179. package/src/plugins/builtin/options-positioning/model.ts +47 -8
  180. package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
  181. package/src/plugins/builtin/perps/freshness.ts +12 -10
  182. package/src/plugins/builtin/perps/live-rows.ts +10 -3
  183. package/src/plugins/builtin/perps/pane.tsx +15 -11
  184. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
  185. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  186. package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
  187. package/src/plugins/builtin/rate-path/pane.tsx +2 -2
  188. package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
  189. package/src/plugins/builtin/scanner/feed.ts +2 -2
  190. package/src/plugins/builtin/scanner/index.ts +24 -1
  191. package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
  192. package/src/plugins/builtin/scanner/live-feed.ts +168 -0
  193. package/src/plugins/builtin/scanner/live-headless.ts +114 -0
  194. package/src/plugins/builtin/scanner/live-model.ts +351 -0
  195. package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
  196. package/src/plugins/builtin/scanner/live-track.ts +186 -0
  197. package/src/plugins/builtin/series-inventory.ts +1 -0
  198. package/src/plugins/builtin/shared/macro-releases.ts +65 -0
  199. package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
  200. package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
  201. package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
  202. package/src/plugins/builtin/themes/headless.ts +6 -4
  203. package/src/plugins/builtin/themes/model.ts +1 -1
  204. package/src/plugins/builtin/themes/pane.tsx +3 -3
  205. package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
  206. package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
  207. package/src/plugins/builtin/vol-surface/client.ts +6 -4
  208. package/src/plugins/builtin/vol-surface/headless.ts +62 -13
  209. package/src/plugins/builtin/vol-surface/index.tsx +2 -2
  210. package/src/plugins/builtin/vol-surface/model.ts +22 -7
  211. package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
  212. package/src/plugins/builtin/vol-surface/tables.ts +31 -3
  213. package/src/plugins/builtin/volatility/dvol.ts +46 -0
  214. package/src/plugins/builtin/volatility/pane.tsx +2 -2
  215. package/src/plugins/builtin/volatility/series.ts +26 -0
  216. package/src/plugins/builtin/world-indices/footer.ts +1 -1
  217. package/src/plugins/catalog-browser.ts +13 -8
  218. package/src/portfolio/column-values.ts +5 -5
  219. package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
  220. package/src/renderers/browser/app-services.ts +1 -7
  221. package/src/renderers/browser/config-host.ts +2 -55
  222. package/src/renderers/browser/main.tsx +7 -57
  223. package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
  224. package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
  225. package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
  226. package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
  227. package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
  228. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
  229. package/src/renderers/electrobun/bun/index.ts +25 -1
  230. package/src/renderers/electrobun/shared/app-name.ts +17 -0
  231. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
  232. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
  233. package/src/renderers/opentui/toast-host.tsx +71 -43
  234. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  235. package/src/sources/provider-router/batches.ts +10 -2
  236. package/src/sources/provider-router/financial-routes.ts +3 -3
  237. package/src/sources/provider-router/history.ts +28 -7
  238. package/src/sources/provider-router/index.ts +146 -10
  239. package/src/sources/provider-router/search.ts +13 -2
  240. package/src/state/app/context/index.tsx +3 -0
  241. package/src/sync/auth-transition.ts +19 -9
  242. package/src/sync/baseline.ts +10 -4
  243. package/src/sync/controller.ts +66 -16
  244. package/src/sync/react.ts +4 -1
  245. package/src/sync/types.ts +3 -0
  246. package/src/telemetry/usage-counts.ts +0 -18
  247. package/src/theme/colors.ts +8 -0
  248. package/src/tickers/company-name.ts +59 -0
  249. package/src/tickers/listing-venues.ts +23 -0
  250. package/src/tickers/search/bare-symbol.ts +36 -0
  251. package/src/tickers/search/index.ts +36 -7
  252. package/src/tickers/search/ranking.ts +10 -1
  253. package/src/tickers/search/types.ts +5 -0
  254. package/src/time-series/base-series.ts +13 -5
  255. package/src/time-series/daily-valuation.ts +62 -0
  256. package/src/time-series/field-catalog.ts +1 -3
  257. package/src/time-series/forward-valuation.ts +32 -0
  258. package/src/time-series/fundamentals.ts +155 -7
  259. package/src/time-series/hooks.ts +1 -0
  260. package/src/time-series/price-units.ts +72 -2
  261. package/src/time-series/resolve-context.ts +5 -1
  262. package/src/time-series/resolve.ts +61 -12
  263. package/src/time-series/series-presentation.ts +40 -6
  264. package/src/time-series/spec.ts +2 -1
  265. package/src/time-series/studies.ts +270 -72
  266. package/src/time-series/transforms.ts +1 -0
  267. package/src/time-series/types.ts +5 -0
  268. package/src/time-series/valuation-releases.ts +39 -0
  269. package/src/types/data-provider.ts +9 -1
  270. package/src/types/desktop-window.ts +2 -0
  271. package/src/types/financials.ts +41 -1
  272. package/src/types/headless.ts +42 -4
  273. package/src/types/instrument.ts +3 -0
  274. package/src/types/plugin.ts +1 -1
  275. package/src/updater/command-names.ts +12 -7
  276. package/src/utils/financial-statements.ts +6 -1
  277. package/src/utils/network-failure.ts +9 -6
  278. package/src/utils/option-expiry.ts +48 -1
  279. package/src/utils/options.ts +10 -0
  280. package/src/utils/plugin-proxy-hosts.json +1 -0
  281. package/src/utils/price-history.ts +5 -0
  282. package/src/renderers/browser/first-screen.ts +0 -193
@@ -0,0 +1,677 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { LiveScanCondition, LiveScanMatch, LiveScanPayload } from "../../../api-client/live-scan";
3
+ import {
4
+ Button,
5
+ confirmDialog,
6
+ DataTableView,
7
+ PageStackView,
8
+ QueryBar,
9
+ TextField,
10
+ useFieldRing,
11
+ usePaneFooter,
12
+ usePaneNoticeFooter,
13
+ usePaneTabs,
14
+ type DataTableCell,
15
+ type DataTableColumn,
16
+ type PaneFooterSegment,
17
+ type PaneHint,
18
+ } from "../../../components";
19
+ import { withCompanyLogo } from "../../../components/company-logo";
20
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
21
+ import { getTableWidth } from "../../../components/ui/table-layout";
22
+ import { useInputCapture, useShortcut } from "../../../public/react";
23
+ import { usePaneVisible } from "../../../state/app/activity";
24
+ import { useAppSelector, usePaneInstanceId, usePaneStateValue } from "../../../state/app/context";
25
+ import { colors, priceColor } from "../../../theme/colors";
26
+ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
27
+ import type { PaneProps } from "../../../types/plugin";
28
+ import { Box, TextAttributes, useUiCapabilities } from "../../../ui";
29
+ import { useDialog, useDialogState } from "../../../ui/dialog";
30
+ import { publicTickerKey } from "../../../utils/exchanges";
31
+ import { formatCompact, formatPercentRaw } from "../../../utils/format";
32
+ import { logFailure } from "../../../utils/log-failure";
33
+ import { usePluginAppActions, usePluginPaneState, usePluginTickerActions } from "../../runtime";
34
+ import { usePluginRenderContext } from "../../runtime/context";
35
+ import { recordFiredAlertFor } from "../alerts/fired-log";
36
+ import { useScannerStatusFooter } from "./feed";
37
+ import { formatHiloPrice } from "./hilo-model";
38
+ import { LiveConditionEditor } from "./live-condition-editor";
39
+ import { useLiveScanFeed } from "./live-feed";
40
+ import {
41
+ conditionColumnLabel,
42
+ conditionText,
43
+ conditionUnit,
44
+ DEFAULT_LIVE_SCAN,
45
+ floorValue,
46
+ formatMultiple,
47
+ formatScanTime,
48
+ LIVE_SCAN_MAX_CONDITIONS,
49
+ LIVE_SCAN_MAX_SAVED,
50
+ liveScanKey,
51
+ matchValue,
52
+ MIN_CAP_OPTIONS,
53
+ MIN_PRICE_OPTIONS,
54
+ MIN_VOLUME_OPTIONS,
55
+ parseFloorValue,
56
+ parseUniverseValue,
57
+ readLiveScan,
58
+ readSavedLiveScans,
59
+ toWireScan,
60
+ universeLabel,
61
+ universeOptions,
62
+ universeValue,
63
+ type LiveScan,
64
+ type SavedLiveScan,
65
+ } from "./live-model";
66
+ import {
67
+ liveScanAlertText,
68
+ liveScanFiredAlert,
69
+ liveScanRowNote,
70
+ LIVE_SCAN_HISTORY_LIMIT,
71
+ trackLiveScan,
72
+ type LiveScanRowNote,
73
+ type LiveScanTrack,
74
+ } from "./live-track";
75
+
76
+ type Tab = "matches" | "conditions" | "saved";
77
+ const TABS: Array<{ value: Tab; label: string }> = [
78
+ { value: "matches", label: "Matches" },
79
+ { value: "conditions", label: "Conditions" },
80
+ { value: "saved", label: "Saved" },
81
+ ];
82
+
83
+ interface MatchSort {
84
+ column: string | null;
85
+ direction: "asc" | "desc";
86
+ }
87
+ const RANKED: MatchSort = { column: null, direction: "desc" };
88
+
89
+ /** Columns beyond the scan's own figures, in the order they are given room. */
90
+ const OPTIONAL_COLUMNS: DataTableColumn[] = [
91
+ { id: "status", label: "STATUS", width: 13, align: "left" },
92
+ { id: "volume", label: "VOLUME", width: 8, align: "right" },
93
+ { id: "rvol", label: "RVOL", width: 6, align: "right" },
94
+ { id: "vwap", label: "VS VWAP", width: 8, align: "right" },
95
+ { id: "gap", label: "GAP", width: 7, align: "right" },
96
+ ];
97
+ const DISPLAY_ORDER = ["symbol", "name", "price", "change", "conditions", "rvol", "volume", "vwap", "gap", "status"];
98
+
99
+ /**
100
+ * Symbol, name, price and change, then one column per condition with the
101
+ * figure it matched on, then context as the width allows: a column is added
102
+ * only when it fits whole, the row's status first.
103
+ */
104
+ function liveScanColumns(width: number, conditions: readonly LiveScanCondition[]): DataTableColumn[] {
105
+ const own = conditions.map((condition, index): DataTableColumn => {
106
+ const label = conditionColumnLabel(condition);
107
+ return { id: `c:${index}`, label, width: Math.max(8, label.length), align: "right" };
108
+ });
109
+ const repeats = new Set(conditions.flatMap((condition) => (
110
+ condition.kind === "gap" ? ["gap"] : condition.kind === "volume_spike" && condition.window === "day" ? ["rvol"] : []
111
+ )));
112
+ const base: DataTableColumn[] = [
113
+ { id: "symbol", label: "SYMBOL", width: 7, align: "left" },
114
+ { id: "name", label: "NAME", width: 14, flexGrow: 1, align: "left" },
115
+ { id: "price", label: "PRICE", width: 9, align: "right" },
116
+ { id: "change", label: "CHG%", width: 8, align: "right" },
117
+ ];
118
+ const order = (columns: DataTableColumn[]) => [...columns].sort((a, b) => (
119
+ DISPLAY_ORDER.indexOf(a.id.startsWith("c:") ? "conditions" : a.id) - DISPLAY_ORDER.indexOf(b.id.startsWith("c:") ? "conditions" : b.id)
120
+ ));
121
+ let columns = order([...base, ...own]);
122
+ for (const column of OPTIONAL_COLUMNS) {
123
+ if (repeats.has(column.id)) continue;
124
+ const next = order([...columns, column]);
125
+ if (getTableWidth(next) > width) break;
126
+ columns = next;
127
+ }
128
+ return columns;
129
+ }
130
+
131
+ function noteText(note: LiveScanRowNote | null): string {
132
+ if (!note) return "";
133
+ if (note.kind === "new") return "new";
134
+ return `${note.kind} ${formatScanTime(note.at).slice(0, 5)}`;
135
+ }
136
+
137
+ function numberCell(value: number | null, format: (value: number) => string, color?: string): DataTableCell {
138
+ return value == null ? { text: "--", color: colors.textDim } : { text: format(value), value, ...(color ? { color } : {}) };
139
+ }
140
+
141
+ const signColor = (value: number | null) => (value == null ? undefined : priceColor(Math.abs(value) < 0.005 ? 0 : value, colors));
142
+
143
+ function sortValue(match: LiveScanMatch, column: string, note: LiveScanRowNote | null): number | string | null {
144
+ if (column.startsWith("c:")) return matchValue(match, Number(column.slice(2)));
145
+ switch (column) {
146
+ case "symbol":
147
+ return match.symbol;
148
+ case "name":
149
+ return match.name ?? match.symbol;
150
+ case "price":
151
+ return match.price;
152
+ case "change":
153
+ return match.changePercent;
154
+ case "volume":
155
+ return match.volume;
156
+ case "rvol":
157
+ return match.relativeVolume;
158
+ case "vwap":
159
+ return match.vwapPercent;
160
+ case "gap":
161
+ return match.gapPercent;
162
+ case "status":
163
+ return note ? (note.kind === "new" ? Number.MAX_SAFE_INTEGER : note.at) : null;
164
+ default:
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /** The matches in the column's order, missing figures last; Gloom Cloud's ranking with no column. */
170
+ function sortMatches(
171
+ matches: readonly LiveScanMatch[],
172
+ sort: MatchSort,
173
+ noteOf: (match: LiveScanMatch) => LiveScanRowNote | null,
174
+ ): LiveScanMatch[] {
175
+ const column = sort.column;
176
+ if (!column) return [...matches];
177
+ const sign = sort.direction === "asc" ? 1 : -1;
178
+ return matches
179
+ .map((match, index) => ({ match, index, value: sortValue(match, column, noteOf(match)) }))
180
+ .sort((a, b) => {
181
+ if (a.value == null || b.value == null) return a.value == null ? (b.value == null ? a.index - b.index : 1) : -1;
182
+ const order = typeof a.value === "string" || typeof b.value === "string"
183
+ ? String(a.value).localeCompare(String(b.value))
184
+ : a.value - b.value;
185
+ return order === 0 ? a.index - b.index : sign * order;
186
+ })
187
+ .map(({ match }) => match);
188
+ }
189
+
190
+ function emptyState(payload: LiveScanPayload | null): { title: string; hint?: string } {
191
+ if (payload?.status === "starting") return { title: "The scanner is starting.", hint: "Matches appear within a minute." };
192
+ if (payload?.status === "closed") return { title: "Nothing matched at the close.", hint: "The scan runs again at the open." };
193
+ return { title: "Nothing matches right now." };
194
+ }
195
+
196
+ function warmingNotice(payload: LiveScanPayload | null): string | null {
197
+ const windows = payload?.status === "live" || payload?.status === "degraded" ? payload.warmingWindows : [];
198
+ if (!windows?.length) return null;
199
+ const list = windows.map((window) => `${window} min`).join(", ");
200
+ return `The scanner started recently: conditions over ${list} match nothing until that much of the session has been watched.`;
201
+ }
202
+
203
+ function SaveScanForm({
204
+ name,
205
+ onName,
206
+ canCopy,
207
+ focused,
208
+ width,
209
+ onSave,
210
+ onCancel,
211
+ }: {
212
+ name: string;
213
+ onName: (name: string) => void;
214
+ canCopy: boolean;
215
+ focused: boolean;
216
+ width: number;
217
+ onSave: (copy: boolean) => void;
218
+ onCancel: () => void;
219
+ }) {
220
+ const ids = canCopy ? (["name", "save", "copy", "cancel"] as const) : (["name", "save", "cancel"] as const);
221
+ type Id = (typeof ids)[number];
222
+ const [active, setActive] = useState<Id>("name");
223
+ const dialogOpen = useDialogState((state) => state.isOpen);
224
+ const ring = useFieldRing<Id>({
225
+ ids,
226
+ activeId: active,
227
+ onActivate: setActive,
228
+ enabled: focused && !dialogOpen,
229
+ scope: "live-scan-save",
230
+ actions: { save: () => onSave(false), copy: () => onSave(true), cancel: onCancel },
231
+ });
232
+ useInputCapture(focused);
233
+ useShortcut((event) => {
234
+ if (event.name !== "escape") return;
235
+ event.preventDefault();
236
+ event.stopPropagation();
237
+ onCancel();
238
+ }, { enabled: focused && !dialogOpen, phase: "before", allowEditable: true, scope: "live-scan-save" });
239
+ const disabled = !name.trim();
240
+ return (
241
+ <Box paddingX={1} paddingTop={1} flexDirection="column" gap={1}>
242
+ <Box ref={ring.nodeRef("name")}>
243
+ <TextField
244
+ label="Scan name"
245
+ value={name}
246
+ onChange={onName}
247
+ onSubmit={() => onSave(false)}
248
+ focused={focused && active === "name"}
249
+ active={active === "name"}
250
+ onMouseDown={() => setActive("name")}
251
+ width={Math.max(20, Math.min(45, width - 4))}
252
+ />
253
+ </Box>
254
+ <Box flexDirection="row" gap={1}>
255
+ <Box ref={ring.nodeRef("save")}>
256
+ <Button label={canCopy ? "Update scan" : "Save scan"} variant="primary" active={focused && active === "save"} disabled={disabled} onPress={() => onSave(false)} />
257
+ </Box>
258
+ {canCopy ? (
259
+ <Box ref={ring.nodeRef("copy" as Id)}>
260
+ <Button label="Save copy" variant="secondary" active={focused && active === "copy"} disabled={disabled} onPress={() => onSave(true)} />
261
+ </Box>
262
+ ) : null}
263
+ <Box ref={ring.nodeRef("cancel")}>
264
+ <Button label="Cancel" variant="secondary" active={focused && active === "cancel"} onPress={onCancel} />
265
+ </Box>
266
+ </Box>
267
+ </Box>
268
+ );
269
+ }
270
+
271
+ export default function LiveScanPane({ focused, width, height }: PaneProps) {
272
+ const paneId = usePaneInstanceId();
273
+ const logos = useUiCapabilities().nativePaneChrome === true;
274
+ const dialog = useDialog();
275
+ const { notify, focusPane } = usePluginAppActions();
276
+ const { pinTicker } = usePluginTickerActions();
277
+ const { runtime } = usePluginRenderContext();
278
+ const watchlists = useAppSelector((state) => state.config.watchlists);
279
+ const tickers = useAppSelector((state) => state.tickers);
280
+
281
+ const [storedScan, setStoredScan] = usePluginPaneState<unknown>("scan", DEFAULT_LIVE_SCAN);
282
+ const scan = useMemo(() => readLiveScan(storedScan), [storedScan]);
283
+ const [storedSaved, setStoredSaved] = usePluginPaneState<unknown>("saved", []);
284
+ const saved = useMemo(() => readSavedLiveScans(storedSaved), [storedSaved]);
285
+ const [currentSavedId, setCurrentSavedId] = usePluginPaneState<string | null>("currentSaved", null);
286
+ const [alerts, setAlerts] = usePluginPaneState<boolean>("alerts", false);
287
+ const [tab, setTab] = usePluginPaneState<Tab>("tab", "matches");
288
+ const [sort, setSort] = usePluginPaneState<MatchSort>("sort", RANKED);
289
+ const [selectedSymbol, setSelectedSymbol] = usePluginPaneState<string | null>("selected", null);
290
+ const [selectedCondition, setSelectedCondition] = usePluginPaneState<string>("condition", "0");
291
+ const [selectedSavedId, setSelectedSavedId] = usePluginPaneState<string | null>("savedSelected", null);
292
+ // Publish the selection on this pane for linked panes, as HILO and FLOW do.
293
+ const [, setCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
294
+ const [editing, setEditing] = useState<number | null>(null);
295
+ const [saveForm, setSaveForm] = useState(false);
296
+ const [name, setName] = useState("");
297
+
298
+ const currentSaved = saved.find((entry) => entry.id === currentSavedId) ?? null;
299
+ const scanName = currentSaved?.name ?? "Live scan";
300
+ const wire = useMemo(() => toWireScan(scan, tickers.values()), [scan, tickers]);
301
+ const runnable = !(wire.definition.universe.kind === "symbols" && wire.definition.universe.symbols.length === 0);
302
+ const key = runnable ? liveScanKey(wire.definition) : "";
303
+ // Alerts keep the scan running while the pane is covered or the app is in the background.
304
+ const paneVisible = usePaneVisible();
305
+ const feed = useLiveScanFeed(runnable ? wire.definition : null, key, paneVisible || alerts);
306
+ const payload = feed.payload;
307
+ usePaneRefreshKey(feed.reload, { focused: focused && editing === null && !saveForm });
308
+
309
+ // Matches since the session began, and the alerts they raised.
310
+ const trackRef = useRef<LiveScanTrack | null>(null);
311
+ const [track, setTrack] = useState<LiveScanTrack | null>(null);
312
+ useEffect(() => {
313
+ if (!payload || !key) return;
314
+ const now = Date.now();
315
+ const step = trackLiveScan(trackRef.current, payload, key, { alerts, now });
316
+ if (step.track === trackRef.current) return;
317
+ trackRef.current = step.track;
318
+ setTrack(step.track);
319
+ const [first, ...rest] = step.alert;
320
+ if (!first) return;
321
+ const text = liveScanAlertText(scanName, step.alert, scan.conditions, payload.delayMinutes);
322
+ notify({
323
+ title: text.title,
324
+ body: text.body,
325
+ type: "info",
326
+ refId: `scan:${paneId}`,
327
+ desktop: step.loud ? "always" : "never",
328
+ ...(step.loud ? { sound: "Glass" } : {}),
329
+ action: { label: "Open", onClick: () => focusPane(paneId) },
330
+ });
331
+ const rule = `${scanName} · ${universeLabel(scan.universe, watchlists)}`;
332
+ recordFiredAlertFor(
333
+ runtime,
334
+ [liveScanFiredAlert([first, ...rest], {
335
+ ruleId: currentSaved?.id ?? paneId,
336
+ rule,
337
+ conditions: scan.conditions,
338
+ delayMinutes: payload.delayMinutes,
339
+ firedAt: now,
340
+ })],
341
+ logFailure("scanner.live", "Record live scan alert failed"),
342
+ LIVE_SCAN_HISTORY_LIMIT,
343
+ );
344
+ // Only a new answer is folded in; the scan and its name are read as they are then.
345
+ }, [payload]);
346
+
347
+ const noteOf = useCallback(
348
+ (match: LiveScanMatch) => (payload ? liveScanRowNote(match, payload, track) : null),
349
+ [payload, track],
350
+ );
351
+ const matches = useMemo(() => sortMatches(payload?.matches ?? [], sort, noteOf), [noteOf, payload?.matches, sort]);
352
+ const columns = useMemo(() => liveScanColumns(width, scan.conditions), [scan.conditions, width]);
353
+
354
+ const apply = (next: LiveScan) => {
355
+ setStoredScan(next);
356
+ setEditing(null);
357
+ };
358
+ const switchTab = (next: string) => {
359
+ setTab(next as Tab);
360
+ setEditing(null);
361
+ setSaveForm(false);
362
+ };
363
+ const startSave = () => {
364
+ setName(currentSaved?.name ?? "");
365
+ setSaveForm(true);
366
+ };
367
+ const save = (copy: boolean) => {
368
+ const trimmed = name.trim();
369
+ if (!trimmed) return;
370
+ const update = currentSaved && !copy;
371
+ const entry: SavedLiveScan = { id: update ? currentSaved.id : crypto.randomUUID(), name: trimmed, scan };
372
+ const next = update ? saved.map((row) => (row.id === entry.id ? entry : row)) : [...saved, entry];
373
+ if (next.length > LIVE_SCAN_MAX_SAVED) {
374
+ notify({ body: `Keep at most ${LIVE_SCAN_MAX_SAVED} saved scans; delete one first.`, type: "error" });
375
+ return;
376
+ }
377
+ setStoredSaved(next);
378
+ setCurrentSavedId(entry.id);
379
+ setSelectedSavedId(entry.id);
380
+ setSaveForm(false);
381
+ notify({ body: `Saved ${entry.name}`, type: "success" });
382
+ };
383
+ const selectedSaved = saved.find((entry) => entry.id === selectedSavedId) ?? saved[0] ?? null;
384
+ const removeSaved = async () => {
385
+ const entry = selectedSaved;
386
+ if (!entry) return;
387
+ const confirmed = await confirmDialog(dialog, {
388
+ closeOnClickOutside: false,
389
+ title: "Delete saved scan?",
390
+ body: [entry.name],
391
+ confirmLabel: "Delete",
392
+ });
393
+ if (!confirmed) return;
394
+ setStoredSaved(saved.filter((row) => row.id !== entry.id));
395
+ if (currentSavedId === entry.id) setCurrentSavedId(null);
396
+ };
397
+ const conditionIndex = Math.min(Number(selectedCondition) || 0, Math.max(0, scan.conditions.length - 1));
398
+ const saveHint: PaneHint = { id: "save", key: "s", label: "ave scan", onPress: startSave };
399
+ const hints: PaneHint[] = saveForm || editing !== null
400
+ ? []
401
+ : tab === "conditions"
402
+ ? [
403
+ { id: "add", key: "a", label: "dd condition", onPress: () => setEditing(-1), disabled: scan.conditions.length >= LIVE_SCAN_MAX_CONDITIONS },
404
+ {
405
+ id: "delete",
406
+ key: "d",
407
+ label: "elete condition",
408
+ onPress: () => apply({ ...scan, conditions: scan.conditions.filter((_, index) => index !== conditionIndex) }),
409
+ disabled: !scan.conditions.length,
410
+ },
411
+ saveHint,
412
+ ]
413
+ : tab === "saved"
414
+ ? [{ id: "delete", key: "d", label: "elete", onPress: () => void removeSaved(), disabled: !selectedSaved }]
415
+ : [saveHint];
416
+ usePaneFooter("scanner-live:hints", () => ({ hints }), [hints]);
417
+
418
+ const footerExtra = useMemo<PaneFooterSegment | null>(() => {
419
+ if (feed.error && payload) return { id: "scanner-live-refresh", parts: [{ text: "refresh failed", tone: "warning" }] };
420
+ if (payload?.asOf != null && payload.status !== "starting") {
421
+ return { id: "scanner-live-asof", parts: [{ text: `as of ${formatScanTime(payload.asOf)}`, tone: "muted" }] };
422
+ }
423
+ return null;
424
+ }, [feed.error, payload]);
425
+ useScannerStatusFooter("scanner-live", { payload, denied: false, deniedReason: null }, focused, footerExtra);
426
+ usePaneNoticeFooter({
427
+ registrationId: "scanner-live:notices",
428
+ focused,
429
+ notices: [
430
+ ...(payload?.warnings ?? []),
431
+ warmingNotice(payload),
432
+ payload && payload.matched > payload.matches.length
433
+ ? `${payload.matched.toLocaleString("en-US")} symbols match; the ${payload.matches.length} strongest are listed. Add a condition or raise a floor to narrow the scan.`
434
+ : null,
435
+ wire.skipped.length
436
+ ? `${wire.skipped.length} watchlist ${wire.skipped.length === 1 ? "row is" : "rows are"} not a US stock and ${wire.skipped.length === 1 ? "is" : "are"} left out: ${wire.skipped.slice(0, 8).join(", ")}${wire.skipped.length > 8 ? ", ..." : ""}`
437
+ : null,
438
+ runnable ? wire.problem : null,
439
+ feed.error && payload ? feed.error : null,
440
+ ].filter((notice): notice is string => !!notice),
441
+ });
442
+
443
+ const { strip: tabStrip, rows: tabRows } = usePaneTabs({
444
+ tabs: TABS,
445
+ activeValue: tab,
446
+ onSelect: switchTab,
447
+ focused: focused && !saveForm && editing === null,
448
+ dense: true,
449
+ });
450
+ const bodyHeight = Math.max(4, height - tabRows);
451
+
452
+ const renderMatchCell = useCallback((match: LiveScanMatch, column: DataTableColumn): DataTableCell => {
453
+ if (column.id.startsWith("c:")) {
454
+ const index = Number(column.id.slice(2));
455
+ const condition = scan.conditions[index];
456
+ const value = matchValue(match, index);
457
+ if (!condition) return { text: "" };
458
+ const unit = conditionUnit(condition);
459
+ if (unit === "percent") return numberCell(value, formatPercentRaw, signColor(value));
460
+ if (unit === "multiple") return numberCell(value, formatMultiple);
461
+ // An event's time, in the colour of where it left the price.
462
+ const tone = condition.kind === "new_high" ? colors.positive
463
+ : condition.kind === "new_low" ? colors.negative
464
+ : signColor(match.vwapPercent);
465
+ return value == null ? { text: "--", color: colors.textDim } : { text: formatScanTime(value), value: new Date(value).toISOString(), ...(tone ? { color: tone } : {}) };
466
+ }
467
+ switch (column.id) {
468
+ case "symbol":
469
+ return { text: match.symbol, attributes: TextAttributes.BOLD };
470
+ case "name":
471
+ return withCompanyLogo({ text: match.name ?? "--" }, { symbol: match.symbol, name: match.name ?? undefined }, logos);
472
+ case "price":
473
+ return { text: formatHiloPrice(match.price), value: match.price };
474
+ case "change":
475
+ return numberCell(match.changePercent, formatPercentRaw, signColor(match.changePercent));
476
+ case "volume":
477
+ return numberCell(match.volume, (value) => formatCompact(value));
478
+ case "rvol":
479
+ return numberCell(match.relativeVolume, formatMultiple);
480
+ case "vwap":
481
+ return numberCell(match.vwapPercent, formatPercentRaw, signColor(match.vwapPercent));
482
+ case "gap":
483
+ return numberCell(match.gapPercent, formatPercentRaw, signColor(match.gapPercent));
484
+ case "status": {
485
+ const note = noteOf(match);
486
+ if (!note) return { text: "" };
487
+ const color = note.kind === "stale" ? colors.warning : note.kind === "new" ? colors.positive : colors.text;
488
+ return { text: noteText(note), color, keepColorWhenSelected: note.kind === "stale" };
489
+ }
490
+ default:
491
+ return { text: "" };
492
+ }
493
+ }, [logos, noteOf, scan.conditions]);
494
+
495
+ const universeChoices = useMemo(() => universeOptions(watchlists), [watchlists]);
496
+ const queryBar = (
497
+ <QueryBar
498
+ width={width}
499
+ filters={[
500
+ {
501
+ id: "universe",
502
+ label: "Universe",
503
+ value: universeValue(scan.universe),
504
+ defaultValue: "all",
505
+ options: universeChoices.some((option) => option.value === universeValue(scan.universe))
506
+ ? universeChoices
507
+ : [...universeChoices, { value: universeValue(scan.universe), label: universeLabel(scan.universe, watchlists) }],
508
+ onChange: (value: string) => apply({ ...scan, universe: parseUniverseValue(value) }),
509
+ },
510
+ {
511
+ id: "min-price",
512
+ label: "Price",
513
+ value: floorValue(scan.minPrice),
514
+ defaultValue: floorValue(DEFAULT_LIVE_SCAN.minPrice),
515
+ options: MIN_PRICE_OPTIONS,
516
+ onChange: (value: string) => apply({ ...scan, minPrice: parseFloorValue(value) }),
517
+ },
518
+ {
519
+ id: "min-cap",
520
+ label: "Mkt cap",
521
+ value: floorValue(scan.minMarketCap),
522
+ defaultValue: floorValue(DEFAULT_LIVE_SCAN.minMarketCap),
523
+ options: MIN_CAP_OPTIONS,
524
+ onChange: (value: string) => apply({ ...scan, minMarketCap: parseFloorValue(value) }),
525
+ },
526
+ {
527
+ id: "min-volume",
528
+ label: "Volume",
529
+ value: floorValue(scan.minVolume),
530
+ defaultValue: floorValue(DEFAULT_LIVE_SCAN.minVolume),
531
+ options: MIN_VOLUME_OPTIONS,
532
+ onChange: (value: string) => apply({ ...scan, minVolume: parseFloorValue(value) }),
533
+ },
534
+ { kind: "toggle", id: "alerts", label: "Alerts", value: alerts, onChange: setAlerts },
535
+ ]}
536
+ />
537
+ );
538
+
539
+ const matchesBody = (
540
+ <DataTableView<LiveScanMatch>
541
+ focused={focused && !saveForm}
542
+ rootBefore={queryBar}
543
+ rootWidth={width}
544
+ rootHeight={bodyHeight}
545
+ columns={columns}
546
+ items={matches}
547
+ getItemKey={(match) => match.symbol}
548
+ selection={{
549
+ kind: "id",
550
+ selectedId: selectedSymbol,
551
+ getId: (match) => match.symbol,
552
+ onChange: (id) => {
553
+ setSelectedSymbol(id);
554
+ setCursorSymbol(id);
555
+ },
556
+ }}
557
+ // The listing on its US venue: a bare CRWD also names its TSX and Mexican lines.
558
+ onActivate={(match) => pinTicker(publicTickerKey(match.symbol, match.exchange ?? undefined), { floating: true, paneType: TICKER_RESEARCH_PANE_ID, instrument: null })}
559
+ renderCell={renderMatchCell}
560
+ selectedTextOverridesCellColor
561
+ sortColumnId={sort.column}
562
+ sortDirection={sort.direction}
563
+ onHeaderClick={(column) => setSort((current) => (
564
+ current.column !== column ? { column, direction: "desc" }
565
+ : current.direction === "desc" ? { column, direction: "asc" }
566
+ : RANKED
567
+ ))}
568
+ onSortChange={(column, direction) => setSort({ column, direction })}
569
+ status={
570
+ !runnable ? { empty: true, emptyTitle: wire.problem ?? "Nothing to scan." }
571
+ : !payload && feed.error ? { error: feed.error, subject: "live scan", retry: { onRetry: feed.reload } }
572
+ : !payload ? { loading: true, loadingLabel: "Waiting for the scanner..." }
573
+ : undefined
574
+ }
575
+ emptyStateTitle={emptyState(payload).title}
576
+ emptyStateHint={emptyState(payload).hint}
577
+ />
578
+ );
579
+
580
+ const editingCondition = editing !== null && editing >= 0 ? scan.conditions[editing] ?? null : null;
581
+ const conditionsBody = (
582
+ <PageStackView
583
+ focused={focused && !saveForm}
584
+ detailOpen={editing !== null}
585
+ onBack={() => setEditing(null)}
586
+ detailTitle={editingCondition ? conditionText(editingCondition) : "New condition"}
587
+ detailContent={editing !== null ? (
588
+ <LiveConditionEditor
589
+ key={editing}
590
+ value={editingCondition}
591
+ focused={focused}
592
+ width={width}
593
+ onCancel={() => setEditing(null)}
594
+ onSave={(condition) => {
595
+ const conditions = [...scan.conditions];
596
+ if (editing < 0) conditions.push(condition);
597
+ else conditions[editing] = condition;
598
+ apply({ ...scan, conditions });
599
+ }}
600
+ />
601
+ ) : null}
602
+ rootContent={(
603
+ <DataTableView<{ id: string; condition: LiveScanCondition }>
604
+ columns={[{ id: "condition", label: "CONDITION", width: 24, flexGrow: 1, align: "left" }]}
605
+ items={scan.conditions.map((condition, index) => ({ id: String(index), condition }))}
606
+ focused={focused && editing === null && !saveForm}
607
+ rootWidth={width}
608
+ rootHeight={bodyHeight}
609
+ getItemKey={(row) => row.id}
610
+ selection={{ kind: "id", selectedId: String(conditionIndex), getId: (row) => row.id, onChange: setSelectedCondition }}
611
+ onActivate={(row) => setEditing(Number(row.id))}
612
+ renderCell={(row) => ({ text: conditionText(row.condition) })}
613
+ sortColumnId={null}
614
+ sortDirection="asc"
615
+ emptyStateTitle="No conditions. Every stock above the floors matches, biggest movers first."
616
+ />
617
+ )}
618
+ />
619
+ );
620
+
621
+ const savedBody = (
622
+ <DataTableView<SavedLiveScan>
623
+ columns={[
624
+ { id: "name", label: "SCAN", width: 20, align: "left" },
625
+ { id: "universe", label: "UNIVERSE", width: 18, align: "left" },
626
+ { id: "conditions", label: "CONDITIONS", width: 20, flexGrow: 1, align: "left" },
627
+ ]}
628
+ items={saved}
629
+ focused={focused && !saveForm}
630
+ rootWidth={width}
631
+ rootHeight={bodyHeight}
632
+ getItemKey={(row) => row.id}
633
+ selection={{ kind: "id", selectedId: selectedSaved?.id ?? null, getId: (row) => row.id, onChange: setSelectedSavedId }}
634
+ onActivate={(row) => {
635
+ apply(row.scan);
636
+ setCurrentSavedId(row.id);
637
+ setTab("matches");
638
+ }}
639
+ renderCell={(row, column) => ({
640
+ text: column.id === "name"
641
+ ? row.name
642
+ : column.id === "universe"
643
+ ? universeLabel(row.scan.universe, watchlists)
644
+ : row.scan.conditions.map(conditionText).join("; ") || "Floors only",
645
+ ...(column.id === "name" && row.id === currentSavedId ? { attributes: TextAttributes.BOLD } : {}),
646
+ })}
647
+ sortColumnId={null}
648
+ sortDirection="asc"
649
+ emptyStateTitle="No saved scans."
650
+ emptyStateHint="Save the scan you are running from Matches or Conditions."
651
+ />
652
+ );
653
+
654
+ return (
655
+ <Box width={width} height={height} flexDirection="column">
656
+ {tabStrip}
657
+ <PageStackView
658
+ focused={focused}
659
+ detailOpen={saveForm}
660
+ onBack={() => setSaveForm(false)}
661
+ detailTitle="Save scan"
662
+ rootContent={tab === "conditions" ? conditionsBody : tab === "saved" ? savedBody : matchesBody}
663
+ detailContent={saveForm ? (
664
+ <SaveScanForm
665
+ name={name}
666
+ onName={setName}
667
+ canCopy={!!currentSaved}
668
+ focused={focused}
669
+ width={width}
670
+ onSave={save}
671
+ onCancel={() => setSaveForm(false)}
672
+ />
673
+ ) : null}
674
+ />
675
+ </Box>
676
+ );
677
+ }