gloomberb 0.10.0 → 0.10.2

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 (353) hide show
  1. package/package.json +8 -1
  2. package/src/api-client/auth.ts +1 -0
  3. package/src/api-client/index.test.ts +359 -108
  4. package/src/api-client/index.ts +24 -13
  5. package/src/api-client/socket.ts +70 -35
  6. package/src/api-client/types.ts +1 -1
  7. package/src/app/language-observer.test.tsx +82 -0
  8. package/src/app/language-observer.tsx +13 -0
  9. package/src/app/pane-runtime/index.tsx +2 -2
  10. package/src/app/pane-runtime/pane-settings-runtime.tsx +2 -2
  11. package/src/app/pane-runtime/pane-template-runtime.ts +2 -2
  12. package/src/app/pane-runtime/plugin-bindings.ts +2 -2
  13. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -2
  14. package/src/app/runtime/broker-import.ts +2 -2
  15. package/src/app/runtime/plugin-bindings.ts +2 -2
  16. package/src/app/runtime/startup.ts +2 -2
  17. package/src/app.tsx +16 -7
  18. package/src/brokers/account-cache.ts +6 -6
  19. package/src/brokers/broker-ticker-sync.ts +3 -3
  20. package/src/brokers/remote-broker-adapter.ts +2 -2
  21. package/src/brokers/sync-broker-instance.ts +5 -6
  22. package/src/cli/commands/api.ts +3 -6
  23. package/src/cli/commands/automation.ts +7 -15
  24. package/src/cli/commands/market.ts +26 -61
  25. package/src/cli/commands/overview.ts +34 -45
  26. package/src/cli/commands/search.ts +25 -27
  27. package/src/cli/commands/system.ts +27 -57
  28. package/src/cli/commands/ticker.ts +62 -63
  29. package/src/cli/context.test.ts +63 -0
  30. package/src/cli/context.ts +15 -1
  31. package/src/cli/index.ts +1 -1
  32. package/src/cli/pane-functions/data.ts +2 -19
  33. package/src/cli/pane-functions/index.ts +26 -25
  34. package/src/cli/pane-functions/report.ts +1 -1
  35. package/src/cli/pane-functions/screenshot.ts +3 -3
  36. package/src/cli/scoped-context.ts +41 -0
  37. package/src/components/chart/composite/composite-chart.test.tsx +615 -18
  38. package/src/components/chart/composite/composite-chart.tsx +179 -79
  39. package/src/components/chart/composite/format.test.ts +20 -7
  40. package/src/components/chart/composite/index.ts +0 -21
  41. package/src/components/chart/composite/interactions.test.ts +90 -3
  42. package/src/components/chart/composite/interactions.ts +189 -24
  43. package/src/components/chart/composite/price-series.ts +0 -2
  44. package/src/components/chart/composite/rasterizer.ts +10 -9
  45. package/src/components/chart/composite/renderers.test.ts +108 -2
  46. package/src/components/chart/composite/scene.test.ts +36 -0
  47. package/src/components/chart/composite/scene.ts +5 -2
  48. package/src/components/chart/composite/text-renderer.ts +37 -25
  49. package/src/components/chart/composite/time-axis.test.ts +243 -0
  50. package/src/components/chart/composite/time-axis.ts +608 -0
  51. package/src/components/chart/composite/types.ts +12 -1
  52. package/src/components/chart/core/data.ts +2 -159
  53. package/src/components/chart/core/date-window.ts +6 -225
  54. package/src/components/chart/core/palette.ts +1 -12
  55. package/src/components/chart/core/pointer.ts +0 -142
  56. package/src/components/chart/core/types.ts +1 -58
  57. package/src/components/chart/cursor-motion.ts +2 -4
  58. package/src/components/chart/indicators/types.ts +0 -9
  59. package/src/components/chart/market-session.ts +0 -11
  60. package/src/components/chart/native/kitty/protocol.ts +1 -1
  61. package/src/components/chart/native/raster/layout.ts +3 -10
  62. package/src/components/chart/static/chart/axis-overlays.test.tsx +64 -0
  63. package/src/components/chart/static/chart/axis-overlays.tsx +59 -14
  64. package/src/components/chart/static/chart/surface.tsx +1 -2
  65. package/src/components/command-bar/action-runtime.ts +2 -2
  66. package/src/components/command-bar/commands/direct/index.ts +1 -1
  67. package/src/components/command-bar/panel/body.tsx +2 -2
  68. package/src/components/command-bar/panel/index.tsx +11 -9
  69. package/src/components/command-bar/routes/ticker-search/actions.ts +2 -2
  70. package/src/components/command-bar/surface/index.tsx +2 -2
  71. package/src/components/command-bar/workflow/body.tsx +3 -3
  72. package/src/components/command-bar/workflow/collection-actions.ts +2 -2
  73. package/src/components/command-bar/workflow/coordinator.ts +2 -2
  74. package/src/components/command-bar/workflow/field-row.tsx +25 -11
  75. package/src/components/command-bar/workflow/tickers.ts +2 -2
  76. package/src/components/feed-data-table/stack-view.test.tsx +28 -36
  77. package/src/components/feed-data-table/stack-view.tsx +3 -1
  78. package/src/components/layout/detached-pane-shell.tsx +6 -6
  79. package/src/components/layout/header.tsx +13 -9
  80. package/src/components/layout/pane/content.test.tsx +42 -3
  81. package/src/components/layout/pane/content.tsx +3 -1
  82. package/src/components/layout/pane/footer/index.test.tsx +45 -2
  83. package/src/components/layout/pane/footer/registration.tsx +3 -1
  84. package/src/components/layout/shell/index.tsx +1 -2
  85. package/src/components/layout/shell/native/surfaces.ts +1 -1
  86. package/src/components/layout/shell/native/window-state.ts +1 -1
  87. package/src/components/layout/status-bar.tsx +12 -8
  88. package/src/components/layout/window-controls.tsx +8 -6
  89. package/src/components/onboarding/onboarding-wizard.tsx +3 -1
  90. package/src/components/pane-settings-dialog/desktop.tsx +0 -2
  91. package/src/components/pane-settings-dialog.tsx +0 -2
  92. package/src/components/pane-template-wizard.tsx +10 -9
  93. package/src/components/price-performance.tsx +2 -113
  94. package/src/components/price-sparkline/model.ts +15 -17
  95. package/src/components/price-sparkline/view.tsx +8 -7
  96. package/src/components/speedometer-gauge/desktop.tsx +2 -1
  97. package/src/components/speedometer-gauge/index.tsx +0 -2
  98. package/src/components/speedometer-gauge/terminal.tsx +24 -5
  99. package/src/components/ticker/badge/index.tsx +1 -18
  100. package/src/components/ui/data-table/opentui/index.tsx +3 -3
  101. package/src/components/ui/frame.tsx +1 -2
  102. package/src/components/ui/page-stack-view.test.tsx +126 -0
  103. package/src/components/ui/page-stack-view.tsx +24 -12
  104. package/src/components/ui/tabs.tsx +7 -3
  105. package/src/components/ui/ui.test.tsx +23 -2
  106. package/src/components/use-external-link-footer.ts +4 -2
  107. package/src/core/app-service-ports.ts +46 -0
  108. package/src/core/app-services.test.ts +66 -0
  109. package/src/core/app-services.ts +17 -15
  110. package/src/data/config/chart-settings.ts +3 -10
  111. package/src/data/config/store/index.test.ts +99 -2
  112. package/src/data/config/store/migrations.ts +278 -0
  113. package/src/data/config/store/normalize.ts +44 -186
  114. package/src/data/resource-store.ts +73 -12
  115. package/src/data/sqlite/cache.test.ts +104 -15
  116. package/src/i18n/index.test.ts +17 -0
  117. package/src/i18n/index.ts +23 -6
  118. package/src/i18n/ja.ts +13 -0
  119. package/src/i18n/ko.ts +13 -0
  120. package/src/i18n/react.ts +6 -0
  121. package/src/i18n/zh-cn.ts +13 -0
  122. package/src/i18n/zh-tw.ts +13 -0
  123. package/src/market-data/coordinator/auxiliary.ts +7 -3
  124. package/src/market-data/coordinator/chart.ts +1 -1
  125. package/src/market-data/coordinator/index.test.ts +26 -0
  126. package/src/market-data/coordinator/index.ts +11 -5
  127. package/src/market-data/coordinator/quotes.ts +4 -40
  128. package/src/market-data/coordinator/subscriptions.test.ts +89 -1
  129. package/src/market-data/hooks.tsx +47 -1
  130. package/src/market-data/market/status.ts +6 -6
  131. package/src/market-data/performance.ts +1 -1
  132. package/src/market-data/quote-subscription-target.test.ts +37 -0
  133. package/src/market-data/quote-subscription-target.ts +60 -0
  134. package/src/market-data/quotes/freshness.ts +1 -0
  135. package/src/market-data/quotes/resolution.ts +4 -0
  136. package/src/market-data/request-types.ts +3 -3
  137. package/src/news/aggregator.test.ts +97 -1
  138. package/src/news/aggregator.ts +100 -57
  139. package/src/news/hooks.test.tsx +64 -0
  140. package/src/news/hooks.ts +18 -25
  141. package/src/plugins/builtin/account-management/footer.ts +4 -2
  142. package/src/plugins/builtin/account-management/pane.tsx +9 -4
  143. package/src/plugins/builtin/ai/agent-history.ts +11 -7
  144. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +0 -5
  145. package/src/plugins/builtin/ai/ask-ai.test.tsx +3 -4
  146. package/src/plugins/builtin/ai/index.test.ts +2 -2
  147. package/src/plugins/builtin/ai/index.tsx +10 -11
  148. package/src/plugins/builtin/ai/pane-settings.ts +1 -1
  149. package/src/plugins/builtin/ai/pi/runtime.ts +6 -5
  150. package/src/plugins/builtin/ai/providers.ts +1 -9
  151. package/src/plugins/builtin/ai/runner.ts +0 -6
  152. package/src/plugins/builtin/ai/screener/footer.ts +4 -1
  153. package/src/plugins/builtin/ai/screener/pane.test.tsx +9 -9
  154. package/src/plugins/builtin/ai/workspace/model.ts +2 -27
  155. package/src/plugins/builtin/ai/workspace/pane.tsx +2 -2
  156. package/src/plugins/builtin/analytics/metrics.ts +1 -7
  157. package/src/plugins/builtin/broker-manager/detail.tsx +3 -2
  158. package/src/plugins/builtin/broker-manager/index.tsx +4 -2
  159. package/src/plugins/builtin/chart-composer/editor/controller.ts +643 -0
  160. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +134 -0
  161. package/src/plugins/builtin/chart-composer/editor/model.test.ts +85 -0
  162. package/src/plugins/builtin/chart-composer/editor/model.ts +292 -0
  163. package/src/plugins/builtin/chart-composer/editor/opentui-view.tsx +108 -0
  164. package/src/plugins/builtin/chart-composer/editor.tsx +65 -947
  165. package/src/plugins/builtin/chart-composer/index.tsx +0 -6
  166. package/src/plugins/builtin/chart-composer/pane.tsx +69 -36
  167. package/src/plugins/builtin/chart-composer/presets.ts +1 -19
  168. package/src/plugins/builtin/chart-composer/settings.ts +1 -1
  169. package/src/plugins/builtin/chat/content/composer-runtime.ts +1 -1
  170. package/src/plugins/builtin/chat/content/shortcuts.ts +0 -1
  171. package/src/plugins/builtin/chat/controller/fetch.ts +8 -2
  172. package/src/plugins/builtin/chat/controller/persistence.ts +2 -0
  173. package/src/plugins/builtin/chat/controller/session.test.ts +25 -0
  174. package/src/plugins/builtin/chat/controller/session.ts +2 -0
  175. package/src/plugins/builtin/chat/controller/state.ts +7 -0
  176. package/src/plugins/builtin/chat/controller.test.ts +73 -20
  177. package/src/plugins/builtin/chat/message/desktop.tsx +2 -0
  178. package/src/plugins/builtin/cloud-tweets/footer.ts +9 -28
  179. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -5
  180. package/src/plugins/builtin/cloud-tweets/table.tsx +12 -17
  181. package/src/plugins/builtin/correlation/compute.ts +4 -10
  182. package/src/plugins/builtin/econ/index.tsx +17 -19
  183. package/src/plugins/builtin/holders/types.ts +0 -1
  184. package/src/plugins/builtin/insider/index.tsx +6 -31
  185. package/src/plugins/builtin/news/index.tsx +5 -7
  186. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +6 -8
  187. package/src/plugins/builtin/news/wire/news/footer.ts +10 -11
  188. package/src/plugins/builtin/news/wire/read-state.test.ts +7 -0
  189. package/src/plugins/builtin/news/wire/read-state.ts +18 -28
  190. package/src/plugins/builtin/news/wire/rss/parser.ts +1 -11
  191. package/src/plugins/builtin/options/footer.test.ts +115 -0
  192. package/src/plugins/builtin/options/footer.ts +110 -0
  193. package/src/plugins/builtin/options/live-quotes.test.ts +238 -0
  194. package/src/plugins/builtin/options/live-quotes.ts +147 -0
  195. package/src/plugins/builtin/options/view.test.tsx +71 -1
  196. package/src/plugins/builtin/options/view.tsx +39 -24
  197. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +103 -117
  198. package/src/plugins/builtin/portfolio-list/cli/render.ts +11 -5
  199. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +120 -135
  200. package/src/plugins/builtin/portfolio-list/header.tsx +9 -4
  201. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +6 -1
  202. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +4 -2
  203. package/src/plugins/builtin/research/corporate-actions-pane.tsx +24 -52
  204. package/src/plugins/builtin/sec/filing-content.test.ts +41 -0
  205. package/src/plugins/builtin/sec/filing-content.ts +94 -0
  206. package/src/plugins/builtin/sec/index.tsx +21 -104
  207. package/src/plugins/builtin/sectors/sector-model.ts +1 -7
  208. package/src/plugins/builtin/shared/cloud-upgrade.ts +1 -0
  209. package/src/plugins/builtin/shared/pane-footer.ts +82 -0
  210. package/src/plugins/builtin/shared/read-state.ts +64 -3
  211. package/src/plugins/builtin/substack/read-state.ts +16 -41
  212. package/src/plugins/builtin/thirteenf/pane.tsx +24 -73
  213. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +10 -10
  214. package/src/plugins/builtin/yield-curve/index.tsx +11 -9
  215. package/src/plugins/ibkr/dialogs.tsx +3 -3
  216. package/src/plugins/ibkr/gateway/fundamentals.ts +1 -1
  217. package/src/plugins/ibkr/gateway/history-requests.ts +2 -2
  218. package/src/plugins/ibkr/gateway/history.ts +2 -2
  219. package/src/plugins/ibkr/gateway/selection.ts +2 -2
  220. package/src/plugins/ibkr/gateway/service/index.ts +2 -2
  221. package/src/plugins/ibkr/gateway/service/native.ts +2 -2
  222. package/src/plugins/plugin-persistence.ts +3 -4
  223. package/src/plugins/prediction-markets/controller/keyboard.ts +1 -1
  224. package/src/plugins/prediction-markets/controller/live-updates.ts +6 -5
  225. package/src/plugins/prediction-markets/detail/book.tsx +2 -2
  226. package/src/plugins/prediction-markets/detail/outcomes.tsx +3 -1
  227. package/src/plugins/prediction-markets/detail/pane.tsx +3 -1
  228. package/src/plugins/prediction-markets/detail/trades.tsx +3 -3
  229. package/src/plugins/registry/context.ts +3 -4
  230. package/src/plugins/registry/index.ts +5 -6
  231. package/src/react/input.test.ts +126 -3
  232. package/src/react/input.ts +120 -1
  233. package/src/remote/app-host.tsx +2 -13
  234. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +44 -60
  235. package/src/renderers/electrobun/bun/desktop/capability-bridge.ts +15 -13
  236. package/src/renderers/electrobun/bun/desktop/host-requests.ts +26 -22
  237. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -1
  238. package/src/renderers/electrobun/bun/desktop/initialization.ts +11 -6
  239. package/src/renderers/electrobun/bun/desktop/media.ts +5 -3
  240. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +21 -22
  241. package/src/renderers/electrobun/bun/desktop/window-controls.ts +3 -1
  242. package/src/renderers/electrobun/bun/desktop/workspace/requests.ts +18 -18
  243. package/src/renderers/electrobun/bun/index.ts +123 -83
  244. package/src/renderers/electrobun/shared/protocol.ts +146 -7
  245. package/src/renderers/electrobun/view/app-services.ts +5 -8
  246. package/src/renderers/electrobun/view/backend-rpc.ts +18 -8
  247. package/src/renderers/electrobun/view/broker-remote-client.ts +2 -2
  248. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +5 -22
  249. package/src/renderers/electrobun/view/config-host.ts +1 -1
  250. package/src/renderers/electrobun/view/context-menu.ts +1 -17
  251. package/src/renderers/electrobun/view/data-table/index.tsx +0 -1
  252. package/src/renderers/electrobun/view/data-table/row.tsx +0 -4
  253. package/src/renderers/electrobun/view/desktop/control-styles.ts +37 -34
  254. package/src/renderers/electrobun/view/desktop/controls.tsx +36 -33
  255. package/src/renderers/electrobun/view/desktop/list-view.tsx +15 -12
  256. package/src/renderers/electrobun/view/desktop/popover.tsx +2 -3
  257. package/src/renderers/electrobun/view/dialog-host.tsx +2 -3
  258. package/src/renderers/electrobun/view/fatal-screen.tsx +0 -1
  259. package/src/renderers/electrobun/view/host/box.tsx +0 -1
  260. package/src/renderers/electrobun/view/host/chart-surface.tsx +0 -1
  261. package/src/renderers/electrobun/view/host/input.tsx +0 -1
  262. package/src/renderers/electrobun/view/host/media-surface.tsx +0 -1
  263. package/src/renderers/electrobun/view/host/native.ts +0 -1
  264. package/src/renderers/electrobun/view/host/scroll-box.tsx +0 -1
  265. package/src/renderers/electrobun/view/host/tabs.tsx +0 -1
  266. package/src/renderers/electrobun/view/host/text.tsx +0 -1
  267. package/src/renderers/electrobun/view/http-fetch.ts +3 -11
  268. package/src/renderers/electrobun/view/input-host.tsx +10 -53
  269. package/src/renderers/electrobun/view/key-event.ts +0 -1
  270. package/src/renderers/electrobun/view/main.tsx +3 -3
  271. package/src/renderers/electrobun/view/native-renderer.ts +0 -1
  272. package/src/renderers/electrobun/view/native-stubs/backend-rpc.ts +13 -1
  273. package/src/renderers/electrobun/view/native-stubs/chart/surface-manager.ts +11 -0
  274. package/src/renderers/electrobun/view/native-stubs/native-stubs.test.ts +47 -0
  275. package/src/renderers/electrobun/view/remote/asset-data-client.ts +6 -44
  276. package/src/renderers/electrobun/view/remote/persistence.ts +6 -2
  277. package/src/renderers/electrobun/view/remote/quote-subscription-registry.test.ts +86 -0
  278. package/src/renderers/electrobun/view/remote/quote-subscription-registry.ts +72 -0
  279. package/src/renderers/electrobun/view/remote/ticker-repository.ts +2 -2
  280. package/src/renderers/electrobun/view/toast-host.tsx +0 -1
  281. package/src/renderers/electrobun/view/ui-host.tsx +1 -2
  282. package/src/renderers/electrobun/view/update-host.ts +1 -1
  283. package/src/renderers/opentui/chart-surface.tsx +2 -1
  284. package/src/renderers/opentui/dialog-host.tsx +20 -4
  285. package/src/renderers/opentui/image/loader.ts +12 -2
  286. package/src/renderers/opentui/input-host.tsx +7 -49
  287. package/src/renderers/opentui/start.tsx +2 -0
  288. package/src/sources/gloomberb-cloud/history-quality.ts +44 -0
  289. package/src/sources/gloomberb-cloud/index.test.ts +173 -0
  290. package/src/sources/gloomberb-cloud/index.ts +42 -20
  291. package/src/sources/gloomberb-cloud/normalizers.ts +9 -3
  292. package/src/sources/provider-router/cache.ts +1 -1
  293. package/src/sources/provider-router/document-routes.ts +10 -5
  294. package/src/sources/provider-router/financials.test.ts +14 -0
  295. package/src/sources/provider-router/financials.ts +6 -1
  296. package/src/sources/provider-router/history.test.ts +163 -1
  297. package/src/sources/provider-router/history.ts +278 -347
  298. package/src/sources/provider-router/index.ts +2 -2
  299. package/src/sources/provider-router/routing.test.ts +39 -0
  300. package/src/sources/provider-router/routing.ts +24 -11
  301. package/src/sources/provider-router/supplemental.ts +37 -17
  302. package/src/sources/sec-edgar/content.ts +2 -12
  303. package/src/sources/sec-edgar.ts +2 -13
  304. package/src/sources/yahoo-finance/history.ts +2 -2
  305. package/src/sources/yahoo-finance/options.ts +5 -0
  306. package/src/sources/yahoo-finance.ts +2 -2
  307. package/src/state/app/bootstrap.ts +2 -2
  308. package/src/state/app/context/index.tsx +2 -2
  309. package/src/state/hooks/quote-streaming.ts +66 -1
  310. package/src/state/hooks/session-snapshot.ts +2 -2
  311. package/src/state/selectors.ts +0 -9
  312. package/src/sync/controller.test.ts +98 -0
  313. package/src/sync/controller.ts +29 -5
  314. package/src/sync/react.ts +2 -2
  315. package/src/sync/types.ts +2 -2
  316. package/src/theme/colors.test.ts +10 -0
  317. package/src/theme/colors.ts +75 -78
  318. package/src/theme/theme-context.test.tsx +67 -0
  319. package/src/theme/theme-context.tsx +9 -5
  320. package/src/tickers/open-target.ts +2 -2
  321. package/src/tickers/search/upsert.ts +2 -2
  322. package/src/time-series/alignment.ts +0 -7
  323. package/src/time-series/chart-data.ts +145 -0
  324. package/src/time-series/date-window.ts +44 -0
  325. package/src/time-series/history-window.ts +21 -0
  326. package/src/time-series/hooks.test.ts +59 -0
  327. package/src/time-series/hooks.ts +33 -12
  328. package/src/time-series/market-resolution.ts +2 -2
  329. package/src/time-series/market.ts +1 -1
  330. package/src/time-series/range.ts +9 -0
  331. package/src/{components/chart/core → time-series}/resolution.ts +3 -102
  332. package/src/time-series/resolve.test.ts +203 -1
  333. package/src/time-series/resolve.ts +96 -18
  334. package/src/time-series/spec-studies.test.ts +15 -0
  335. package/src/time-series/spec.ts +2 -2
  336. package/src/time-series/studies.ts +5 -2
  337. package/src/time-series/types.ts +3 -1
  338. package/src/time-series/use-chart-resolution.test.tsx +358 -0
  339. package/src/time-series/use-chart-resolution.ts +46 -4
  340. package/src/types/broker.ts +2 -2
  341. package/src/types/data-provider.ts +3 -3
  342. package/src/types/financials.ts +12 -0
  343. package/src/types/plugin.ts +2 -2
  344. package/src/ui/context-menu.tsx +3 -2
  345. package/src/ui/dialog.tsx +2 -2
  346. package/src/utils/html-entities.ts +3 -5
  347. package/src/utils/main-thread-monitor.ts +1 -1
  348. package/src/utils/price-history.ts +5 -5
  349. package/src/version.ts +3 -1
  350. package/src/components/chart/core/viewport.test.ts +0 -8
  351. package/src/components/chart/core/viewport.ts +0 -24
  352. package/src/time-series/index.ts +0 -14
  353. /package/src/{components/chart/core → time-series}/resolution.test.ts +0 -0
@@ -19,8 +19,10 @@ interface ResolvedQuickAdd {
19
19
  export type QuickAddValidation =
20
20
  | { status: "idle"; query: "" }
21
21
  | { status: "checking"; query: string }
22
- | (ResolvedQuickAdd & { status: "ready" | "duplicate" })
23
- | { status: "missing" | "error"; query: string; message: string };
22
+ | (ResolvedQuickAdd & { status: "ready" })
23
+ | (ResolvedQuickAdd & { status: "duplicate" })
24
+ | { status: "missing"; query: string; message: string }
25
+ | { status: "error"; query: string; message: string };
24
26
 
25
27
  export const IDLE_VALIDATION: QuickAddValidation = { status: "idle", query: "" };
26
28
 
@@ -19,8 +19,7 @@ import { blendHex, colors } from "../../../theme/colors";
19
19
  import { formatCompact, formatCurrency, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
20
20
  import { isPlainKey } from "../../../utils/keyboard";
21
21
  import { wrapTextLines } from "../../../utils/text-wrap";
22
- import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
23
- import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
22
+ import { useResolvedEntryValue, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
24
23
  import { instrumentFromTicker } from "../../../market-data/request-types";
25
24
  import { isUsEquityTicker } from "../../../utils/sec";
26
25
  import { useAssetData } from "../../runtime";
@@ -28,12 +27,15 @@ import { handleRefreshKey, loadingErrorFooterInfo, refreshFooterHint } from "../
28
27
  import { useBoundTicker as useSymbolBinding, useTickerRequest } from "../shared/ticker-request";
29
28
  import {
30
29
  documentContentKey,
31
- documentContentTarget,
32
30
  documentHeading,
33
31
  formatCompactDocumentLabel,
34
32
  isDefaultVisibleFilingDocument,
35
33
  isInlineExhibitDocument,
36
34
  } from "../sec/filing-documents";
35
+ import {
36
+ buildInlineFilingContentTargets,
37
+ useSecFilingContentCache,
38
+ } from "../sec/filing-content";
37
39
 
38
40
  type EventStatus = "Earnings" | "Q Est" | "FY Est" | "TTM" | "Dividend" | "Split";
39
41
 
@@ -523,9 +525,6 @@ export function CorporateActionsView({
523
525
  const [selectedIdx, setSelectedIdx] = useState(0);
524
526
  const [openRowId, setOpenRowId] = useState<string | null>(null);
525
527
  const detailScrollRef = useRef<ScrollBoxRenderable>(null);
526
- const [inlineContent, setInlineContent] = useState<Map<string, string | null>>(new Map());
527
- const documentFetchStartedRef = useRef(new Set<string>());
528
- const documentFetchGenRef = useRef(0);
529
528
  const todayKey = todayDateKey();
530
529
  const futureRowBackground = blendHex(colors.bg, colors.positive, 0.16);
531
530
  const loading = actionsLoading || analystLoading || financialsLoading;
@@ -561,16 +560,26 @@ export function CorporateActionsView({
561
560
  || documentsEntry?.phase === "loading"
562
561
  || documentsEntry?.phase === "refreshing"
563
562
  );
564
- const hasInlineExhibits = documents.some(isInlineExhibitDocument);
565
- const filingContentEntry = useSecFilingContent(
566
- matchedFiling && !documentsLoading && !hasInlineExhibits ? matchedFiling : null,
567
- );
568
- const primaryContent = useResolvedEntryValue(filingContentEntry);
569
- const primaryContentLoading = !!matchedFiling && (
570
- filingContentEntry?.phase === "idle"
571
- || filingContentEntry?.phase === "loading"
572
- || filingContentEntry?.phase === "refreshing"
563
+ const inlineTargets = useMemo(
564
+ () => buildInlineFilingContentTargets(matchedFiling, documents),
565
+ [documents, matchedFiling],
573
566
  );
567
+ const filingContentTargets = useMemo(() => {
568
+ if (!matchedFiling || documentsLoading) return [];
569
+ return inlineTargets.length > 0 ? inlineTargets : [matchedFiling];
570
+ }, [documentsLoading, inlineTargets, matchedFiling]);
571
+ const { contentCache: inlineContent } = useSecFilingContentCache({
572
+ scopeKey: `${symbol}:${exchange}`,
573
+ targets: filingContentTargets,
574
+ });
575
+ const hasInlineExhibits = inlineTargets.length > 0;
576
+ const primaryContent = matchedFiling
577
+ ? inlineContent.get(matchedFiling.accessionNumber) ?? null
578
+ : null;
579
+ const primaryContentLoading = !!matchedFiling
580
+ && !documentsLoading
581
+ && !hasInlineExhibits
582
+ && !inlineContent.has(matchedFiling.accessionNumber);
574
583
 
575
584
  useEffect(() => {
576
585
  if (rows.length > 0 && selectedIdx >= rows.length) {
@@ -590,43 +599,6 @@ export function CorporateActionsView({
590
599
  if (scrollBox) scrollBox.scrollTop = 0;
591
600
  }, [openRowId]);
592
601
 
593
- useEffect(() => {
594
- setInlineContent(new Map());
595
- documentFetchStartedRef.current.clear();
596
- documentFetchGenRef.current += 1;
597
- }, [exchange, symbol]);
598
-
599
- useEffect(() => {
600
- if (!matchedFiling || documents.length === 0) return;
601
- const coordinator = getSharedMarketDataCoordinator();
602
- if (!coordinator) return;
603
- const gen = documentFetchGenRef.current;
604
- const documentsToFetch = documents.filter((document) => {
605
- if (!isInlineExhibitDocument(document)) return false;
606
- const key = documentContentKey(matchedFiling, document);
607
- return !inlineContent.has(key) && !documentFetchStartedRef.current.has(key);
608
- });
609
- if (documentsToFetch.length === 0) return;
610
-
611
- for (const document of documentsToFetch) {
612
- documentFetchStartedRef.current.add(documentContentKey(matchedFiling, document));
613
- }
614
-
615
- (async () => {
616
- for (const document of documentsToFetch) {
617
- if (documentFetchGenRef.current !== gen) return;
618
- const key = documentContentKey(matchedFiling, document);
619
- try {
620
- const entry = await coordinator.loadSecFilingContent(documentContentTarget(matchedFiling, document));
621
- if (documentFetchGenRef.current !== gen) return;
622
- setInlineContent((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
623
- } catch {
624
- if (documentFetchGenRef.current !== gen) return;
625
- setInlineContent((prev) => new Map(prev).set(key, null));
626
- }
627
- }
628
- })();
629
- }, [documents, inlineContent, matchedFiling]);
630
602
  const detailBody = openRow
631
603
  ? buildEventDetailBody({
632
604
  row: openRow,
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
3
+ import {
4
+ buildInlineFilingContentTargets,
5
+ nextUncachedFilingContentTarget,
6
+ } from "./filing-content";
7
+
8
+ function filing(accessionNumber: string): SecFilingItem {
9
+ return {
10
+ accessionNumber,
11
+ filingDate: "2026-01-01",
12
+ filingUrl: `https://example.com/${accessionNumber}`,
13
+ form: "8-K",
14
+ primaryDocument: "filing.htm",
15
+ };
16
+ }
17
+
18
+ describe("SEC filing content cache", () => {
19
+ test("loads unique content targets sequentially in caller priority order", () => {
20
+ const selected = filing("selected");
21
+ const background = filing("background");
22
+ const targets = [selected, selected, background];
23
+
24
+ expect(nextUncachedFilingContentTarget(targets, new Map())?.accessionNumber).toBe("selected");
25
+ expect(nextUncachedFilingContentTarget(
26
+ targets,
27
+ new Map([["selected", null]]),
28
+ )?.accessionNumber).toBe("background");
29
+ });
30
+
31
+ test("builds content targets only for readable inline exhibits", () => {
32
+ const selected = filing("selected");
33
+ const documents: SecFilingDocument[] = [
34
+ { document: "exhibit.htm", description: "Press release", type: "EX-99.1", url: "https://example.com/exhibit.htm", isPrimary: false },
35
+ { document: "schema.xsd", description: "Schema", type: "EX-101", url: "https://example.com/schema.xsd", isPrimary: false },
36
+ ];
37
+
38
+ expect(buildInlineFilingContentTargets(selected, documents).map((target) => target.accessionNumber))
39
+ .toEqual(["selected:exhibit.htm"]);
40
+ });
41
+ });
@@ -0,0 +1,94 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { useResolvedEntryValue, useSecFilingContent } from "../../../market-data/hooks";
3
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
4
+ import { documentContentTarget, isInlineExhibitDocument } from "./filing-documents";
5
+
6
+ interface ScopedFilingContentCache {
7
+ scopeKey: string;
8
+ values: Map<string, string | null>;
9
+ }
10
+
11
+ const EMPTY_CONTENT_CACHE = new Map<string, string | null>();
12
+
13
+ export function buildInlineFilingContentTargets(
14
+ filing: SecFilingItem | null | undefined,
15
+ documents: readonly SecFilingDocument[],
16
+ ): SecFilingItem[] {
17
+ if (!filing) return [];
18
+ return documents
19
+ .filter(isInlineExhibitDocument)
20
+ .map((document) => documentContentTarget(filing, document));
21
+ }
22
+
23
+ export function nextUncachedFilingContentTarget(
24
+ targets: readonly SecFilingItem[],
25
+ cache: ReadonlyMap<string, string | null>,
26
+ ): SecFilingItem | null {
27
+ const seen = new Set<string>();
28
+ for (const target of targets) {
29
+ const key = target.accessionNumber;
30
+ if (seen.has(key)) continue;
31
+ seen.add(key);
32
+ if (!cache.has(key)) return target;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function useSecFilingContentCache({
38
+ scopeKey,
39
+ targets,
40
+ }: {
41
+ scopeKey: string;
42
+ targets: readonly SecFilingItem[];
43
+ }) {
44
+ const [state, setState] = useState<ScopedFilingContentCache>(() => ({
45
+ scopeKey,
46
+ values: new Map(),
47
+ }));
48
+ const contentCache = state.scopeKey === scopeKey ? state.values : EMPTY_CONTENT_CACHE;
49
+
50
+ useEffect(() => {
51
+ setState((current) => current.scopeKey === scopeKey
52
+ ? current
53
+ : { scopeKey, values: new Map() });
54
+ }, [scopeKey]);
55
+
56
+ const nextTarget = useMemo(
57
+ () => nextUncachedFilingContentTarget(targets, contentCache),
58
+ [contentCache, targets],
59
+ );
60
+ const contentEntry = useSecFilingContent(nextTarget);
61
+ const resolvedContent = useResolvedEntryValue(contentEntry);
62
+
63
+ useEffect(() => {
64
+ if (!nextTarget) return;
65
+ const phase = contentEntry?.phase;
66
+ const settled = phase === "ready"
67
+ || phase === "error"
68
+ || (phase === "refreshing" && resolvedContent !== null);
69
+ if (!settled) return;
70
+ const key = nextTarget.accessionNumber;
71
+ const content = phase === "error" ? null : resolvedContent;
72
+ setState((current) => {
73
+ if (current.scopeKey !== scopeKey || current.values.has(key)) return current;
74
+ return {
75
+ scopeKey,
76
+ values: new Map(current.values).set(key, content),
77
+ };
78
+ });
79
+ }, [contentEntry?.phase, nextTarget, resolvedContent, scopeKey]);
80
+
81
+ const pendingCount = useMemo(() => {
82
+ const pending = new Set<string>();
83
+ for (const target of targets) {
84
+ if (!contentCache.has(target.accessionNumber)) pending.add(target.accessionNumber);
85
+ }
86
+ return pending.size;
87
+ }, [contentCache, targets]);
88
+
89
+ return {
90
+ contentCache,
91
+ loadingKey: nextTarget?.accessionNumber ?? null,
92
+ pendingCount,
93
+ };
94
+ }
@@ -1,15 +1,14 @@
1
1
  import { Text } from "../../../ui";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import type { PluginModule } from "../plugin-module";
4
4
  import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
5
- import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
5
+ import { useResolvedEntryValue, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
6
6
  import { instrumentFromTicker } from "../../../market-data/request-types";
7
7
  import { useDebouncedPluginPaneState } from "../../runtime";
8
8
  import { usePaneTicker } from "../../../state/app/context";
9
9
  import { colors } from "../../../theme/colors";
10
- import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTableItem } from "../../../components";
10
+ import { FeedDataTableStackView, Spinner, type FeedDataTableItem } from "../../../components";
11
11
  import { isUsEquityTicker } from "../../../utils/sec";
12
- import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
13
12
  import { parseForm4Xml, transactionTypeLabel } from "../insider/insider-data";
14
13
  import { formatCompact, formatCurrency } from "../../../utils/format";
15
14
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
@@ -19,12 +18,16 @@ import {
19
18
  } from "./filing-display";
20
19
  import {
21
20
  documentContentKey,
22
- documentContentTarget,
23
21
  documentHeading,
24
22
  formatCompactDocumentLabel,
25
23
  isDefaultVisibleFilingDocument,
26
24
  isInlineExhibitDocument,
27
25
  } from "./filing-documents";
26
+ import {
27
+ buildInlineFilingContentTargets,
28
+ useSecFilingContentCache,
29
+ } from "./filing-content";
30
+ import { usePaneStatusLinkFooter } from "../shared/pane-footer";
28
31
 
29
32
  const SEC_FILING_LIMIT = 50;
30
33
  const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
@@ -244,9 +247,7 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
244
247
  const { ticker } = usePaneTicker();
245
248
  const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
246
249
  const [selectedIdx, setSelectedIdx] = useDebouncedPluginPaneState<number>(selectionKey, 0);
247
- const [contentCache, setContentCache] = useState<Map<string, string | null>>(new Map());
248
250
  const [openItemId, setOpenItemId] = useState<string | null>(null);
249
- const contentFetchRef = useRef(0);
250
251
  const eligibleTicker = isUsEquityTicker(ticker);
251
252
  const instrument = instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null);
252
253
  const filingsEntry = useSecFilingsQuery(
@@ -258,20 +259,9 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
258
259
  const loading = filingsEntry?.phase === "loading" || (filingsEntry?.phase === "refreshing" && filings.length === 0);
259
260
  const error = filingsEntry?.phase === "error" ? filingsEntry.error?.message ?? "Failed to load SEC filings" : null;
260
261
 
261
- useEffect(() => {
262
- setContentCache(new Map());
263
- contentFetchRef.current += 1;
264
- }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
265
-
266
262
  const openFiling = openItemId
267
263
  ? filings.find((filing) => filing.accessionNumber === openItemId) ?? null
268
264
  : null;
269
- const cachedOpenContent = openFiling ? contentCache.get(openFiling.accessionNumber) : undefined;
270
- const filingContentEntry = useSecFilingContent(
271
- openFiling && cachedOpenContent === undefined ? openFiling : null,
272
- );
273
- const filingContent = useResolvedEntryValue(filingContentEntry);
274
- const loadingContent = filingContentEntry?.phase === "loading" || filingContentEntry?.phase === "refreshing";
275
265
  const documentsEntry = useSecFilingDocuments(openFiling ?? null);
276
266
  const openDocuments = useResolvedEntryValue(documentsEntry) ?? [];
277
267
  const loadingDocuments = !!openFiling && (
@@ -280,50 +270,16 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
280
270
  || documentsEntry?.phase === "refreshing"
281
271
  );
282
272
 
283
- useEffect(() => {
284
- if (!openFiling) return;
285
- if (cachedOpenContent !== undefined) return;
286
- if (filingContentEntry?.phase === "error") {
287
- setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, null));
288
- return;
289
- }
290
- if (filingContent !== null) {
291
- setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, filingContent));
292
- }
293
- }, [cachedOpenContent, filingContent, filingContentEntry?.phase, openFiling]);
294
-
295
- const documentFetchStartedRef = useRef(new Set<string>());
296
- useEffect(() => {
297
- if (!openFiling || openDocuments.length === 0) return;
298
- const coordinator = getSharedMarketDataCoordinator();
299
- if (!coordinator) return;
300
- const gen = contentFetchRef.current;
301
- const documentsToFetch = openDocuments.filter((document) => {
302
- if (!isInlineExhibitDocument(document)) return false;
303
- const key = documentContentKey(openFiling, document);
304
- return !contentCache.has(key) && !documentFetchStartedRef.current.has(key);
305
- });
306
- if (documentsToFetch.length === 0) return;
307
-
308
- for (const document of documentsToFetch) {
309
- documentFetchStartedRef.current.add(documentContentKey(openFiling, document));
310
- }
311
-
312
- (async () => {
313
- for (const document of documentsToFetch) {
314
- if (contentFetchRef.current !== gen) return;
315
- const key = documentContentKey(openFiling, document);
316
- try {
317
- const entry = await coordinator.loadSecFilingContent(documentContentTarget(openFiling, document));
318
- if (contentFetchRef.current !== gen) return;
319
- setContentCache((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
320
- } catch {
321
- if (contentFetchRef.current !== gen) return;
322
- setContentCache((prev) => new Map(prev).set(key, null));
323
- }
324
- }
325
- })();
326
- }, [contentCache, openFiling, openDocuments]);
273
+ const contentTargets = useMemo(() => [
274
+ ...(openFiling ? [openFiling] : []),
275
+ ...buildInlineFilingContentTargets(openFiling, openDocuments),
276
+ ...filings.filter((filing) => OWNERSHIP_FORMS.has(filing.form.trim())),
277
+ ], [filings, openDocuments, openFiling]);
278
+ const { contentCache } = useSecFilingContentCache({
279
+ scopeKey: `${ticker?.metadata.ticker ?? "none"}:${ticker?.metadata.exchange ?? ""}:${eligibleTicker}`,
280
+ targets: contentTargets,
281
+ });
282
+ const loadingContent = !!openFiling && !contentCache.has(openFiling.accessionNumber);
327
283
 
328
284
  useEffect(() => {
329
285
  if (filings.length > 0 && selectedIdx >= filings.length) {
@@ -331,53 +287,14 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
331
287
  }
332
288
  }, [filings.length, selectedIdx, setSelectedIdx]);
333
289
 
334
- // Background prefetch Form 4 content for enriched previews
335
- const prefetchStartedRef = useRef(new Set<string>());
336
- useEffect(() => {
337
- prefetchStartedRef.current.clear();
338
- documentFetchStartedRef.current.clear();
339
- }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
340
-
341
- useEffect(() => {
342
- if (filings.length === 0) return;
343
- const coordinator = getSharedMarketDataCoordinator();
344
- if (!coordinator) return;
345
- const gen = contentFetchRef.current;
346
- const form4s = filings.filter((f) =>
347
- OWNERSHIP_FORMS.has(f.form.trim())
348
- && !contentCache.has(f.accessionNumber)
349
- && !prefetchStartedRef.current.has(f.accessionNumber),
350
- );
351
- if (form4s.length === 0) return;
352
-
353
- for (const filing of form4s) {
354
- prefetchStartedRef.current.add(filing.accessionNumber);
355
- }
356
-
357
- (async () => {
358
- for (const filing of form4s) {
359
- if (contentFetchRef.current !== gen) return;
360
- try {
361
- const entry = await coordinator.loadSecFilingContent(filing);
362
- if (contentFetchRef.current !== gen) return;
363
- const content = entry?.data ?? null;
364
- setContentCache((prev) => new Map(prev).set(filing.accessionNumber, content));
365
- } catch { /* skip */ }
366
- }
367
- })();
368
- }, [filings]);
369
-
370
- const footerInfo = useMemo(() => [
371
- ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
372
- ...(error ? [{ id: "error", parts: [{ text: "error", tone: "warning" as const }] }] : []),
373
- ], [error, loading]);
374
- useExternalLinkFooter({
290
+ usePaneStatusLinkFooter({
375
291
  registrationId: "sec",
376
292
  focused,
377
293
  url: error ? null : openFiling?.filingUrl,
378
294
  source: openFiling?.form,
379
- info: footerInfo,
380
295
  label: "filing",
296
+ loading,
297
+ error,
381
298
  });
382
299
 
383
300
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view SEC filings.</Text>;
@@ -1,6 +1,7 @@
1
1
  import type { DataTableColumn } from "../../../components";
2
2
  import type { PricePoint } from "../../../types/financials";
3
3
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
4
+ import { getPricePointTimestamp } from "../../../utils/price-history";
4
5
  import {
5
6
  getSectorCollection,
6
7
  type SectorCollectionId,
@@ -101,13 +102,6 @@ export function updateRowsForCollection(
101
102
  };
102
103
  }
103
104
 
104
- function getPricePointTimestamp(point: PricePoint): number {
105
- const value = point.date as Date | string | number | null | undefined;
106
- if (value instanceof Date) return value.getTime();
107
- if (value == null) return Number.NaN;
108
- return new Date(value).getTime();
109
- }
110
-
111
105
  function getSortedHistory(history: readonly PricePoint[]): Array<{ point: PricePoint; timestamp: number }> {
112
106
  return history
113
107
  .map((point) => ({ point, timestamp: getPricePointTimestamp(point) }))
@@ -0,0 +1 @@
1
+ export const CLOUD_UPGRADE_URL = "https://gloom.sh/cloud?upgrade=pro";
@@ -0,0 +1,82 @@
1
+ import { useMemo } from "react";
2
+ import {
3
+ useExternalLinkFooter,
4
+ usePaneFooter,
5
+ type PaneFooterSegment,
6
+ } from "../../../components";
7
+
8
+ const EMPTY_STATUS_INFO: PaneFooterSegment[] = [];
9
+
10
+ function buildPaneStatusInfo({
11
+ loading = false,
12
+ error,
13
+ info = EMPTY_STATUS_INFO,
14
+ }: {
15
+ loading?: boolean;
16
+ error?: string | null;
17
+ info?: readonly PaneFooterSegment[];
18
+ }): PaneFooterSegment[] {
19
+ return [
20
+ ...info,
21
+ ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
22
+ ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
23
+ ];
24
+ }
25
+
26
+ export function usePaneStatusFooter({
27
+ registrationId,
28
+ loading = false,
29
+ error,
30
+ info = EMPTY_STATUS_INFO,
31
+ enabled = true,
32
+ }: {
33
+ registrationId: string;
34
+ loading?: boolean;
35
+ error?: string | null;
36
+ info?: readonly PaneFooterSegment[];
37
+ enabled?: boolean;
38
+ }) {
39
+ const statusInfo = useMemo(
40
+ () => buildPaneStatusInfo({ loading, error, info }),
41
+ [error, info, loading],
42
+ );
43
+ usePaneFooter(
44
+ registrationId,
45
+ () => enabled && statusInfo.length > 0 ? { info: statusInfo } : null,
46
+ [enabled, registrationId, statusInfo],
47
+ );
48
+ }
49
+
50
+ export function usePaneStatusLinkFooter({
51
+ registrationId,
52
+ focused,
53
+ url,
54
+ source,
55
+ label,
56
+ loading = false,
57
+ error,
58
+ info = EMPTY_STATUS_INFO,
59
+ }: {
60
+ registrationId: string;
61
+ focused: boolean;
62
+ url: string | null | undefined;
63
+ source?: string | null;
64
+ label?: string;
65
+ loading?: boolean;
66
+ error?: string | null;
67
+ info?: readonly PaneFooterSegment[];
68
+ }) {
69
+ const statusInfo = useMemo(
70
+ () => buildPaneStatusInfo({ loading, error, info }),
71
+ [error, info, loading],
72
+ );
73
+ return useExternalLinkFooter({
74
+ registrationId,
75
+ focused,
76
+ url,
77
+ source,
78
+ label,
79
+ info: statusInfo,
80
+ showHint: false,
81
+ });
82
+ }
@@ -1,15 +1,31 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { usePluginState } from "../../runtime";
3
+
1
4
  export const DEFAULT_MAX_READ_IDS = 2_000;
2
5
 
6
+ export interface PersistedReadIdAdapter<TState> {
7
+ getIds(state: TState): readonly unknown[] | undefined;
8
+ withIds(state: TState, ids: string[]): TState;
9
+ maxIds?: number;
10
+ }
11
+
12
+ interface UsePersistedReadIdsOptions<TState> {
13
+ key: string;
14
+ fallback: TState;
15
+ schemaVersion: number;
16
+ adapter: PersistedReadIdAdapter<TState>;
17
+ }
18
+
3
19
  function normalizeReadId(readId: unknown): string {
4
20
  return typeof readId === "string" ? readId.trim() : "";
5
21
  }
6
22
 
7
- export function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
23
+ function sameStringArray(left: readonly string[], right: readonly unknown[]): boolean {
8
24
  if (left.length !== right.length) return false;
9
25
  return left.every((value, index) => value === right[index]);
10
26
  }
11
27
 
12
- export function normalizeReadIds(
28
+ function normalizeReadIds(
13
29
  readIds: readonly unknown[] | undefined,
14
30
  maxIds = DEFAULT_MAX_READ_IDS,
15
31
  ): string[] {
@@ -27,7 +43,7 @@ export function normalizeReadIds(
27
43
  return normalizedIds;
28
44
  }
29
45
 
30
- export function markReadId(
46
+ function markReadId(
31
47
  readIds: readonly unknown[] | undefined,
32
48
  readId: string,
33
49
  maxIds = DEFAULT_MAX_READ_IDS,
@@ -41,3 +57,48 @@ export function markReadId(
41
57
  ...current.filter((currentId) => currentId !== normalizedId),
42
58
  ].slice(0, maxIds);
43
59
  }
60
+
61
+ export function normalizePersistedReadIdState<TState>(
62
+ state: TState,
63
+ adapter: PersistedReadIdAdapter<TState>,
64
+ ): TState {
65
+ const current = adapter.getIds(state);
66
+ const ids = normalizeReadIds(current, adapter.maxIds);
67
+ return Array.isArray(current) && sameStringArray(ids, current)
68
+ ? state
69
+ : adapter.withIds(state, ids);
70
+ }
71
+
72
+ export function markPersistedReadId<TState>(
73
+ state: TState,
74
+ readId: string,
75
+ adapter: PersistedReadIdAdapter<TState>,
76
+ ): TState {
77
+ const current = adapter.getIds(state);
78
+ const ids = markReadId(current, readId, adapter.maxIds);
79
+ return Array.isArray(current) && sameStringArray(ids, current)
80
+ ? state
81
+ : adapter.withIds(state, ids);
82
+ }
83
+
84
+ export function usePersistedReadIds<TState>({
85
+ key,
86
+ fallback,
87
+ schemaVersion,
88
+ adapter,
89
+ }: UsePersistedReadIdsOptions<TState>) {
90
+ const [state, setState] = usePluginState<TState>(key, fallback, { schemaVersion });
91
+ const normalizedState = useMemo(
92
+ () => normalizePersistedReadIdState(state, adapter),
93
+ [adapter, state],
94
+ );
95
+ const readIds = useMemo(
96
+ () => new Set(normalizeReadIds(adapter.getIds(normalizedState), adapter.maxIds)),
97
+ [adapter, normalizedState],
98
+ );
99
+ const markRead = useCallback((readId: string) => {
100
+ setState((current) => markPersistedReadId(current, readId, adapter));
101
+ }, [adapter, setState]);
102
+
103
+ return { readIds, markRead };
104
+ }