gloomberb 0.19.0 → 0.19.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 (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -8,6 +8,7 @@ import {
8
8
  type PaneFooterSegment,
9
9
  type PaneHint,
10
10
  } from "../../../components";
11
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
11
12
  import { useAsyncResource } from "../../../react/async-resource";
12
13
  import { colors } from "../../../theme/colors";
13
14
  import {
@@ -119,8 +120,9 @@ function RiskFactorsReader({
119
120
  const reportLoader = useCallback((force: boolean) => guard(loadRiskReport(ticker!, year!, { force, listing })), [guard, listing, ticker, year]);
120
121
  const detail = useAsyncResource(ticker && year !== null ? reportLoader : null, { clearOnError: isPermanentClientError });
121
122
  const report = detail.data;
122
- const listError = list.error ?? list.data?.refreshError ?? null;
123
- const reportError = detail.error ?? report?.refreshError ?? null;
123
+ // A copy saved earlier answering a failed refresh says when it was saved, in the footer every pane uses, not as an error.
124
+ const listError = list.error ?? (list.data?.savedAt == null ? list.data?.refreshError : null) ?? null;
125
+ const reportError = detail.error ?? (report?.savedAt == null ? report?.refreshError : null) ?? null;
124
126
  const refresh = useCallback(() => {
125
127
  void list.reload();
126
128
  if (year !== null) void detail.reload();
@@ -151,12 +153,7 @@ function RiskFactorsReader({
151
153
  info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
152
154
  if (listError) info.push({ id: "list-error", parts: [{ text: `Report list: ${listError}`, tone: "warning" }] });
153
155
  if (reportError) info.push({ id: "report-error", parts: [{ text: `${year} report: ${reportError}`, tone: "warning" }] });
154
- if ((listError || list.data?.stale) && list.data) {
155
- info.push({ id: "list-stale", parts: [{ text: `List cached ${new Date(list.data.fetchedAt).toISOString()}`, tone: "muted" }] });
156
- }
157
- if ((reportError || report?.stale) && report) {
158
- info.push({ id: "report-stale", parts: [{ text: `Report cached ${new Date(report.fetchedAt).toISOString()}`, tone: "muted" }] });
159
- }
156
+ info.push(...savedCopyFooterInfo([list.data?.savedAt, report?.savedAt]));
160
157
  const hints: PaneHint[] = report?.docUrl
161
158
  ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }]
162
159
  : [];
@@ -0,0 +1,28 @@
1
+ import type { PluginModule } from "../plugin-module";
2
+ import { RoadmapPane } from "./pane";
3
+
4
+ export const roadmapModule: PluginModule = {
5
+ panes: [
6
+ {
7
+ id: "roadmap",
8
+ name: "Roadmap",
9
+ icon: "R",
10
+ component: RoadmapPane,
11
+ reportFreshness: { status: "not-a-feed", basis: "curated list" },
12
+ defaultPosition: "right",
13
+ defaultMode: "floating",
14
+ defaultFloatingSize: { width: 100, height: 30 },
15
+ },
16
+ ],
17
+
18
+ paneTemplates: [
19
+ {
20
+ id: "roadmap-pane",
21
+ paneId: "roadmap",
22
+ label: "Roadmap",
23
+ description: "What is being built, what is planned and what shipped. Pro members vote on what comes next.",
24
+ keywords: ["roadmap", "planned", "building", "shipped", "upcoming", "features", "vote", "votes", "request"],
25
+ shortcut: { prefix: "ROADMAP", aliases: ["RMAP"] },
26
+ },
27
+ ],
28
+ };
@@ -0,0 +1,67 @@
1
+ import type { RoadmapItem, RoadmapPayload, RoadmapStatus, RoadmapVote } from "../../../api-client/roadmap";
2
+ import type { TableSection } from "../../../components";
3
+
4
+ export const ROADMAP_STATUS_LABELS: Record<RoadmapStatus, string> = {
5
+ building: "Building",
6
+ planned: "Planned",
7
+ shipped: "Shipped",
8
+ };
9
+
10
+ const SECTION_ORDER: readonly RoadmapStatus[] = ["building", "planned", "shipped"];
11
+
12
+ /** Building, then Planned, then Shipped, each in the order the service sent; an empty group is left out. */
13
+ export function roadmapSections(items: readonly RoadmapItem[]): TableSection<RoadmapItem>[] {
14
+ return SECTION_ORDER.map((status) => ({
15
+ label: ROADMAP_STATUS_LABELS[status],
16
+ key: status,
17
+ items: items.filter((item) => item.status === status),
18
+ }));
19
+ }
20
+
21
+ /** Votes are taken on what is not shipped yet; a shipped item's count is final. */
22
+ export function isVotable(item: RoadmapItem): boolean {
23
+ return item.status !== "shipped";
24
+ }
25
+
26
+ /**
27
+ * The items with this session's own votes on top: the count and mark a vote
28
+ * changed, held until the next read of the list replaces them.
29
+ */
30
+ export function withLocalVotes(
31
+ payload: RoadmapPayload | null,
32
+ local: Readonly<Record<string, RoadmapVote>>,
33
+ ): RoadmapItem[] {
34
+ if (!payload) return [];
35
+ return payload.items.map((item) => {
36
+ const vote = local[item.id];
37
+ return vote ? { ...item, votes: vote.votes, voted: vote.voted } : item;
38
+ });
39
+ }
40
+
41
+ /** What the row shows while a vote is on its way: the mark flipped and the count moved by one. */
42
+ export function optimisticVote(item: RoadmapItem, voted: boolean): RoadmapVote {
43
+ const step = voted === item.voted ? 0 : voted ? 1 : -1;
44
+ return { id: item.id, votes: Math.max(0, item.votes + step), voted };
45
+ }
46
+
47
+ /** The votes cell: the count, with a check before it when this account voted. */
48
+ export function votesText(item: RoadmapItem): string {
49
+ return item.voted ? `✓ ${item.votes}` : String(item.votes);
50
+ }
51
+
52
+ /** What one vote that failed is called on its row, and the reason the detail gives. */
53
+ export interface VoteFailure {
54
+ status: string;
55
+ reason: string;
56
+ }
57
+
58
+ export function voteFailure(error: unknown, voted: boolean): VoteFailure {
59
+ const status = typeof error === "object" && error && "status" in error ? (error as { status?: unknown }).status : undefined;
60
+ const action = voted ? "The vote" : "Taking the vote back";
61
+ if (status === 401) return { status: "sign in", reason: `${action} did not go through: sign in again to vote.` };
62
+ if (status === 402 || status === 403) return { status: "Pro only", reason: `${action} did not go through: voting is for verified Pro members.` };
63
+ if (status === 404) return { status: "removed", reason: "This item is no longer on the roadmap." };
64
+ if (status === 409) return { status: "closed", reason: "This item shipped, so its votes are final." };
65
+ const message = error instanceof Error && error.message.trim() ? error.message.trim() : "the request failed";
66
+ return { status: "failed", reason: `${action} did not go through: ${message.replace(/\.$/, "")}.` };
67
+ }
@@ -0,0 +1,244 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { apiClient } from "../../../api-client";
3
+ import { usePlanAccess } from "../../../api-client/plan-access";
4
+ import type { RoadmapItem, RoadmapVote } from "../../../api-client/roadmap";
5
+ import {
6
+ buildSectionedRows,
7
+ DataTableStackView,
8
+ EMPTY_TABLE_CELL,
9
+ isSectionedItemRow,
10
+ renderSectionedRowHeader,
11
+ usePaneStatusFooter,
12
+ useRowNotes,
13
+ type DataTableCell,
14
+ type DataTableColumn,
15
+ type PaneFooterSegment,
16
+ type PaneHint,
17
+ type SectionedRow,
18
+ } from "../../../components";
19
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
20
+ import { DetailReader, type DetailFact } from "../../../components/reader/detail-reader";
21
+ import { useAsyncResource, usePluginPaneState } from "../../../public/react";
22
+ import { useThemeColors } from "../../../theme/theme-context";
23
+ import type { PaneProps } from "../../../types/plugin";
24
+ import { Box, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
25
+ import { formatShortDate } from "../../../utils/datetime-format";
26
+ import {
27
+ isVotable,
28
+ optimisticVote,
29
+ ROADMAP_STATUS_LABELS,
30
+ roadmapSections,
31
+ voteFailure,
32
+ votesText,
33
+ withLocalVotes,
34
+ type VoteFailure,
35
+ } from "./model";
36
+
37
+ /** The roadmap calls the pane makes; tests hand it their own. */
38
+ export type RoadmapClient = Pick<typeof apiClient, "getRoadmap" | "setRoadmapVote">;
39
+ export const RoadmapClientContext = createContext<RoadmapClient | null>(null);
40
+
41
+ type Row = SectionedRow<RoadmapItem>;
42
+ type ColumnId = "title" | "blurb" | "votes" | "shipped";
43
+ type Column = DataTableColumn & { id: ColumnId };
44
+
45
+ const rowKey = (row: Row) => row.key;
46
+ const itemKey = (item: RoadmapItem) => item.id;
47
+ const voteSignature = (item: RoadmapItem) => `${item.votes}:${item.voted}`;
48
+
49
+ /** Said once, quietly, to everyone who cannot vote: no upgrade pitch. */
50
+ const PRO_VOTES_SEGMENT: PaneFooterSegment = { id: "pro-votes", parts: [{ text: "Pro members vote", tone: "muted" }] };
51
+ const NO_SEGMENTS: PaneFooterSegment[] = [];
52
+
53
+ function shippedDate(item: RoadmapItem): string {
54
+ return item.shippedAt ? formatShortDate(item.shippedAt, { fallback: "" }) : "";
55
+ }
56
+
57
+ /** `votesCell` is what the votes cell says: the count, or a vote's failure on that row. */
58
+ function buildColumns(items: readonly RoadmapItem[], width: number, votesCell: (item: RoadmapItem) => string): Column[] {
59
+ const longest = Math.max(8, ...items.map((item) => item.title.length));
60
+ const narrow = width < 64;
61
+ const columns: Column[] = narrow
62
+ ? [{ id: "title", label: "Feature", width: Math.min(longest, 24), flexGrow: 1, align: "left" }]
63
+ : [
64
+ { id: "title", label: "Feature", width: Math.min(longest, width >= 120 ? 36 : 26), align: "left" },
65
+ { id: "blurb", label: "Summary", width: 16, flexGrow: 1, align: "left" },
66
+ ];
67
+ columns.push({ id: "votes", label: "Votes", width: Math.max(5, ...items.map((item) => votesCell(item).length)), align: "right" });
68
+ if (!narrow && items.some((item) => item.shippedAt)) {
69
+ columns.push({ id: "shipped", label: "Shipped", width: 12, align: "left" });
70
+ }
71
+ return columns;
72
+ }
73
+
74
+ function RoadmapDetail({ item, failure, width, scrollRef }: {
75
+ item: RoadmapItem;
76
+ failure: VoteFailure | null;
77
+ width: number;
78
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
79
+ }) {
80
+ const colors = useThemeColors();
81
+ // The stack title names the item; the facts and the blurb follow.
82
+ const facts: DetailFact[] = [
83
+ { label: "Status", value: ROADMAP_STATUS_LABELS[item.status] },
84
+ { label: "Votes", value: item.voted ? `${item.votes}, yours included` : String(item.votes) },
85
+ ...(item.shippedAt ? [{ label: "Shipped", value: shippedDate(item) }] : []),
86
+ ...(failure ? [{ label: "Vote", value: failure.reason, color: colors.negative }] : []),
87
+ ];
88
+ return (
89
+ <DetailReader
90
+ ref={scrollRef}
91
+ resetScrollKey={item.id}
92
+ width={width}
93
+ facts={facts}
94
+ body={item.blurb || null}
95
+ bodyFormat="text"
96
+ />
97
+ );
98
+ }
99
+
100
+ export function RoadmapPane({ focused, width, height }: PaneProps) {
101
+ const colors = useThemeColors();
102
+ const client = useContext(RoadmapClientContext) ?? apiClient;
103
+ const access = usePlanAccess();
104
+ // A sign-in, a sign-out or a new plan asks again: the votes and the right to
105
+ // cast one are the account's. The new loader starts from no list, so the
106
+ // last account's marks and vote hint go at once, not when (or if) the new
107
+ // read lands. A refresh of the same session keeps its list.
108
+ const sessionKey = JSON.stringify([apiClient.getCurrentUser()?.id ?? null, access.emailVerified, access.hasProAccess]);
109
+ const loader = useCallback((_force: boolean) => client.getRoadmap(), [client, sessionKey]);
110
+ const resource = useAsyncResource(loader);
111
+ const sessionRef = useRef(sessionKey);
112
+ sessionRef.current = sessionKey;
113
+ const payload = resource.data;
114
+ const canVote = payload?.canVote === true;
115
+
116
+ // This session's votes, over the list until the next read replaces it.
117
+ const [localVotes, setLocalVotes] = useState<Record<string, RoadmapVote>>({});
118
+ useEffect(() => setLocalVotes({}), [resource.updatedAt]);
119
+ const items = useMemo(() => withLocalVotes(payload, localVotes), [payload, localVotes]);
120
+ const rows = useMemo(() => buildSectionedRows(roadmapSections(items), itemKey), [items]);
121
+ const { noteFor, setNote, clearNotes } = useRowNotes<RoadmapItem, VoteFailure>(items, { key: itemKey, signature: voteSignature });
122
+ // Another account's votes and refusals are not this one's.
123
+ useEffect(() => {
124
+ setLocalVotes({});
125
+ clearNotes();
126
+ }, [sessionKey, clearNotes]);
127
+
128
+ const [selectedId, setSelectedId] = usePluginPaneState<string | null>("selectedItemId", null);
129
+ const [openId, setOpenId] = usePluginPaneState<string | null>("openItemId", null);
130
+ const selected = items.find((item) => item.id === selectedId) ?? rows.find(isSectionedItemRow)?.item ?? null;
131
+ const open = openId ? items.find((item) => item.id === openId) ?? null : null;
132
+ useEffect(() => {
133
+ // An item that left the roadmap closes once the list that dropped it is in.
134
+ if (openId && payload && !open) setOpenId(null);
135
+ }, [open, openId, payload]);
136
+ const detailScrollRef = useRef<ScrollBoxRenderable>(null);
137
+
138
+ const inFlight = useRef(new Set<string>());
139
+ const reload = resource.reload;
140
+ const toggleVote = useCallback(async (item: RoadmapItem) => {
141
+ if (!canVote || !isVotable(item) || inFlight.current.has(item.id)) return;
142
+ const voted = !item.voted;
143
+ const session = sessionRef.current;
144
+ inFlight.current.add(item.id);
145
+ setNote(item.id, null);
146
+ setLocalVotes((current) => ({ ...current, [item.id]: optimisticVote(item, voted) }));
147
+ try {
148
+ const answer = await client.setRoadmapVote(item.id, voted);
149
+ // An answer for the account that just left changes nothing on screen.
150
+ if (sessionRef.current !== session) return;
151
+ setLocalVotes((current) => ({ ...current, [item.id]: answer }));
152
+ } catch (error) {
153
+ if (sessionRef.current !== session) return;
154
+ setLocalVotes((current) => ({ ...current, [item.id]: { id: item.id, votes: item.votes, voted: item.voted } }));
155
+ setNote(item.id, voteFailure(error, voted));
156
+ } finally {
157
+ inFlight.current.delete(item.id);
158
+ }
159
+ }, [canVote, client, setNote]);
160
+
161
+ usePaneRefreshKey(() => void reload(), { focused });
162
+
163
+ const target = open ?? selected;
164
+ const hints = useMemo<PaneHint[]>(() => (
165
+ canVote && target && isVotable(target)
166
+ ? [{ id: "vote", key: "v", label: target.voted ? " unvote" : "ote", title: target.voted ? "Take vote back" : "Vote", onPress: () => void toggleVote(target) }]
167
+ : []
168
+ ), [canVote, target, toggleVote]);
169
+ usePaneStatusFooter({
170
+ registrationId: "roadmap",
171
+ // A failed refresh keeps the list; the failure goes in the footer.
172
+ loading: resource.loading && !!payload,
173
+ error: payload ? resource.error : null,
174
+ info: !canVote && items.length > 0 ? [PRO_VOTES_SEGMENT] : NO_SEGMENTS,
175
+ hints,
176
+ });
177
+
178
+ const columns = useMemo(
179
+ () => buildColumns(items, width, (item) => noteFor(item)?.status ?? votesText(item)),
180
+ [items, noteFor, width],
181
+ );
182
+ const renderCell = useCallback((row: Row, column: Column): DataTableCell => {
183
+ if (!isSectionedItemRow(row)) return EMPTY_TABLE_CELL;
184
+ const item = row.item;
185
+ switch (column.id) {
186
+ case "title":
187
+ return { text: item.title, color: colors.textBright };
188
+ case "blurb":
189
+ return { text: item.blurb, color: colors.textDim };
190
+ case "votes": {
191
+ const failure = noteFor(item);
192
+ if (failure) return { text: failure.status, color: colors.negative };
193
+ return {
194
+ text: votesText(item),
195
+ value: item.votes,
196
+ color: item.voted ? colors.positive : isVotable(item) ? colors.text : colors.textDim,
197
+ ...(item.voted ? { attributes: TextAttributes.BOLD } : {}),
198
+ };
199
+ }
200
+ case "shipped":
201
+ return { text: shippedDate(item), color: colors.textDim };
202
+ }
203
+ }, [colors, noteFor]);
204
+
205
+ return (
206
+ <DataTableStackView<Row, Column>
207
+ focused={focused}
208
+ detailOpen={!!open}
209
+ onBack={() => setOpenId(null)}
210
+ detailTitle={open?.title}
211
+ detailContent={open ? (
212
+ <RoadmapDetail item={open} failure={noteFor(open)} width={width} scrollRef={detailScrollRef} />
213
+ ) : <Box flexGrow={1} />}
214
+ detailScrollRef={detailScrollRef}
215
+ rootWidth={width}
216
+ rootHeight={height}
217
+ // A refresh keeps the list on screen; only a first read waits or fails in the body.
218
+ status={payload ? undefined : {
219
+ loading: resource.loading,
220
+ error: resource.error,
221
+ subject: "Roadmap",
222
+ retry: { onRetry: reload, pending: resource.loading },
223
+ }}
224
+ emptyStateTitle="Nothing on the roadmap yet."
225
+ columns={columns}
226
+ items={rows}
227
+ getItemKey={rowKey}
228
+ isNavigable={isSectionedItemRow}
229
+ renderSectionHeader={renderSectionedRowHeader}
230
+ selection={{
231
+ kind: "id",
232
+ selectedId: selected?.id ?? null,
233
+ getId: rowKey,
234
+ onChange: (id) => setSelectedId(String(id)),
235
+ }}
236
+ onActivate={(row) => { if (isSectionedItemRow(row)) setOpenId(row.item.id); }}
237
+ sortColumnId={null}
238
+ sortDirection="asc"
239
+ renderCell={renderCell}
240
+ selectedTextOverridesCellColor
241
+ showHorizontalScrollbar={false}
242
+ />
243
+ );
244
+ }
@@ -17,6 +17,7 @@ import {
17
17
  type LiveScan,
18
18
  } from "./live-model";
19
19
  import { LIVE_SCAN_INDEXES } from "../../../api-client/live-scan";
20
+ import { formatHiloPrice } from "./hilo-model";
20
21
 
21
22
  /** `all`, an index (`sp500`) or a sector (`Energy`), as `--universe` takes it. */
22
23
  function parseHeadlessUniverse(value: string): LiveScan["universe"] {
@@ -61,7 +62,24 @@ function emptyText(payload: LiveScanPayload): string {
61
62
 
62
63
  const number = (value: unknown) => (typeof value === "number" ? value : undefined);
63
64
 
65
+ /** `2026-10-27 19:59 UTC`: when the scan last ran, to the minute. */
66
+ const utcMinute = (at: number) => `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
67
+
68
+ /**
69
+ * What the report says above the matches: outside the session that the
70
+ * market is closed and when the scan last ran, since a delay means nothing
71
+ * then; in the session a delayed feed's delay.
72
+ */
73
+ function headerNotice(payload: LiveScanPayload): string | null {
74
+ if (payload.status === "closed") {
75
+ const last = payload.asOf ?? payload.takenAt;
76
+ return last != null && Number.isFinite(last) ? `Market closed; last scan ${utcMinute(last)}` : "Market closed";
77
+ }
78
+ return payload.access === "delayed" ? `Delayed ${payload.delayMinutes} minutes` : null;
79
+ }
80
+
64
81
  function projectLiveScan(payload: LiveScanPayload, scan: LiveScan): HeadlessSnapshotResult {
82
+ const notice = headerNotice(payload);
65
83
  return {
66
84
  asOf: new Date(payload.asOf ?? payload.takenAt ?? Date.now()).toISOString(),
67
85
  empty: emptyText(payload),
@@ -80,7 +98,7 @@ function projectLiveScan(payload: LiveScanPayload, scan: LiveScan): HeadlessSnap
80
98
  ...(payload.status === "degraded" ? { errors: ["Live scanner degraded"] } : {}),
81
99
  freshness: freshness(payload),
82
100
  metadata: {
83
- ...(payload.access === "delayed" ? { notices: [`Delayed ${payload.delayMinutes} minutes`] } : {}),
101
+ ...(notice ? { notices: [notice] } : {}),
84
102
  ...(payload.warnings.length ? { warnings: payload.warnings } : {}),
85
103
  status: payload.status,
86
104
  access: payload.access,
@@ -117,7 +135,8 @@ export const liveScanHeadless: HeadlessPaneDefinition<"snapshot"> = {
117
135
  columns: [
118
136
  { key: "symbol", header: "Symbol" },
119
137
  { key: "name", header: "Name" },
120
- { key: "price", header: "Price", align: "right" },
138
+ // At the pane's precision: cents, or four decimals under a dollar; the JSON keeps the feed's digits.
139
+ { key: "price", header: "Price", align: "right", format: (value) => typeof value === "number" ? formatHiloPrice(value) : "--" },
121
140
  { key: "changePercent", header: "Chg %", align: "right", format: (value) => formatPercentRaw(number(value)) },
122
141
  { key: "relativeVolume", header: "RVol", align: "right", format: (value) => formatMultiple(number(value)) },
123
142
  { key: "volume", header: "Volume", align: "right" },
@@ -1,12 +1,34 @@
1
1
  import type { PaneLock } from "../../../types/plugin";
2
- import { proLockNote } from "../shared/pro-lock-note";
2
+ import { isOptionsUnavailableError } from "../../../market-data/options-alternatives";
3
+ import { resolveHeadlessInstrument } from "../shared/headless-market-data";
4
+ import { proLockNote, type FreeRoute } from "../shared/pro-lock-note";
5
+
6
+ /**
7
+ * The listing's option chain, which needs no account, where options are
8
+ * served for it: LHA:XETRA has none, and `options LHA:XETRA` says so.
9
+ */
10
+ const OPTIONS_ROUTE: FreeRoute = {
11
+ text: (ticker, company) => `gloom options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.`,
12
+ answers: async (symbol, context) => {
13
+ const { symbol: ticker, exchange } = await resolveHeadlessInstrument(context, symbol);
14
+ if (!context.marketData.getOptionsChain) return false;
15
+ try {
16
+ const chain = await context.marketData.getOptionsChain(ticker, exchange ?? "");
17
+ return chain.expirationDates.length + chain.calls.length + chain.puts.length > 0;
18
+ } catch (error) {
19
+ if (isOptionsUnavailableError(error)) return false;
20
+ throw error;
21
+ }
22
+ },
23
+ };
3
24
 
4
25
  /**
5
26
  * FLOW is Pro whole: the one scanner with no delayed tier to fall back to.
6
27
  * The options chain needs no account and lists each contract's day volume and
7
28
  * open interest, the raw counts behind a sweep or a block, without the prints.
8
29
  * FLOW takes a ticker only to narrow the market to one name, so the note
9
- * repeats it only when the reader gave one.
30
+ * repeats it only when the reader gave one, and only when the listing has
31
+ * options served: FLOW's prints are of the same US options.
10
32
  */
11
33
  export function flowLockNote(lock: PaneLock): Promise<string> {
12
34
  return proLockNote(lock, {
@@ -14,6 +36,7 @@ export function flowLockNote(lock: PaneLock): Promise<string> {
14
36
  argument: "optional",
15
37
  does: "it streams real-time sweeps, blocks and large premium prints across US options exchanges",
16
38
  shows: "it",
17
- free: [{ text: (ticker, company) => `gloom options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.` }],
39
+ free: [OPTIONS_ROUTE],
40
+ noneFor: (ticker) => `Options are not available for ${ticker}, so FLOW has no prints of it.`,
18
41
  });
19
42
  }
@@ -20,6 +20,7 @@ import { fetchFundFacts, fundFactEntries, fundFactPublishers, fundFactsEmpty, fu
20
20
  import { REPORT_ROWS } from "../shared/headless-options";
21
21
  import { fundAbroadProfile, venueAbroad, type FundAbroadProfile } from "./fund-abroad";
22
22
  import { listingAnnouncementsPage } from "../../../utils/exchange-announcements";
23
+ import { companyDataNotes } from "../../../types/company-from";
23
24
 
24
25
  const SEC_COLUMNS: HeadlessPaneColumn[] = [
25
26
  {
@@ -140,7 +141,7 @@ export function createSecHeadless(dependencies: SecHeadlessDependencies = defaul
140
141
  return {
141
142
  rows,
142
143
  textColumns: secTextColumns(rows),
143
- ...(note ? { notes: [note] } : {}),
144
+ ...(note || filings[0]?.companyFrom ? { notes: [...(note ? [note] : []), ...companyDataNotes(filings[0]?.companyFrom)] } : {}),
144
145
  ...(form ? { empty: `No ${form} filings found for ${symbol}.` }
145
146
  : page ? { empty: `${symbol} files no SEC reports. ${page.name}: ${page.url}` } : {}),
146
147
  metadata: {
@@ -12,6 +12,7 @@ import { Box, Text, useUiCapabilities, type ScrollBoxRenderable } from "../../..
12
12
  import { FILL_REST } from "../../../components/ui/fill";
13
13
  import { t, tf } from "../../../i18n";
14
14
  import { EmptyState, ExternalLinkText, FeedDataTableStackView, PaneLinkMenu, QueryBar, Spinner, TickerBadgeList, usePaneNoticeFooter, useTableLoadMore, type FeedDataTableItem, type PaneHint, type QueryBarFilter } from "../../../components";
15
+ import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
15
16
  import { displayWidth } from "../../../utils/format";
16
17
  import type { DetailFact } from "../../../components/reader/detail-reader";
17
18
  import type { ThemeColors } from "../../../theme/colors";
@@ -466,6 +467,8 @@ function SecView({ width, height, focused, paneType }: { width: number; height:
466
467
  showOpenHint: !!(openFiling ? openFiling.filingUrl : factsUrl ?? abroadPage ?? announcements),
467
468
  });
468
469
 
470
+ // A foreign line's filings are its company's, read from the home line.
471
+ useCompanyDataFooter(issuerFilings[0]?.companyFrom, !openFiling);
469
472
  usePaneNoticeFooter({
470
473
  registrationId: "sec:form-filter",
471
474
  notices: filterHistoryCapped
@@ -1,6 +1,6 @@
1
1
  import { logFailure } from "../../../utils/log-failure";
2
- import { ApiRequestError, isAccessDenied } from "../../../api-client/errors";
3
- import type { PluginCacheResult } from "../../../data/plugin-cache";
2
+ import { ApiRequestError } from "../../../api-client/errors";
3
+ import { savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
4
4
 
5
5
  interface CloudCache<T> {
6
6
  get(key: string, options?: { allowExpired?: boolean }): PluginCacheResult<T> | null;
@@ -12,6 +12,8 @@ export interface CloudResource<T> {
12
12
  payload: T;
13
13
  stale: boolean;
14
14
  refreshError: string | null;
15
+ /** When the copy shown was saved, when it is one because its refresh failed. */
16
+ savedAt?: number;
15
17
  }
16
18
 
17
19
  /**
@@ -36,8 +38,11 @@ const isAbort = (error: unknown) => !!error && typeof error === "object" && (err
36
38
 
37
39
  /**
38
40
  * Loads through the cache, falling back to the cached copy when a refresh
39
- * fails. A refused session still throws, so the pane shows its sign-in wall
40
- * instead of data the account can no longer see.
41
+ * fails for a reason asking again may get past (`isTransientFailure`). A
42
+ * refusal still throws: a refused session, so the pane shows its sign-in wall
43
+ * instead of data the account can no longer see, any other permanent client
44
+ * error (an unknown series, a route not served), and a reply that fails
45
+ * validation.
41
46
  *
42
47
  * Callers of one key share the request in flight, which runs on the first
43
48
  * caller's `signal`. When that caller goes away (a tab left while it loads,
@@ -57,14 +62,22 @@ export async function loadCloudResource<T>(
57
62
  throw error;
58
63
  });
59
64
  if (!result || cancelledByOther(result.error)) result = await cache.load(key, fetch, { force });
60
- if (isAccessDenied(result.error)) throw result.error;
61
- return { payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null };
65
+ const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
66
+ // A copy served because its refresh failed is one by where it came from, whatever the failure said (or did not).
67
+ return {
68
+ payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null,
69
+ ...(saved ? { savedAt: result.fetchedAt } : {}),
70
+ };
62
71
  }
63
72
 
64
73
  /**
65
74
  * Turns a route the server does not serve yet (404, or the given statuses)
66
75
  * into a message that says what is unavailable; other errors pass through.
76
+ * The status stays, so a 404 is still a refusal no saved copy answers for and
77
+ * a 503 still a failure one may.
67
78
  */
68
79
  export function unavailableOnServer(error: unknown, message: string, statuses: readonly number[] = [404]): unknown {
69
- return error instanceof ApiRequestError && statuses.includes(error.status ?? 0) ? new Error(message) : error;
80
+ return error instanceof ApiRequestError && statuses.includes(error.status ?? 0)
81
+ ? new ApiRequestError(message, error.status, error.retryAfterMs, error.code, error.details)
82
+ : error;
70
83
  }
@@ -1,11 +1,15 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import { loadCachedFredSeries } from "../../../sources/gloomberb-cloud/fred-series";
3
3
 
4
- /** A FRED series from `start` on, oldest first, through the shared FRED cache. */
4
+ /**
5
+ * A FRED series from `start` on, oldest first, through the shared FRED cache;
6
+ * `savedAt` when a failed refresh left the copy saved then answering.
7
+ */
5
8
  export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
6
9
  points: Array<{ date: string; value: number | null }>;
7
10
  stale: boolean;
8
11
  refreshError?: string;
12
+ savedAt?: number;
9
13
  }> {
10
14
  const startDate = start ? start.toISOString().slice(0, 10) : "1900-01-01";
11
15
  const result = await loadCachedFredSeries(
@@ -16,5 +20,6 @@ export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
16
20
  points: (result.data.observations ?? []).map((row) => ({ date: row.date, value: row.value })),
17
21
  stale: result.stale,
18
22
  ...(result.refreshError ? { refreshError: result.refreshError } : {}),
23
+ ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}),
19
24
  };
20
25
  }
@@ -6,7 +6,7 @@ import { clipPriceHistoryToRange } from "../../../time-series/history-window";
6
6
  import { canonicalExchange, parsePublicTickerKey } from "../../../utils/exchanges";
7
7
  import { nonUsSecListingVenue } from "../../../utils/sec";
8
8
 
9
- export async function resolveHeadlessInstrument(ctx: HeadlessPaneContext, symbol: string) {
9
+ export async function resolveHeadlessInstrument(ctx: Pick<HeadlessPaneContext, "resolveInstrument">, symbol: string) {
10
10
  return ctx.resolveInstrument ? ctx.resolveInstrument(symbol) : parsePublicTickerKey(symbol);
11
11
  }
12
12
 
@@ -39,7 +39,7 @@ export async function resolveHeadlessIssuerListing(ctx: HeadlessPaneContext, key
39
39
  return resolveIssuerListing(key, exchange, ctx.marketData);
40
40
  }
41
41
 
42
- export async function loadHeadlessFinancials(ctx: HeadlessPaneContext, key: string) {
42
+ export async function loadHeadlessFinancials(ctx: Pick<HeadlessPaneContext, "marketData" | "resolveInstrument">, key: string) {
43
43
  const { symbol, exchange } = await resolveHeadlessInstrument(ctx, key);
44
44
  return ctx.marketData.getTickerFinancials(symbol, exchange ?? "");
45
45
  }