gloomberb 0.18.4 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +32 -1
  5. package/src/account/auth-model.ts +7 -3
  6. package/src/api-client/auth.ts +9 -4
  7. package/src/api-client/catalysts.ts +2 -1
  8. package/src/api-client/data.ts +14 -0
  9. package/src/api-client/index.ts +160 -11
  10. package/src/api-client/live-scan.ts +91 -0
  11. package/src/api-client/members.ts +12 -0
  12. package/src/api-client/request.ts +222 -41
  13. package/src/api-client/research-activity.ts +3 -10
  14. package/src/api-client/types.ts +6 -0
  15. package/src/app/keybindings/index.ts +3 -0
  16. package/src/app/keybindings/labels.ts +18 -1
  17. package/src/app/keybindings/resolve.ts +14 -0
  18. package/src/app/runtime/broker-import.ts +43 -26
  19. package/src/architecture/core-builtin-allowlist.json +1 -1
  20. package/src/cli/commands/fred-series.ts +5 -0
  21. package/src/cli/commands/keybindings.ts +23 -0
  22. package/src/cli/commands/market.ts +68 -22
  23. package/src/cli/commands/options-chain.ts +59 -3
  24. package/src/cli/commands/overview.ts +119 -31
  25. package/src/cli/commands/search.ts +18 -2
  26. package/src/cli/commands/ticker.ts +2 -1
  27. package/src/cli/errors.ts +2 -2
  28. package/src/cli/export-source.ts +29 -1
  29. package/src/cli/glossary.ts +49 -0
  30. package/src/cli/history-rows.ts +30 -7
  31. package/src/cli/known-gaps.ts +5 -2
  32. package/src/cli/listing-arg.ts +16 -5
  33. package/src/cli/pane-functions/freshness.ts +25 -13
  34. package/src/cli/pane-functions/function-help.ts +10 -3
  35. package/src/cli/pane-functions/headless.ts +39 -5
  36. package/src/cli/pane-functions/index.ts +10 -3
  37. package/src/cli/pane-functions/market-session.ts +15 -5
  38. package/src/cli/pane-functions/report.ts +5 -1
  39. package/src/cli/pane-functions/resolver.ts +18 -2
  40. package/src/cli/quote-answer.ts +10 -4
  41. package/src/cli/report-tables.ts +18 -7
  42. package/src/cli/restore-plugins.ts +13 -1
  43. package/src/cli/result.ts +57 -5
  44. package/src/components/chart/composite/composite-chart.tsx +22 -4
  45. package/src/components/chart/composite/format.ts +27 -0
  46. package/src/components/chart/composite/levels.ts +2 -0
  47. package/src/components/chart/composite/panel-series.ts +1 -0
  48. package/src/components/chart/composite/panel-surface.tsx +9 -2
  49. package/src/components/chart/composite/range-statistics.ts +62 -0
  50. package/src/components/chart/composite/scene.ts +7 -3
  51. package/src/components/chart/composite/series-axis-tags.tsx +16 -9
  52. package/src/components/chart/composite/types.ts +4 -0
  53. package/src/components/command-bar/panel/prompt-binding.ts +0 -8
  54. package/src/components/command-bar/workflow/alert.ts +1 -1
  55. package/src/components/layout/header.tsx +26 -40
  56. package/src/components/layout/pane/footer/index.tsx +1 -0
  57. package/src/components/layout/pane/footer/model.ts +27 -0
  58. package/src/components/onboarding/onboarding-frame.tsx +1 -1
  59. package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
  60. package/src/components/ui/display.tsx +5 -1
  61. package/src/core/state/app/layout.ts +2 -0
  62. package/src/core/state/app/state.ts +9 -0
  63. package/src/core/state/app/types.ts +3 -0
  64. package/src/data/saved-copies.ts +55 -16
  65. package/src/docs/snapshot.json +361 -84
  66. package/src/i18n/es.ts +1 -1
  67. package/src/i18n/ja.ts +1 -1
  68. package/src/i18n/ko.ts +1 -1
  69. package/src/i18n/zh-cn.ts +1 -1
  70. package/src/i18n/zh-tw.ts +1 -1
  71. package/src/market-data/coordinator/financials.ts +10 -2
  72. package/src/market-data/coordinator/index.ts +3 -1
  73. package/src/market-data/coordinator/quotes.ts +4 -2
  74. package/src/market-data/market/change-basis.ts +33 -5
  75. package/src/market-data/market/dated-previous-close.ts +203 -0
  76. package/src/market-data/market/futures-week.ts +68 -0
  77. package/src/market-data/market/status.ts +16 -4
  78. package/src/market-data/options-alternatives.ts +60 -0
  79. package/src/market-data/quotes/contributions.ts +20 -2
  80. package/src/market-data/quotes/resolution.ts +3 -0
  81. package/src/market-data/selectors.ts +3 -0
  82. package/src/notifications/notification-log.ts +6 -4
  83. package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
  84. package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
  85. package/src/plugins/builtin/alerts/cli.ts +59 -20
  86. package/src/plugins/builtin/alerts/command.ts +7 -0
  87. package/src/plugins/builtin/alerts/constants.ts +1 -1
  88. package/src/plugins/builtin/alerts/fired-log.ts +45 -4
  89. package/src/plugins/builtin/alerts/format.ts +8 -2
  90. package/src/plugins/builtin/alerts/headless.ts +10 -5
  91. package/src/plugins/builtin/alerts/history-rows.ts +5 -2
  92. package/src/plugins/builtin/alerts/history.ts +2 -0
  93. package/src/plugins/builtin/alerts/index.tsx +66 -16
  94. package/src/plugins/builtin/alerts/live.ts +3 -3
  95. package/src/plugins/builtin/alerts/metric-command.ts +61 -0
  96. package/src/plugins/builtin/alerts/metric-data.ts +70 -0
  97. package/src/plugins/builtin/alerts/metric-model.ts +175 -0
  98. package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
  99. package/src/plugins/builtin/alerts/pane.tsx +42 -29
  100. package/src/plugins/builtin/alerts/storage.ts +33 -10
  101. package/src/plugins/builtin/alerts/types.ts +3 -0
  102. package/src/plugins/builtin/backtest/headless.ts +10 -5
  103. package/src/plugins/builtin/backtest/pane.tsx +9 -4
  104. package/src/plugins/builtin/backtest/presets.ts +29 -3
  105. package/src/plugins/builtin/broker-manager/index.tsx +3 -2
  106. package/src/plugins/builtin/broker-manager/model.ts +6 -1
  107. package/src/plugins/builtin/broker-trading/controller.ts +22 -12
  108. package/src/plugins/builtin/broker-trading/index.tsx +13 -1
  109. package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
  110. package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
  111. package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
  112. package/src/plugins/builtin/catalysts/model.ts +2 -1
  113. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
  114. package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
  115. package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
  116. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
  117. package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
  118. package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
  119. package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
  120. package/src/plugins/builtin/chart-composer/formula.ts +71 -18
  121. package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
  122. package/src/plugins/builtin/chart-composer/headless.ts +13 -2
  123. package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
  124. package/src/plugins/builtin/chart-composer/presets.ts +50 -15
  125. package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
  126. package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
  127. package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
  128. package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
  129. package/src/plugins/builtin/chat/content/composer.tsx +13 -6
  130. package/src/plugins/builtin/chat/content/footer.tsx +6 -3
  131. package/src/plugins/builtin/chat/content/index.tsx +50 -21
  132. package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
  133. package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
  134. package/src/plugins/builtin/chat/index.tsx +23 -0
  135. package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
  136. package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
  137. package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
  138. package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
  139. package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
  140. package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
  141. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
  142. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  143. package/src/plugins/builtin/econ/calendar-model.ts +14 -9
  144. package/src/plugins/builtin/econ/figures.ts +72 -0
  145. package/src/plugins/builtin/econ/index.tsx +33 -3
  146. package/src/plugins/builtin/econ/types.ts +2 -0
  147. package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
  148. package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
  149. package/src/plugins/builtin/econ-statistics/view.ts +3 -0
  150. package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
  151. package/src/plugins/builtin/exposure/charts.tsx +2 -2
  152. package/src/plugins/builtin/exposure/model.ts +13 -1
  153. package/src/plugins/builtin/exposure/pane.tsx +4 -4
  154. package/src/plugins/builtin/futures/index.tsx +1 -0
  155. package/src/plugins/builtin/futures/model.ts +2 -1
  156. package/src/plugins/builtin/futures/table.tsx +2 -2
  157. package/src/plugins/builtin/fx-matrix/client.ts +6 -1
  158. package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
  159. package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
  160. package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
  161. package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
  162. package/src/plugins/builtin/iv-history/headless.ts +3 -0
  163. package/src/plugins/builtin/macro-day/pane.tsx +7 -5
  164. package/src/plugins/builtin/market-movers/footer.ts +2 -2
  165. package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
  166. package/src/plugins/builtin/members/client.ts +19 -4
  167. package/src/plugins/builtin/members/headless.ts +20 -7
  168. package/src/plugins/builtin/members/index.tsx +9 -9
  169. package/src/plugins/builtin/members/model.ts +23 -4
  170. package/src/plugins/builtin/members/pane.tsx +25 -15
  171. package/src/plugins/builtin/options/analytics.ts +26 -2
  172. package/src/plugins/builtin/options/model-spot.ts +5 -2
  173. package/src/plugins/builtin/options/strike-window.ts +2 -1
  174. package/src/plugins/builtin/options/table.ts +1 -7
  175. package/src/plugins/builtin/options/view.tsx +25 -8
  176. package/src/plugins/builtin/options-positioning/client.ts +19 -0
  177. package/src/plugins/builtin/options-positioning/headless.ts +54 -14
  178. package/src/plugins/builtin/options-positioning/index.tsx +3 -3
  179. package/src/plugins/builtin/options-positioning/model.ts +47 -8
  180. package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
  181. package/src/plugins/builtin/perps/freshness.ts +12 -10
  182. package/src/plugins/builtin/perps/live-rows.ts +10 -3
  183. package/src/plugins/builtin/perps/pane.tsx +15 -11
  184. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
  185. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  186. package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
  187. package/src/plugins/builtin/rate-path/pane.tsx +2 -2
  188. package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
  189. package/src/plugins/builtin/scanner/feed.ts +2 -2
  190. package/src/plugins/builtin/scanner/index.ts +24 -1
  191. package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
  192. package/src/plugins/builtin/scanner/live-feed.ts +168 -0
  193. package/src/plugins/builtin/scanner/live-headless.ts +114 -0
  194. package/src/plugins/builtin/scanner/live-model.ts +351 -0
  195. package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
  196. package/src/plugins/builtin/scanner/live-track.ts +186 -0
  197. package/src/plugins/builtin/series-inventory.ts +1 -0
  198. package/src/plugins/builtin/shared/macro-releases.ts +65 -0
  199. package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
  200. package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
  201. package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
  202. package/src/plugins/builtin/themes/headless.ts +6 -4
  203. package/src/plugins/builtin/themes/model.ts +1 -1
  204. package/src/plugins/builtin/themes/pane.tsx +3 -3
  205. package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
  206. package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
  207. package/src/plugins/builtin/vol-surface/client.ts +6 -4
  208. package/src/plugins/builtin/vol-surface/headless.ts +62 -13
  209. package/src/plugins/builtin/vol-surface/index.tsx +2 -2
  210. package/src/plugins/builtin/vol-surface/model.ts +22 -7
  211. package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
  212. package/src/plugins/builtin/vol-surface/tables.ts +31 -3
  213. package/src/plugins/builtin/volatility/dvol.ts +46 -0
  214. package/src/plugins/builtin/volatility/pane.tsx +2 -2
  215. package/src/plugins/builtin/volatility/series.ts +26 -0
  216. package/src/plugins/builtin/world-indices/footer.ts +1 -1
  217. package/src/plugins/catalog-browser.ts +13 -8
  218. package/src/portfolio/column-values.ts +5 -5
  219. package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
  220. package/src/renderers/browser/app-services.ts +1 -7
  221. package/src/renderers/browser/config-host.ts +2 -55
  222. package/src/renderers/browser/main.tsx +7 -57
  223. package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
  224. package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
  225. package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
  226. package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
  227. package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
  228. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
  229. package/src/renderers/electrobun/bun/index.ts +25 -1
  230. package/src/renderers/electrobun/shared/app-name.ts +17 -0
  231. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
  232. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
  233. package/src/renderers/opentui/toast-host.tsx +71 -43
  234. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  235. package/src/sources/provider-router/batches.ts +10 -2
  236. package/src/sources/provider-router/financial-routes.ts +3 -3
  237. package/src/sources/provider-router/history.ts +28 -7
  238. package/src/sources/provider-router/index.ts +146 -10
  239. package/src/sources/provider-router/search.ts +13 -2
  240. package/src/state/app/context/index.tsx +3 -0
  241. package/src/sync/auth-transition.ts +19 -9
  242. package/src/sync/baseline.ts +10 -4
  243. package/src/sync/controller.ts +66 -16
  244. package/src/sync/react.ts +4 -1
  245. package/src/sync/types.ts +3 -0
  246. package/src/telemetry/usage-counts.ts +0 -18
  247. package/src/theme/colors.ts +8 -0
  248. package/src/tickers/company-name.ts +59 -0
  249. package/src/tickers/listing-venues.ts +23 -0
  250. package/src/tickers/search/bare-symbol.ts +36 -0
  251. package/src/tickers/search/index.ts +36 -7
  252. package/src/tickers/search/ranking.ts +10 -1
  253. package/src/tickers/search/types.ts +5 -0
  254. package/src/time-series/base-series.ts +13 -5
  255. package/src/time-series/daily-valuation.ts +62 -0
  256. package/src/time-series/field-catalog.ts +1 -3
  257. package/src/time-series/forward-valuation.ts +32 -0
  258. package/src/time-series/fundamentals.ts +155 -7
  259. package/src/time-series/hooks.ts +1 -0
  260. package/src/time-series/price-units.ts +72 -2
  261. package/src/time-series/resolve-context.ts +5 -1
  262. package/src/time-series/resolve.ts +61 -12
  263. package/src/time-series/series-presentation.ts +40 -6
  264. package/src/time-series/spec.ts +2 -1
  265. package/src/time-series/studies.ts +270 -72
  266. package/src/time-series/transforms.ts +1 -0
  267. package/src/time-series/types.ts +5 -0
  268. package/src/time-series/valuation-releases.ts +39 -0
  269. package/src/types/data-provider.ts +9 -1
  270. package/src/types/desktop-window.ts +2 -0
  271. package/src/types/financials.ts +41 -1
  272. package/src/types/headless.ts +42 -4
  273. package/src/types/instrument.ts +3 -0
  274. package/src/types/plugin.ts +1 -1
  275. package/src/updater/command-names.ts +12 -7
  276. package/src/utils/financial-statements.ts +6 -1
  277. package/src/utils/network-failure.ts +9 -6
  278. package/src/utils/option-expiry.ts +48 -1
  279. package/src/utils/options.ts +10 -0
  280. package/src/utils/plugin-proxy-hosts.json +1 -0
  281. package/src/utils/price-history.ts +5 -0
  282. package/src/renderers/browser/first-screen.ts +0 -193
@@ -330,11 +330,15 @@ function buildAxisDomain(
330
330
  scale: PanelScale,
331
331
  rows: number,
332
332
  manualScale?: CompositeManualValueScale,
333
+ referenceLevels?: BuildCompositeChartSceneOptions["referenceLevels"],
333
334
  ): CompositeAxisDomain | undefined {
334
335
  const axisSeries = series.filter((entry) => entry.axis === side);
335
336
  if (axisSeries.length === 0) return undefined;
336
337
  const values = axisSeries.flatMap(seriesDomainValues);
337
- const fitted = paddedDomain([...values, ...priorCloseInReach(axisSeries, values)], scale);
338
+ const references = referenceLevels && axisSeries.some((entry) => entry.id === referenceLevels.seriesId)
339
+ ? referenceLevels.items.map((level) => level.value).filter(Number.isFinite)
340
+ : [];
341
+ const fitted = paddedDomain([...values, ...priorCloseInReach(axisSeries, values), ...references], scale);
338
342
  const scaleKey = valueScaleKey(scale, axisSeries);
339
343
  // A range set by hand holds while the axis plots the same series and some
340
344
  // of their data is in it; otherwise the axis fits the data again.
@@ -849,8 +853,8 @@ export function buildCompositeChartScene(
849
853
  ? dataSeries.filter((entry) => entry.panelId === panel.id)
850
854
  : panelSeries;
851
855
  const rows = panelHeights.get(panel.id) ?? 1;
852
- const left = buildAxisDomain("left", domainSeries, scale, rows, options.valueScales?.[valueScaleSlot(panel.id, "left")]);
853
- const right = buildAxisDomain("right", domainSeries, scale, rows, options.valueScales?.[valueScaleSlot(panel.id, "right")]);
856
+ const left = buildAxisDomain("left", domainSeries, scale, rows, options.valueScales?.[valueScaleSlot(panel.id, "left")], options.referenceLevels);
857
+ const right = buildAxisDomain("right", domainSeries, scale, rows, options.valueScales?.[valueScaleSlot(panel.id, "right")], options.referenceLevels);
854
858
  const axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>> = { left, right };
855
859
  return {
856
860
  id: panel.id,
@@ -7,20 +7,23 @@ import type { SeriesAxisTag } from "./panel-labels";
7
7
  * ties. A forward pass separates neighbours; a backward pass fits the bottom
8
8
  * edge. Panels shorter than the readout use parallel lanes, not dropped tags
9
9
  * or smaller type. The gutter reserves those lanes before the cursor moves.
10
+ * Only a tag pushed off its value's height gets a leader: a tick beside a tag
11
+ * still on its value reads as a minus sign (`-8k` for a positive 8k).
10
12
  */
11
13
  export function placeSeriesAxisTags(tags: readonly SeriesAxisTag[], pixelHeight: number, labelHeight: number) {
12
14
  const height = Math.max(pixelHeight, labelHeight);
13
15
  const capacity = Math.max(1, Math.floor(height / labelHeight));
14
16
  const sorted = tags.map((tag, order) => ({ ...tag, order, anchorY: clamp(tag.yRatio, 0, 1) * Math.max(pixelHeight - 1, 0) }))
15
17
  .sort((a, b) => a.anchorY - b.anchorY || a.order - b.order);
16
- const placed: Array<SeriesAxisTag & { anchorY: number; centerY: number; lane: number }> = [];
18
+ const placed: Array<SeriesAxisTag & { anchorY: number; centerY: number; lane: number; leader: boolean }> = [];
17
19
  for (let start = 0; start < sorted.length; start += capacity) {
18
20
  const lane = sorted.slice(start, start + capacity);
19
21
  const centers = lane.map((tag) => clamp(tag.anchorY, labelHeight / 2, height - labelHeight / 2));
20
22
  for (let i = 1; i < centers.length; i++) centers[i] = Math.max(centers[i]!, centers[i - 1]! + labelHeight);
21
23
  if (centers.length) centers[centers.length - 1] = Math.min(centers.at(-1)!, height - labelHeight / 2);
22
24
  for (let i = centers.length - 2; i >= 0; i--) centers[i] = Math.min(centers[i]!, centers[i + 1]! - labelHeight);
23
- lane.forEach((tag, index) => placed.push({ ...tag, centerY: centers[index]!, lane: Math.floor(start / capacity) }));
25
+ lane.forEach((tag, index) => placed.push({ ...tag, centerY: centers[index]!, lane: Math.floor(start / capacity),
26
+ leader: Math.abs(centers[index]! - tag.anchorY) > labelHeight / 2 }));
24
27
  }
25
28
  return placed;
26
29
  }
@@ -37,13 +40,14 @@ export function SeriesAxisTags({ placed, pixelHeight, lanes, background }: {
37
40
  const edge = left ? "right" : "left";
38
41
  const inset = `${tag.lane * 100 / lanes}%`;
39
42
  return <Box key={tag.seriesId} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
40
- {/* The tick stays at the observation, even when the badge must move. */}
43
+ {/* The anchor stays at the observation; it draws a tick when the badge had to move off it. */}
41
44
  <Box
42
45
  data-gloom-role="chart-series-axis-anchor"
43
46
  data-series-id={tag.seriesId}
44
- style={{ position: "absolute", [edge]: 0, top: percent(tag.anchorY), width: `calc(${inset} + .5em)`, height: 0, borderTop: `1px ${tag.carried ? "dashed" : "solid"} ${tag.color}` }}
47
+ data-leader={tag.leader ? "true" : undefined}
48
+ style={{ position: "absolute", [edge]: 0, top: percent(tag.anchorY), width: `calc(${inset} + .5em)`, height: 0, borderTop: tag.leader ? `1px ${tag.carried ? "dashed" : "solid"} ${tag.color}` : undefined }}
45
49
  />
46
- <Box style={{ position: "absolute", [edge]: `calc(${inset} + .25em)`, top: percent(Math.min(tag.anchorY, tag.centerY)), height: percent(Math.abs(tag.centerY - tag.anchorY)), width: 0, borderLeft: `1px solid ${tag.color}` }} />
50
+ {tag.leader ? <Box style={{ position: "absolute", [edge]: `calc(${inset} + .25em)`, top: percent(Math.min(tag.anchorY, tag.centerY)), height: percent(Math.abs(tag.centerY - tag.anchorY)), width: 0, borderLeft: `1px solid ${tag.color}` }} /> : null}
47
51
  <Box
48
52
  data-gloom-role="chart-series-axis-tag"
49
53
  data-series-id={tag.seriesId}
@@ -52,12 +56,15 @@ export function SeriesAxisTags({ placed, pixelHeight, lanes, background }: {
52
56
  data-carried={tag.carried ? "true" : undefined}
53
57
  aria-label={`${tag.seriesLabel}: ${tag.label}${tag.carried ? " (carried)" : ""}`}
54
58
  style={{
55
- position: "absolute", [edge]: `calc(${inset} + .5em)`, top: percent(tag.centerY), transform: "translateY(-50%)",
56
- width: `calc(${100 / lanes}% - .5em)`, height: "1.5em", lineHeight: "1.5em",
57
- alignItems: left ? "flex-end" : "flex-start", justifyContent: "center",
59
+ position: "absolute", [edge]: inset, top: percent(tag.centerY), transform: "translateY(-50%)",
60
+ // The value sits in a box in its series colour, as a chart level's label does, so nothing
61
+ // beside it can read as the value's sign. A box wider than its lane is clipped, not wrapped.
62
+ width: "max-content", maxWidth: `calc(${100 / lanes}% - ${lanes > 1 ? ".25em" : "0px"})`,
63
+ boxSizing: "border-box", height: "1.5em", lineHeight: "1.5em", paddingInline: ".15em",
64
+ alignItems: left ? "flex-end" : "flex-start", justifyContent: "center", overflow: "hidden",
58
65
  // Every badge paints above every leader, including another lane's.
59
66
  zIndex: 2, color: tag.color, backgroundColor: background, whiteSpace: "nowrap",
60
- borderBlockEnd: tag.carried ? `1px dashed ${tag.color}` : undefined,
67
+ border: `1px ${tag.carried ? "dashed" : "solid"} ${tag.color}`,
61
68
  }}
62
69
  >
63
70
  <Text fg={tag.color} selectable={false}>{tag.label}</Text>
@@ -210,6 +210,8 @@ export interface BuildCompositeChartSceneOptions {
210
210
  rightOffsetRatio?: number;
211
211
  /** Value axes stretched or squeezed by hand, in place of fitting them to the data. */
212
212
  valueScales?: CompositeValueScales;
213
+ /** Reference guides participate in auto-scaling on the axis of this series. */
214
+ referenceLevels?: CompositeChartLevels | null;
213
215
  }
214
216
 
215
217
  export interface CompositeChartXMarker {
@@ -347,4 +349,6 @@ export interface CompositeChartProps {
347
349
  * tool (Shift+H) adds, moves and deletes them by mouse and keyboard.
348
350
  */
349
351
  levels?: CompositeChartLevels | null;
352
+ /** Mean and population ±1σ of at least 20 finite observations in the active viewport. */
353
+ rangeStatisticsSeriesId?: string;
350
354
  }
@@ -52,11 +52,3 @@ function getSnapshot(): CommandBarPromptBinding | null {
52
52
  export function useCommandBarPromptBinding(): CommandBarPromptBinding | null {
53
53
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
54
54
  }
55
-
56
- /**
57
- * Calls `listener` with the open screen's query each time the bar publishes
58
- * its prompt, and with an empty one when it closes. For code outside React.
59
- */
60
- export function subscribeCommandBarQuery(listener: (query: string) => void): () => void {
61
- return subscribe(() => listener(binding?.query ?? ""));
62
- }
@@ -13,7 +13,7 @@ import type {
13
13
  const SET_ALERT_WORKFLOW_ID = "plugin-command:set-alert";
14
14
 
15
15
  function isSetAlertWorkflow(route: CommandBarWorkflowRoute): boolean {
16
- return route.workflowId === SET_ALERT_WORKFLOW_ID;
16
+ return route.workflowId === SET_ALERT_WORKFLOW_ID && (!route.values.kind || route.values.kind === "price");
17
17
  }
18
18
 
19
19
  function normalizeAlertWorkflowSymbol(value: CommandBarFieldValue | undefined): string {
@@ -38,17 +38,9 @@ import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-control
38
38
  import { Button, terminalButtonColumns } from "../ui/button";
39
39
 
40
40
  const UPDATE_NOTICE_DURATION_MS = 5_000;
41
- /**
42
- * The failed-update message is cut to the room it has, but not below this many
43
- * columns ("Update fa..."): narrower says nothing, so the banner is left out.
44
- * The retry hint gives way before the message goes under it.
45
- */
46
- const UPDATE_ERROR_MIN_COLUMNS = 12;
47
41
  /** Kept clear after the terminal update status, so its last character never touches the Help pill. */
48
42
  const UPDATE_STATUS_GAP_COLUMNS = 1;
49
- /** Below this many characters of the reason, the terminal banner says "Update failed" and the retry key instead. */
50
- const UPDATE_REASON_MIN_COLUMNS = 8;
51
- /** A terminal line about an update narrower than this is left out rather than shown as a stub. */
43
+ /** A clipped terminal notice narrower than this is left out rather than shown as a stub. */
52
44
  const UPDATE_TEXT_MIN_COLUMNS = 8;
53
45
  /** Left padding of the update status's slot, in cells; the desktop's title-bar layout also pads its right. */
54
46
  const UPDATE_SLOT_PADDING_COLUMNS = 2;
@@ -316,7 +308,7 @@ function HeaderCommandPrompt({
316
308
  */
317
309
  type UpdateStatusView =
318
310
  | { kind: "busy"; text: string; parts: StatusPart[] }
319
- | { kind: "text"; text: string }
311
+ | { kind: "text"; text: string; compact?: string; clip?: boolean }
320
312
  | { kind: "restart"; text: string; version: string }
321
313
  | { kind: "failed"; message: string; parts: StatusPart[]; retry: boolean };
322
314
 
@@ -374,7 +366,7 @@ function resolveUpdateStatusView({
374
366
  if (updateProgress.canRestart && canRestart) {
375
367
  return { kind: "restart", text: t("Update ready"), version: updateAvailable?.version ?? "" };
376
368
  }
377
- return { kind: "text", text: t("Update ready, restart to apply") };
369
+ return { kind: "text", text: t("Update ready, restart to apply"), compact: t("Update ready") };
378
370
  }
379
371
  if (updateProgress.phase === "done") {
380
372
  return { kind: "text", text: t(updateProgress.message ?? "Update ready, restart to apply") };
@@ -419,7 +411,7 @@ function resolveUpdateStatusView({
419
411
  }
420
412
  return { kind: "text", text: tf("v{version} available — starting download...", { version: updateAvailable.version }) };
421
413
  }
422
- if (updateNotice) return { kind: "text", text: updateNotice };
414
+ if (updateNotice) return { kind: "text", text: updateNotice, clip: true };
423
415
  return null;
424
416
  }
425
417
 
@@ -460,8 +452,8 @@ function retryHint(view: Extract<UpdateStatusView, { kind: "failed" }>, updateKe
460
452
  }
461
453
 
462
454
  /**
463
- * Columns the terminal status takes when nothing is cut: the header gives the
464
- * slot that much from the closed prompt's empty room before it cuts anything.
455
+ * Columns the terminal status takes when everything fits: the header gives
456
+ * the slot that much from the closed prompt's empty room before dropping parts.
465
457
  */
466
458
  function terminalUpdateStatusColumns(view: UpdateStatusView, updateKey: string): number {
467
459
  return UPDATE_STATUS_GAP_COLUMNS + terminalUpdateStatusContentColumns(view, updateKey);
@@ -490,7 +482,7 @@ function terminalClosedPromptFloor(): number {
490
482
  }
491
483
 
492
484
  /**
493
- * What the header says about an update. The terminal cuts it to `columns`,
485
+ * What the header says about an update. The terminal fits it to `columns`,
494
486
  * the room its slot is guaranteed; the desktop and the web let the browser fit
495
487
  * it to the room the bar leaves (NativeUpdateStatus). Either way it never runs
496
488
  * into the Help pill or the market cluster beside it.
@@ -525,7 +517,7 @@ function TerminalUpdateStatus({ columns, onRestart, updateKey, view }: {
525
517
  );
526
518
  }
527
519
 
528
- /** The status cut to `room` columns, or null when nothing of it would read. */
520
+ /** Whole status parts in priority order; only transient notices may be clipped. */
529
521
  function terminalUpdateStatusContent({ onRestart, room, updateKey, view }: {
530
522
  onRestart?: () => void;
531
523
  room: number;
@@ -533,9 +525,9 @@ function terminalUpdateStatusContent({ onRestart, room, updateKey, view }: {
533
525
  view: UpdateStatusView;
534
526
  }): ReactNode {
535
527
  if (view.kind === "busy") {
536
- // The spinner and a cell after it, then the words, cut to what is left.
537
- if (room < 2 + UPDATE_TEXT_MIN_COLUMNS) return null;
538
- return <TerminalBusyStatus text={fitStatusParts(view.parts, room - 2)} />;
528
+ // The percent stays when the words give way; the spinner never stands alone.
529
+ const text = fitStatusParts(view.parts, room - 2);
530
+ return text ? <TerminalBusyStatus text={text} /> : null;
539
531
  }
540
532
  if (view.kind === "restart") {
541
533
  // The button stays when the words do not fit beside it.
@@ -549,9 +541,7 @@ function terminalUpdateStatusContent({ onRestart, room, updateKey, view }: {
549
541
  const hintColumns = displayWidth(hint);
550
542
  const messageColumns = displayWidth(view.message);
551
543
  const short = t(UPDATE_FAILED_SHORT);
552
- const reasonRoom = room - displayWidth(view.parts[0]?.cut === false ? view.parts[0].text : "");
553
- // The whole message with its key, then the message alone, then as much of
554
- // the reason as reads, then "Update failed" with its key; never less.
544
+ // The reason and retry hint give way whole, leaving a compact failure signal.
555
545
  let text: string;
556
546
  let showHint = false;
557
547
  if (messageColumns + hintColumns <= room) {
@@ -559,37 +549,33 @@ function terminalUpdateStatusContent({ onRestart, room, updateKey, view }: {
559
549
  showHint = hintColumns > 0;
560
550
  } else if (messageColumns <= room) {
561
551
  text = view.message;
562
- } else if (reasonRoom >= UPDATE_REASON_MIN_COLUMNS) {
563
- text = truncateToDisplayWidth(view.message, room);
564
552
  } else if (displayWidth(short) + hintColumns <= room) {
565
553
  text = short;
566
554
  showHint = hintColumns > 0;
567
- } else if (room >= UPDATE_ERROR_MIN_COLUMNS) {
568
- text = truncateToDisplayWidth(short, room);
555
+ } else if (displayWidth(short) <= room) {
556
+ text = short;
569
557
  } else {
570
558
  return null;
571
559
  }
572
560
  return <TerminalFailedStatus text={text} hint={showHint ? hint : ""} />;
573
561
  }
574
- // A notice or a line about the update: cut to the slot, never past it.
575
- if (room < UPDATE_TEXT_MIN_COLUMNS) return null;
576
- return <TerminalTextStatus text={view.text} width={room} />;
562
+ if (view.clip) {
563
+ if (room < UPDATE_TEXT_MIN_COLUMNS) return null;
564
+ return <TerminalTextStatus text={view.text} width={room} />;
565
+ }
566
+ const text = displayWidth(view.text) <= room ? view.text : view.compact;
567
+ return text && displayWidth(text) <= room ? <TerminalTextStatus text={text} width={room} /> : null;
577
568
  }
578
569
 
579
570
  /**
580
- * A status line in `width` columns: the pieces that stay whole (a download's
581
- * percent) keep their room, and the rest is cut to what they leave.
571
+ * A status line in `width` columns: the parts the desktop may clip instead
572
+ * drop out whole in the terminal, keeping the download's percent readable.
582
573
  */
583
574
  function fitStatusParts(parts: StatusPart[], width: number): string {
584
- const whole = parts.reduce((total, part) => total + (part.cut ? 0 : displayWidth(part.text)), 0);
585
- const cutRoom = Math.max(0, width - whole);
586
- const cutTotal = parts.reduce((total, part) => total + (part.cut ? displayWidth(part.text) : 0), 0);
587
- if (cutTotal <= cutRoom) return parts.map((part) => part.text).join("");
588
- // A cut piece keeps a space before the whole one after it: "Downloading v... 42%".
589
- const cut = (part: StatusPart, index: number) => index < parts.length - 1
590
- ? `${truncateToDisplayWidth(part.text.trimEnd(), Math.max(0, cutRoom - 1))} `
591
- : truncateToDisplayWidth(part.text, cutRoom);
592
- return truncateToDisplayWidth(parts.map((part, index) => (part.cut ? cut(part, index) : part.text)).join(""), width);
575
+ const full = parts.map((part) => part.text).join("");
576
+ if (displayWidth(full) <= width) return full;
577
+ const compact = parts.filter((part) => !part.cut).map((part) => part.text).join("").trim();
578
+ return displayWidth(compact) <= width ? compact : "";
593
579
  }
594
580
 
595
581
  function TerminalBusyStatus({ text }: { text: string }) {
@@ -26,6 +26,7 @@ export {
26
26
  DELAYED_SEGMENT,
27
27
  delayedFooterSegment,
28
28
  hasPaneFooterContent,
29
+ savedCopyFooterSegment,
29
30
  splitDelayedStatus,
30
31
  type CombinedPaneFooter,
31
32
  type PaneFooterPressEvent,
@@ -1,6 +1,7 @@
1
1
  import type { ContextMenuItem } from "../../../../types/context-menu";
2
2
  import { t } from "../../../../i18n";
3
3
  import { clipToDisplayWidth, displayWidth } from "../../../../utils/format";
4
+ import { formatShortDate } from "../../../../utils/datetime-format";
4
5
  import { getShortcutHintWidth } from "../../../ui/shortcut-hint";
5
6
 
6
7
  export interface PaneFooterRegistration {
@@ -42,6 +43,12 @@ export interface PaneFooterSegment {
42
43
  * line states its data's date and delay leaves it out.
43
44
  */
44
45
  quoteStatus?: boolean;
46
+ /**
47
+ * The segment says what the rows leave out (releases a filter hides), which
48
+ * a picture of the rows cannot: a `gloom shot` keeps it, as it keeps a
49
+ * warning.
50
+ */
51
+ capture?: boolean;
45
52
  /**
46
53
  * Shorter forms of `parts`, longest first. When the row is too narrow for
47
54
  * `parts` beside the hints, it shows the first that fits, or the last, and
@@ -90,6 +97,26 @@ export function delayedFooterSegment({
90
97
  };
91
98
  }
92
99
 
100
+ /**
101
+ * The footer's one form for data shown from a copy saved earlier because
102
+ * refreshing it failed (offline, a hung or refused proxy): `saved copy from
103
+ * 14:37`, or with its day when saved on another. It says how current the
104
+ * pane's quotes are, so it is a quote status.
105
+ */
106
+ export function savedCopyFooterSegment(id: string, savedAt: number, now = Date.now()): PaneFooterSegment {
107
+ const saved = new Date(savedAt);
108
+ const time = saved.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
109
+ const when = saved.toDateString() === new Date(now).toDateString() ? time : `${formatShortDate(saved, { year: false })} ${time}`;
110
+ const text = `${t("saved copy from")} ${when}`;
111
+ return {
112
+ id,
113
+ quoteStatus: true,
114
+ title: `${t("Couldn't refresh")}: ${text}`,
115
+ parts: [{ text, tone: "warning" }],
116
+ variants: [[{ text: t("saved copy"), tone: "warning" }]],
117
+ };
118
+ }
119
+
93
120
  /** A pane whose market data is all delayed, by no one figure: the clock alone, its tooltip saying so. */
94
121
  export const DELAYED_SEGMENT: PaneFooterSegment = delayedFooterSegment({ id: "delayed", detail: "Delayed market data" });
95
122
 
@@ -343,7 +343,7 @@ export function OnboardingHeader({
343
343
  if (!desktop) {
344
344
  return (
345
345
  <Box height={1} flexDirection="row" justifyContent="space-between">
346
- <Text fg={colors.textMuted}>{t("GLOOMBERB SETUP")}</Text>
346
+ <Text fg={colors.textMuted}>{t("GLOOM SETUP")}</Text>
347
347
  {showDismiss ? (
348
348
  <Button
349
349
  label={dismissing ? "Closing..." : "Skip setup"}
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, type ComponentProps, type RefObject } from "react";
2
2
  import { Box, Text, TextAttributes, useUiCapabilities, type InputRenderable } from "../../../ui";
3
3
  import { useThemeColors } from "../../../theme/theme-context";
4
- import { priceColor } from "../../../theme/colors";
4
+ import { shownPriceColor } from "../../../theme/colors";
5
5
  import { t, tf } from "../../../i18n";
6
6
  import { formatMarketPrice } from "../../../market-data/market/format";
7
7
  import { getRegularSessionDisplay } from "../../../market-data/market/status";
@@ -62,7 +62,7 @@ function PreviewLine({ preview, error }: Pick<OnboardingPositionsState, "preview
62
62
  <Text fg={colors.text} attributes={TextAttributes.BOLD}>{preview.symbol}</Text>
63
63
  {preview.name ? <Text fg={colors.textDim}>{` ${preview.name}`}</Text> : null}
64
64
  {price != null ? <Text fg={colors.text}>{` ${formatMarketPrice(price, { maxWidth: 12 })}`}</Text> : null}
65
- {change != null ? <Text fg={priceColor(change, colors)}>{` ${formatPercentRaw(change)}`}</Text> : null}
65
+ {change != null ? <Text fg={shownPriceColor(change, 2, colors)}>{` ${formatPercentRaw(change)}`}</Text> : null}
66
66
  {preview.duplicate ? <Text fg={colors.textMuted}>{t(" already added")}</Text> : null}
67
67
  </Box>
68
68
  );
@@ -135,8 +135,12 @@ function NativeKeyValueRow({ label, value, detail, tone, color, width, labelWidt
135
135
  } : {};
136
136
  const rowWidth = width === undefined ? undefined : Math.max(0, Math.floor(width));
137
137
  const labelChars = Math.max(0, labelWidth ?? (rowWidth === undefined ? 14 : Math.min(12, Math.max(8, Math.floor(rowWidth * 0.32)))));
138
+ // A bounded row fills its parent's width. Rows side by side (a pair of metrics) each give way to an equal
139
+ // share of their row, so the second is not pushed past the pane edge; never shorter than its line, so a
140
+ // column with less room than its rows still overflows and scrolls instead of squeezing them.
141
+ const bounded = width === undefined ? {} : { width: "100%", flexShrink: 1, minHeight: "min-content" };
138
142
  return (
139
- <Box width={width === undefined ? undefined : "100%"} flexDirection="row" overflow="hidden" data-gloom-ui="key-value-row" {...target}>
143
+ <Box {...bounded} flexDirection="row" overflow="hidden" data-gloom-ui="key-value-row" {...target}>
140
144
  {/* At least the label width, in characters; a longer label keeps every character and moves its value along. */}
141
145
  <Text fg={colors.textDim} style={{ ...NATIVE_CLIP_STYLE, flex: "0 0 auto", minWidth: `${labelChars}ch`, paddingRight: "1ch", boxSizing: "border-box" }}>{t(label)}</Text>
142
146
  {country ? <CountryFlagLead value={country} /> : null}
@@ -594,6 +594,7 @@ export function withFocusedPane(
594
594
  config: syncedConfig,
595
595
  paneState: nextPaneState,
596
596
  brokerAccounts: reconcileBrokerAccounts(syncedConfig, state.brokerAccounts),
597
+ brokerSyncErrors: Object.fromEntries(Object.entries(state.brokerSyncErrors).filter(([id]) => syncedConfig.brokerInstances.some((instance) => instance.id === id))),
597
598
  focusedPaneId,
598
599
  previousFocusedPaneId: state.focusedPaneId && state.focusedPaneId !== focusedPaneId
599
600
  ? state.focusedPaneId
@@ -605,6 +606,7 @@ export function hydrateDesktopSnapshot(state: AppState, snapshot: DesktopSharedS
605
606
  return withFocusedPane({
606
607
  ...state,
607
608
  paneState: snapshot.paneState,
609
+ brokerSyncErrors: snapshot.brokerSyncErrors ?? {},
608
610
  focusedPaneId: snapshot.focusedPaneId,
609
611
  statusBarVisible: snapshot.statusBarVisible,
610
612
  }, snapshot.config);
@@ -203,6 +203,14 @@ export function appReducer(state: AppState, action: AppAction): AppState {
203
203
  },
204
204
  };
205
205
 
206
+ case "SET_BROKER_SYNC_ERROR": {
207
+ if (!state.config.brokerInstances.some((instance) => instance.id === action.instanceId)) return state;
208
+ const brokerSyncErrors = { ...state.brokerSyncErrors };
209
+ if (action.error) brokerSyncErrors[action.instanceId] = action.error;
210
+ else delete brokerSyncErrors[action.instanceId];
211
+ return { ...state, brokerSyncErrors };
212
+ }
213
+
206
214
  case "SET_INITIALIZED":
207
215
  return { ...state, initialized: true };
208
216
 
@@ -371,6 +379,7 @@ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessio
371
379
  financials: new Map(),
372
380
  exchangeRates: new Map([["USD", 1]]),
373
381
  brokerAccounts: {},
382
+ brokerSyncErrors: {},
374
383
  focusedPaneId,
375
384
  previousFocusedPaneId: null,
376
385
  paneState,
@@ -68,6 +68,8 @@ export interface AppState {
68
68
  */
69
69
  exchangeRates: Map<string, number>;
70
70
  brokerAccounts: Record<string, BrokerAccount[]>;
71
+ /** A failed position import stays visible until this profile syncs successfully. */
72
+ brokerSyncErrors: Record<string, string>;
71
73
  /**
72
74
  * @deprecated Never set. The app has no left or right panel state; read
73
75
  * `focusedPaneId` for the focused pane.
@@ -131,6 +133,7 @@ export type AppAction =
131
133
  | { type: "RECORD_COMMAND"; id: string; label: string; arg?: string }
132
134
  | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
133
135
  | { type: "SET_BROKER_ACCOUNTS"; instanceId: string; accounts: BrokerAccount[] }
136
+ | { type: "SET_BROKER_SYNC_ERROR"; instanceId: string; error: string | null }
134
137
  | { type: "SET_INITIALIZED" }
135
138
  | { type: "TOGGLE_STATUS_BAR" }
136
139
  | { type: "SET_THEME"; theme: string }
@@ -1,27 +1,33 @@
1
1
  /**
2
- * Answers a CLI command gave from a copy saved earlier because refreshing it
3
- * failed: offline, a refused proxy, a service error. A command answers once,
4
- * so the line it ends with says "couldn't refresh; saved <time>" instead of
5
- * reading as live or delayed, but only when what it shows came from such a
6
- * copy: a saved copy of something else it loaded on the way (a fallback for a
7
- * pane it does not print) says nothing about the data on screen.
2
+ * Answers given from a copy saved earlier because refreshing it failed:
3
+ * offline, a hung or refused proxy, a service error.
4
+ *
5
+ * A CLI command answers once, so the line it ends with says "couldn't
6
+ * refresh; saved copy from <time>" instead of reading as live or delayed, but
7
+ * only when what it shows came from such a copy: a saved copy of something
8
+ * else it loaded on the way (a fallback for a pane it does not print) says
9
+ * nothing about the data on screen. The app says the same of a quote, in the
10
+ * footer of a pane showing one (`savedCopyAt`), until a refresh replaces it.
8
11
  *
9
12
  * The copy served is marked, by a symbol property that object spreads carry
10
13
  * along and JSON never writes, and the command's output is searched for the
11
- * marks. Nothing is marked outside `collectSavedCopies`: the app and its panes
12
- * keep running for hours and say how current their data is in their own
13
- * footers.
14
+ * marks. Outside a command only quotes are marked (`quoteServedFromSavedCopy`):
15
+ * the app keeps running for hours, and its other panes say how current their
16
+ * data is in their own footers. A quote built from a newer answer must not
17
+ * inherit a mark through a spread: `withSavedCopyMarkOf` gives it the mark of
18
+ * what was served instead.
14
19
  */
15
20
  const SAVED_AT = Symbol("gloomberb.savedCopyAt");
16
21
 
17
22
  let collecting = 0;
18
- let marked = 0;
23
+ /** Whether anything was ever marked, so output is not searched for marks that cannot be there. */
24
+ let anyMarked = false;
19
25
 
20
26
  type Marked = { [SAVED_AT]?: number };
21
27
 
22
28
  function mark<T>(value: T, savedAt: number): T {
23
29
  if (!value || typeof value !== "object" || Array.isArray(value)) return value;
24
- marked += 1;
30
+ anyMarked = true;
25
31
  return { ...value, [SAVED_AT]: savedAt };
26
32
  }
27
33
 
@@ -36,13 +42,48 @@ export function servedFromSavedCopy<T>(value: T, savedAt: number): T {
36
42
  return (Array.isArray(value) ? value.map((item) => mark(item, savedAt)) : mark(value, savedAt)) as T;
37
43
  }
38
44
 
45
+ /** A quote answered from a copy saved at `savedAt` (ms) after its refresh failed: marked, in a command and in the app. */
46
+ export function quoteServedFromSavedCopy<T extends object>(quote: T, savedAt: number): T {
47
+ return Number.isFinite(savedAt) ? mark(quote, savedAt) : quote;
48
+ }
49
+
39
50
  /**
40
51
  * The saved-copy mark of `value`, to spread into what is drawn from it (a
41
52
  * report row built from a quote's fields), or nothing when it has none.
42
53
  */
43
54
  export function savedCopyMark(value: unknown): object {
55
+ const savedAt = savedCopyAt(value);
56
+ return savedAt != null ? { [SAVED_AT]: savedAt } : {};
57
+ }
58
+
59
+ /** When `value` itself was saved, if it is a saved copy served after a failed refresh; null otherwise. */
60
+ export function savedCopyAt(value: unknown): number | null {
44
61
  const savedAt = value && typeof value === "object" ? (value as Marked)[SAVED_AT] : undefined;
45
- return typeof savedAt === "number" ? { [SAVED_AT]: savedAt } : {};
62
+ return typeof savedAt === "number" ? savedAt : null;
63
+ }
64
+
65
+ /** The oldest saved copy among `values` (a pane's quotes), by when it was saved; null when none is one. */
66
+ export function oldestSavedCopyAt(values: Iterable<unknown>): number | null {
67
+ let oldest: number | null = null;
68
+ for (const value of values) {
69
+ const savedAt = savedCopyAt(value);
70
+ if (savedAt != null) oldest = Math.min(oldest ?? savedAt, savedAt);
71
+ }
72
+ return oldest;
73
+ }
74
+
75
+ /**
76
+ * `value` carrying the saved-copy mark of `served` and no other: what is
77
+ * built from an answer (resolved, merged onto an older quote) is a saved copy
78
+ * exactly when that answer was.
79
+ */
80
+ export function withSavedCopyMarkOf<T extends object>(value: T, served: unknown): T {
81
+ const savedAt = savedCopyAt(served);
82
+ const own = savedCopyAt(value);
83
+ if (own === savedAt) return value;
84
+ if (savedAt != null) return mark(value, savedAt);
85
+ const { [SAVED_AT]: _dropped, ...rest } = value as T & Marked;
86
+ return rest as T;
46
87
  }
47
88
 
48
89
  /** How far into a command's output the marks are looked for, so a large report is not walked whole. */
@@ -55,7 +96,7 @@ const MAX_NODES = 50_000;
55
96
  * came from one. The oldest, since that is how old what is shown can be.
56
97
  */
57
98
  export function savedCopyShownAt(shown: unknown): number | null {
58
- if (collecting === 0 || marked === 0) return null;
99
+ if (collecting === 0 || !anyMarked) return null;
59
100
  let oldest: number | null = null;
60
101
  let budget = MAX_NODES;
61
102
  const seen = new Set<object>();
@@ -71,14 +112,12 @@ export function savedCopyShownAt(shown: unknown): number | null {
71
112
  return oldest;
72
113
  }
73
114
 
74
- /** Runs one command, marking the saved copies it answers from. */
115
+ /** Runs one command, whose output `savedCopyShownAt` then searches for saved copies. */
75
116
  export async function collectSavedCopies<T>(run: () => Promise<T>): Promise<T> {
76
- if (collecting === 0) marked = 0;
77
117
  collecting += 1;
78
118
  try {
79
119
  return await run();
80
120
  } finally {
81
121
  collecting -= 1;
82
- if (collecting === 0) marked = 0;
83
122
  }
84
123
  }