@gloom-sh/terminal 0.19.0 → 0.19.1

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 (203) 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 +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/hiring.ts +3 -0
  10. package/src/api-client/index.ts +4 -0
  11. package/src/api-client/ipo.ts +24 -1
  12. package/src/api-client/mna.ts +3 -0
  13. package/src/api-client/paths.ts +9 -1
  14. package/src/api-client/roadmap.ts +85 -0
  15. package/src/api-client/types.ts +12 -0
  16. package/src/architecture/core-builtin-allowlist.json +2 -2
  17. package/src/cli/commands/market.ts +35 -12
  18. package/src/cli/commands/news-search.ts +22 -0
  19. package/src/cli/commands/overview.ts +86 -20
  20. package/src/cli/commands/ticker.ts +32 -2
  21. package/src/cli/errors.ts +17 -2
  22. package/src/cli/glossary.ts +6 -0
  23. package/src/cli/index-roots.ts +0 -7
  24. package/src/cli/listing-arg.ts +57 -10
  25. package/src/cli/pane-functions/capabilities.ts +1 -0
  26. package/src/cli/pane-functions/discovery.ts +21 -1
  27. package/src/cli/pane-functions/freshness.ts +7 -1
  28. package/src/cli/pane-functions/function-help.ts +7 -0
  29. package/src/cli/pane-functions/index.ts +6 -3
  30. package/src/cli/quote-answer.ts +1 -1
  31. package/src/cli/saved-copy-persistence.ts +88 -0
  32. package/src/components/command-bar/list/model.ts +5 -0
  33. package/src/components/command-bar/routes/root/runtime.ts +6 -7
  34. package/src/components/command-bar/selection-runtime.ts +52 -3
  35. package/src/components/command-bar/surface/index.tsx +2 -0
  36. package/src/components/layout/pane/capture-stamp.ts +2 -2
  37. package/src/components/layout/pane/footer/index.tsx +22 -6
  38. package/src/components/layout/pane/footer/model.ts +83 -15
  39. package/src/components/layout/pane/status-footer.ts +35 -2
  40. package/src/components/layout/status-widget-space.ts +9 -0
  41. package/src/components/ticker/list-table-view.tsx +4 -20
  42. package/src/components/ui/display.tsx +18 -3
  43. package/src/components/ui/icon.tsx +4 -1
  44. package/src/data/plugin-cache.ts +5 -3
  45. package/src/data/saved-copies.ts +8 -0
  46. package/src/docs/snapshot.json +32 -5
  47. package/src/i18n/es.ts +2 -4
  48. package/src/i18n/ja.ts +2 -4
  49. package/src/i18n/ko.ts +2 -4
  50. package/src/i18n/zh-cn.ts +2 -4
  51. package/src/i18n/zh-tw.ts +2 -4
  52. package/src/market-data/depositary-pair.ts +25 -1
  53. package/src/market-data/quotes/metadata.ts +7 -0
  54. package/src/news/aggregator.ts +7 -1
  55. package/src/news/types.ts +6 -0
  56. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  57. package/src/plugins/builtin/alerts/cli.ts +34 -11
  58. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  59. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  60. package/src/plugins/builtin/alerts/headless.ts +10 -6
  61. package/src/plugins/builtin/cds/client.ts +142 -30
  62. package/src/plugins/builtin/cds/headless.ts +35 -8
  63. package/src/plugins/builtin/cds/model.ts +145 -18
  64. package/src/plugins/builtin/cds/pane.tsx +16 -5
  65. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  66. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  67. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  68. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  69. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  70. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  71. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  72. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  73. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  74. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  75. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  76. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  77. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  78. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  79. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  80. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  81. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  82. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  83. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  84. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  85. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  86. package/src/plugins/builtin/composite-plugins.ts +2 -1
  87. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  88. package/src/plugins/builtin/doe/headless.ts +3 -2
  89. package/src/plugins/builtin/doe/pane.tsx +13 -3
  90. package/src/plugins/builtin/earnings-calls/data.ts +18 -6
  91. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
  93. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  94. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  95. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  96. package/src/plugins/builtin/econ/figures.ts +48 -0
  97. package/src/plugins/builtin/econ/index.tsx +14 -5
  98. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  99. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  100. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  101. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  102. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  103. package/src/plugins/builtin/help/index.tsx +61 -59
  104. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  105. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  106. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  107. package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
  108. package/src/plugins/builtin/holders/headless.ts +4 -2
  109. package/src/plugins/builtin/holders/pane.tsx +3 -0
  110. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  111. package/src/plugins/builtin/insider/headless.ts +2 -2
  112. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  113. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  114. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  115. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  116. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  117. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  118. package/src/plugins/builtin/macro-day/model.ts +39 -5
  119. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  120. package/src/plugins/builtin/market-movers/client.ts +2 -0
  121. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  122. package/src/plugins/builtin/market-movers/screener.ts +8 -4
  123. package/src/plugins/builtin/mna/headless.ts +2 -0
  124. package/src/plugins/builtin/mna/pane.tsx +3 -1
  125. package/src/plugins/builtin/news/headless.ts +3 -1
  126. package/src/plugins/builtin/news/index.tsx +3 -0
  127. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  128. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  129. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  130. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  131. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  132. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  133. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  134. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  135. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  136. package/src/plugins/builtin/research/event-model.ts +30 -4
  137. package/src/plugins/builtin/research/index.tsx +7 -3
  138. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  139. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  140. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  141. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  142. package/src/plugins/builtin/risk-factors/data.ts +4 -2
  143. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  144. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  145. package/src/plugins/builtin/roadmap/model.ts +67 -0
  146. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  147. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  148. package/src/plugins/builtin/sec/headless.ts +2 -1
  149. package/src/plugins/builtin/sec/index.tsx +3 -0
  150. package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
  151. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  152. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  153. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  154. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  155. package/src/plugins/builtin/thirteenf/api.ts +12 -1
  156. package/src/plugins/builtin/thirteenf/data.ts +7 -7
  157. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  158. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  159. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  160. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  161. package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
  162. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  163. package/src/plugins/builtin/volatility/pane.tsx +5 -2
  164. package/src/plugins/builtin/yield-curve/client.ts +61 -1
  165. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  166. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  167. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  168. package/src/plugins/catalog-browser.ts +2 -1
  169. package/src/plugins/host-link.ts +1 -1
  170. package/src/portfolio/column-values.ts +11 -7
  171. package/src/renderers/dom/desktop/icons.tsx +7 -0
  172. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  173. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  174. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  175. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  176. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  177. package/src/renderers/electrobun/bun/index.ts +26 -2
  178. package/src/renderers/electrobun/bun/window/create.ts +43 -4
  179. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  180. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  181. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  182. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  183. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  184. package/src/renderers/electrobun/view/news-source.ts +2 -0
  185. package/src/renderers/electrobun/view/ui-host.tsx +8 -0
  186. package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
  187. package/src/sources/gloomberb-cloud/index.ts +42 -8
  188. package/src/tickers/company-name-listing.ts +53 -13
  189. package/src/tickers/company-name.ts +6 -1
  190. package/src/tickers/index-roots.ts +7 -0
  191. package/src/tickers/listing-fate.ts +2 -2
  192. package/src/tickers/search/index.ts +6 -2
  193. package/src/tickers/search/ranking.ts +5 -1
  194. package/src/tickers/search/upsert.ts +13 -0
  195. package/src/tickers/ticker-changes.ts +8 -4
  196. package/src/types/company-from.ts +40 -0
  197. package/src/types/data-provider.ts +7 -0
  198. package/src/types/financials.ts +27 -0
  199. package/src/types/headless.ts +6 -0
  200. package/src/utils/crypto-pair.ts +121 -7
  201. package/src/utils/declared-dividends.ts +1 -1
  202. package/src/utils/fundamentals.ts +36 -5
  203. package/src/version.ts +2 -2
@@ -6,6 +6,7 @@ import type {
6
6
  HeadlessShotState,
7
7
  } from "../../../types/plugin";
8
8
  import { formatShortDate } from "../../../utils/datetime-format";
9
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
9
10
  import { normalizeCik, searchThirteenFFunds, TOP_FUNDS_MAX_LIMIT } from "./api";
10
11
  import { ApiRequestError } from "../../../api-client/errors";
11
12
  import { REPORT_PAGE, REPORT_ROWS } from "../shared/headless-options";
@@ -229,15 +230,40 @@ export interface ThirteenFHeadlessDependencies {
229
230
  loadEtfHoldings?(ticker: string, ctx: HeadlessPaneContext): Promise<FundMembersPayload>;
230
231
  }
231
232
 
233
+ /** The oldest copy a load's 13F reads answered from because their refresh failed, by the load's context. */
234
+ const savedCopies = new WeakMap<HeadlessPaneContext, { savedAt: number | null }>();
235
+
236
+ /** How a 13F read for the load `ctx` says it answered from the copy saved at `savedAt`. */
237
+ const noteSavedCopy = (ctx: HeadlessPaneContext) => (savedAt: number) => {
238
+ const entry = savedCopies.get(ctx);
239
+ if (entry) entry.savedAt = Math.min(entry.savedAt ?? savedAt, savedAt);
240
+ };
241
+
242
+ /**
243
+ * One load, whose report ends with when the oldest saved copy it answered
244
+ * from was saved (`couldn't refresh; saved copy from <time>`), as every
245
+ * command says it, instead of listing each failed refresh as an error.
246
+ */
247
+ async function readingSavedCopies<T extends object>(ctx: HeadlessPaneContext, run: () => Promise<T>): Promise<T> {
248
+ const entry: { savedAt: number | null } = { savedAt: null };
249
+ savedCopies.set(ctx, entry);
250
+ try {
251
+ const result = await run();
252
+ return entry.savedAt == null ? result : servedFromSavedCopy(result, entry.savedAt);
253
+ } finally {
254
+ if (savedCopies.get(ctx) === entry) savedCopies.delete(ctx);
255
+ }
256
+ }
257
+
232
258
  const defaultDependencies: Required<ThirteenFHeadlessDependencies> = {
233
259
  loadBrowser: (tab, query, limit, _args, ctx) => loadBrowserRows(
234
260
  tab,
235
261
  query,
236
262
  ctx.signal,
237
- { limit },
263
+ { limit, onSavedCopy: noteSavedCopy(ctx) },
238
264
  ),
239
265
  // An overlap never shows a fund's missed quarter, so it does not ask EDGAR.
240
- loadDetail: (cik, name, args, ctx) => loadFundDetail(cik, name, ctx.signal, { filingGap: !isOverlapLoad(args) }),
266
+ loadDetail: (cik, name, args, ctx) => loadFundDetail(cik, name, ctx.signal, { filingGap: !isOverlapLoad(args), onSavedCopy: noteSavedCopy(ctx) }),
241
267
  loadSplitFactors: (detail, ctx) => loadSplitFactors(
242
268
  detail,
243
269
  (ticker, after, through) => corporateSplitFactor(ctx.marketData, ticker, after, through),
@@ -247,6 +273,7 @@ const defaultDependencies: Required<ThirteenFHeadlessDependencies> = {
247
273
  searchFunds: (query, ctx) => searchThirteenFFunds(query, THIRTEENF_MAX_LIMIT, ctx.signal),
248
274
  loadPerformance: (cik, name, ctx) => loadFundPerformance(cik, name, ctx.signal, {
249
275
  splitFactor: (ticker, after, through) => corporateSplitFactor(ctx.marketData, ticker, after, through),
276
+ onSavedCopy: noteSavedCopy(ctx),
250
277
  }),
251
278
  loadEtfFunds: async (ctx) => (await fetchFunds(ctx.apiClient)).funds,
252
279
  loadEtfHoldings: (ticker, ctx) => fetchMembers(ticker, ctx.apiClient),
@@ -541,7 +568,7 @@ export function createThirteenFHeadless(
541
568
  ],
542
569
  describe: (args, result) => thirteenFTitle(args, result?.metadata),
543
570
  shotState: async (args, load) => thirteenFShotState(args, (await load()).metadata ?? {}),
544
- async load(args, ctx) {
571
+ load: (args, ctx) => readingSavedCopies(ctx, async () => {
545
572
  const query = typeof args.argument === "string" ? args.argument.trim() : "";
546
573
  const requestedView = String(args.options.view) as HeadlessThirteenFView;
547
574
  const compare = typeof args.options.compare === "string" ? args.options.compare.trim() : "";
@@ -920,7 +947,7 @@ export function createThirteenFHeadless(
920
947
  ],
921
948
  },
922
949
  };
923
- },
950
+ }),
924
951
  };
925
952
  }
926
953
 
@@ -1,9 +1,9 @@
1
1
  import { formatPriceEarnings, NOT_MEANINGFUL } from "../../../../utils/price-earnings";
2
2
  import { convertMarketCapitalization } from "../../../../utils/market-capitalization";
3
3
  import { priceColor } from "../../../../theme/colors";
4
- import type { CompanyProfile, DepositaryPair, NextEarnings, Quote, TickerFinancials } from "../../../../types/financials";
4
+ import type { CedearRatio, CompanyProfile, DepositaryPair, NextEarnings, Quote, TickerFinancials } from "../../../../types/financials";
5
5
  import { adrRatioText } from "../../../../utils/depositary-receipt";
6
- import { ratioCount } from "../../../../market-data/depositary-pair";
6
+ import { cedearRatioText, ratioCount } from "../../../../market-data/depositary-pair";
7
7
  import { ADR_PAIR_TAB_ID, ADR_PAIR_TEMPLATE_ID } from "../adr-pair/model";
8
8
  import type { Portfolio, TickerPosition, TickerRecord } from "../../../../types/ticker";
9
9
  import type { BrokerInstanceConfig } from "../../../../types/config";
@@ -151,6 +151,7 @@ export function buildOverviewStats({
151
151
  nextEarnings,
152
152
  depositaryReceipt = false,
153
153
  depositaryPair = null,
154
+ cedearRatio = null,
154
155
  today,
155
156
  dividend,
156
157
  bond = null,
@@ -169,6 +170,8 @@ export function buildOverviewStats({
169
170
  depositaryReceipt?: boolean;
170
171
  /** The receipt pair the listing is a side of, as Gloom Cloud serves it; `receiptSide` when the listing is the receipt. */
171
172
  depositaryPair?: { pair: DepositaryPair; receiptSide: boolean } | null;
173
+ /** A Buenos Aires CEDEAR's ratio in its company's shares, as Gloom Cloud serves it. */
174
+ cedearRatio?: CedearRatio | null;
172
175
  /** The listing's calendar day, YYYY-MM-DD; dates before it are left out. */
173
176
  today?: string;
174
177
  /** The history the dividend yield reads (useOverviewDividend): a trailing one is measured from its cash, a forward one stops with it; without it the yield stands. */
@@ -223,6 +226,11 @@ export function buildOverviewStats({
223
226
  ? { label: "ADR Ratio", value: `1:${count}`, detail: [adrRatioText(pair.ratio)!, `${count} sh per ADR`], link: ADR_PAIR }
224
227
  : { label: "ADR", value: pair.receipt, detail: [`1 ADR = ${count} of these shares`, `1:${count}`], link: ADR_PAIR });
225
228
  }
229
+ if (cedearRatio) {
230
+ const receipts = ratioCount(cedearRatio.receipts);
231
+ const shares = ratioCount(cedearRatio.shares);
232
+ stats.push({ label: "CEDEAR Ratio", value: `${receipts}:${shares}`, detail: [cedearRatioText(cedearRatio), `${receipts} per ${shares} sh`] });
233
+ }
226
234
  const trailingPE = liveTrailingPE(quote, fundamentals);
227
235
  if (trailingPE != null) {
228
236
  stats.push({ label: "P/E (TTM)", value: formatPriceEarnings(trailingPE) });
@@ -42,7 +42,7 @@ import { appTtmRate, enterpriseValueReconciliationNote, ttmAtAppRate, ttmConvers
42
42
  import { atRegularSessionPrice } from "../../../market-data/quotes/regular-session-valuation";
43
43
  import { zonedDateKey } from "../../../utils/zoned-date-time";
44
44
  import { sharesOutstandingInReceipts } from "../../../utils/depositary-receipt";
45
- import { isReceiptSide, listingDepositaryPair } from "../../../market-data/depositary-pair";
45
+ import { isReceiptSide, listingCedearRatio, listingDepositaryPair } from "../../../market-data/depositary-pair";
46
46
  import {
47
47
  CompactRangeBar,
48
48
  FundamentalsGrid,
@@ -335,6 +335,7 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
335
335
  nextEarnings: financials?.nextEarnings,
336
336
  depositaryReceipt: sharesOutstandingInReceipts(quote, fundamentals, description),
337
337
  depositaryPair: depositaryPair ? { pair: depositaryPair, receiptSide: isReceiptSide(financials) } : null,
338
+ cedearRatio: listingCedearRatio(financials),
338
339
  // Report and ex-dividend dates are the listing's calendar days.
339
340
  today: zonedDateKey(Date.now(), chartTimeZone ?? "America/New_York"),
340
341
  dividend,
@@ -326,16 +326,21 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
326
326
  selectResearchTab(tabId);
327
327
  return true;
328
328
  }, [selectResearchTab, visibleTabIds]);
329
+ // Without a ticker the pane draws only its empty state, and the tab list has
330
+ // no Overview, so the fallback above lands on the first tab that needs no
331
+ // ticker. Nobody opened it, and keeping it mounted would load its data on
332
+ // every ticker switch.
333
+ const shownTabId = ticker && visibleTabIds.has(resolvedTabId) ? resolvedTabId : null;
329
334
  const renderedTabIds = useMemo(() => {
330
335
  const next = new Set<string>();
331
336
  for (const tabId of mountedTabIds) {
332
337
  if (visibleTabIds.has(tabId)) next.add(tabId);
333
338
  }
334
- if (visibleTabIds.has(resolvedTabId)) {
335
- next.add(resolvedTabId);
339
+ if (shownTabId) {
340
+ next.add(shownTabId);
336
341
  }
337
342
  return next;
338
- }, [mountedTabIds, resolvedTabId, visibleTabIds]);
343
+ }, [mountedTabIds, shownTabId, visibleTabIds]);
339
344
 
340
345
  const handlePluginCapture = useCallback((capturing: boolean) => {
341
346
  setPluginCaptured(capturing);
@@ -354,12 +359,12 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
354
359
  for (const tabId of current) {
355
360
  if (visibleTabIds.has(tabId)) next.add(tabId);
356
361
  }
357
- if (visibleTabIds.has(resolvedTabId)) {
358
- next.add(resolvedTabId);
362
+ if (shownTabId) {
363
+ next.add(shownTabId);
359
364
  }
360
365
  return sameStringSet(current, next) ? current : next;
361
366
  });
362
- }, [resolvedTabId, visibleTabIds]);
367
+ }, [shownTabId, visibleTabIds]);
363
368
 
364
369
  if (!ticker) {
365
370
  const isEmptyFollowCollection = paneInstance?.binding?.kind === "follow" && !!collectionId && collectionTickerCount === 0;
@@ -28,27 +28,34 @@ import {
28
28
  } from "./model";
29
29
 
30
30
  /**
31
- * A completed auction's missing bid-to-cover or bidder split is a blank cell:
32
- * the source has bid-to-cover regularly from late 1999 and the split only from
33
- * 2008-04-07. "-" stays for an announced auction whose results are not out yet.
31
+ * A missing bid-to-cover or bidder split is a blank cell: the source has
32
+ * bid-to-cover regularly from late 1999 and the split only from 2008-04-07,
33
+ * and an announced auction has no results to fill them; its Rate says so.
34
34
  */
35
- const missing = (row: Record<string, unknown>) => isPendingAuction(row as unknown as TreasuryAuction) ? "-" : "";
36
- const formatPercent = (value: unknown, row: Record<string, unknown>) => value == null ? missing(row) : `${Number(value).toFixed(1)}%`;
35
+ const formatPercent = (value: unknown) => value == null ? "" : `${Number(value).toFixed(1)}%`;
36
+
37
+ /** What an announced auction's Rate reads until the source publishes its results. */
38
+ const RESULTS_NOT_PUBLISHED = "results not published";
39
+ const pending = (row: Record<string, unknown>) => isPendingAuction(row as unknown as TreasuryAuction);
37
40
 
38
41
  /** Why the stop-out column is not a tail; the docs page says the same at length. */
39
42
  const STOP_OUT_NOTE = "Stop-out vs avg is the high yield minus the average/median yield, in bp (bills: discount rates). "
40
43
  + "A true tail, the stop-out against the 1 pm when-issued yield, needs the when-issued yield, which the source does not carry.";
41
44
 
42
45
  const MISSING_METRICS_NOTE = "A blank B/C or bidder share is one the source does not have for that auction: "
43
- + "bid-to-cover is regular from late 1999, the indirect, direct and dealer split from 2008-04-07. - is a result not out yet.";
46
+ + "bid-to-cover is regular from late 1999, the indirect, direct and dealer split from 2008-04-07.";
44
47
 
45
48
  /** A completed auction the source gives no bid-to-cover or no bidder split for. */
46
49
  const lacksMetrics = (auction: TreasuryAuction) => !isPendingAuction(auction)
47
50
  && (auction.bidToCoverRatio == null || indirectPct(auction) == null || directPct(auction) == null || dealerPct(auction) == null);
48
51
 
49
- /** The rate as the table prints it; a TIPS or FRN row says what its number is (`2.653% real`, `4.0bp spread`). */
52
+ /**
53
+ * The rate as the table prints it; a TIPS or FRN row says what its number is
54
+ * (`2.653% real`, `4.0bp spread`), and an announced auction that its results
55
+ * are not published yet.
56
+ */
50
57
  const rateCell = (value: unknown, row: Record<string, unknown>) => {
51
- const text = formatAuctionRate(row as unknown as TreasuryAuction, value == null ? null : Number(value), "-");
58
+ const text = formatAuctionRate(row as unknown as TreasuryAuction, value == null ? null : Number(value), pending(row) ? RESULTS_NOT_PUBLISHED : "-");
52
59
  const qualifier = value == null ? null : rateQualifier(row as unknown as TreasuryAuction);
53
60
  return qualifier ? `${text} ${qualifier}` : text;
54
61
  };
@@ -61,14 +68,16 @@ const COLUMNS = [
61
68
  key: "rate",
62
69
  header: "Rate",
63
70
  align: "right" as const,
64
- format: (value: unknown, row: Record<string, unknown>) => formatAuctionRate(row as unknown as TreasuryAuction, value == null ? null : Number(value), "-"),
71
+ format: (value: unknown, row: Record<string, unknown>) => formatAuctionRate(
72
+ row as unknown as TreasuryAuction, value == null ? null : Number(value), pending(row) ? RESULTS_NOT_PUBLISHED : "-",
73
+ ),
65
74
  textFormat: rateCell,
66
75
  },
67
- { key: "stopOutVsAvgBp", header: "Stop-out vs avg (bp)", align: "right" as const, format: (value: unknown) => value == null ? "-" : Number(value).toFixed(1) },
68
76
  {
69
- key: "bidToCoverRatio", header: "B/C", align: "right" as const,
70
- format: (value: unknown, row: Record<string, unknown>) => value == null ? missing(row) : Number(value).toFixed(2),
77
+ key: "stopOutVsAvgBp", header: "Stop-out vs avg (bp)", align: "right" as const,
78
+ format: (value: unknown, row: Record<string, unknown>) => value == null ? pending(row) ? "" : "-" : Number(value).toFixed(1),
71
79
  },
80
+ { key: "bidToCoverRatio", header: "B/C", align: "right" as const, format: (value: unknown) => value == null ? "" : Number(value).toFixed(2) },
72
81
  { key: "indirectPercent", header: "Indirect", align: "right" as const, format: formatPercent },
73
82
  { key: "directPercent", header: "Direct", align: "right" as const, format: formatPercent },
74
83
  { key: "dealerPercent", header: "Dealer", align: "right" as const, format: formatPercent },
@@ -105,6 +105,9 @@ function secTypeColor(secType: string, selected: boolean): string {
105
105
  }
106
106
  }
107
107
 
108
+ /** An announced auction's Rate in the table, the word other panes use for a figure not out yet (KPI's Later actual). */
109
+ const RESULTS_PENDING = "Pending";
110
+
108
111
  // Stable table adapter so memoized rows survive pane re-renders.
109
112
  const auctionKey = (auction: TreasuryAuction) => auction.id;
110
113
 
@@ -119,8 +122,8 @@ function renderAuctionCell(
119
122
 
120
123
  switch (column.id) {
121
124
  case "date":
122
- // Announced auctions have no results yet; every metric cell reads "—",
123
- // so the date carries the distinction instead of a second placeholder.
125
+ // Announced auctions have no results yet: Rate reads Pending, the other
126
+ // metric cells "—", and the date is bright.
124
127
  return {
125
128
  text: showYear ? auction.auctionDate : formatShortDate(auction.auctionDate, { year: false, utc: true, fallback: "\u2014" }),
126
129
  color: rowState.selected ? colors.selectedText : isPendingAuction(auction) ? colors.textBright : colors.textDim,
@@ -134,7 +137,10 @@ function renderAuctionCell(
134
137
  case "term":
135
138
  return { text: auction.securityTerm, color: selected ?? colors.text };
136
139
  case "rate":
137
- return { text: formatAuctionRate(auction, rateValue(auction), "—"), color: selected ?? colors.textBright };
140
+ // An announced auction says its results are pending, as its detail does; other metric cells stay "—".
141
+ return isPendingAuction(auction)
142
+ ? { text: RESULTS_PENDING, color: dimmed }
143
+ : { text: formatAuctionRate(auction, rateValue(auction), "—"), color: selected ?? colors.textBright };
138
144
  case "stopOut":
139
145
  return { text: formatBp(stopOutVsAverageBp(auction)), color: selected ?? colors.text };
140
146
  case "btc":
@@ -171,8 +171,11 @@ export function VolatilityPane({ focused, width, height }: PaneProps) {
171
171
  usePaneFooter("volatility", () => ({ info: [
172
172
  ...(resource.loading ? [{ id: "loading", parts: [{ text: "loading volatility", tone: "muted" as const }] }] : []),
173
173
  ...(result?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
174
- ...(data ? [{ id: "basis", parts: [{ text: observationBasis, tone: "muted" as const }] }] : []),
175
- ...(asOf ? [{ id: "date", parts: [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] }] : []),
174
+ // One fact, "close 2026-09-25": the basis and its date read as one segment.
175
+ ...(data || asOf ? [{ id: "basis", parts: [
176
+ ...(data ? [{ text: observationBasis, tone: "muted" as const }] : []),
177
+ ...(asOf ? [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] : []),
178
+ ] }] : []),
176
179
  ], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, data, asOf, observationTime, observationBasis, cycleTab]);
177
180
  const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs: TABS, activeValue: tab, onSelect: setTab, focused, dense: true });
178
181
  const contentHeight = Math.max(5, height - tabRows);
@@ -1,8 +1,11 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import type { SwapCurvePayload, SwapTenor } from "../../../api-client/swap-curve";
3
- import { createPluginCache } from "../../../data/plugin-cache";
3
+ import { isPermanentClientError } from "../../../api-client/errors";
4
+ import { createPluginCache, type PluginCacheResult } from "../../../data/plugin-cache";
4
5
  import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
5
6
  import { validateSwapCurve } from "./swaps-model";
7
+ import { loadCurveData, type CurveData, type CurveId } from "./curves";
8
+ import { loadWorldRows, type WorldRow } from "./world";
6
9
 
7
10
  export const swapCurveCache = createPluginCache<SwapCurvePayload>({
8
11
  kind: "sofr-swap-curve", source: "gloom-cloud", schemaVersion: 1,
@@ -23,3 +26,60 @@ export async function fetchSwapCurve(tenor: SwapTenor,
23
26
  export const getCachedSwapCurve = (tenor: SwapTenor) => cachedCloudResource(swapCurveCache, tenor, validateSwapCurve);
24
27
  export const loadSwapCurve = (tenor: SwapTenor, force = false) =>
25
28
  loadCloudResource(swapCurveCache, tenor, () => fetchSwapCurve(tenor), { force, validate: validateSwapCurve });
29
+
30
+ const DAY_MS = 24 * 60 * 60_000;
31
+
32
+ /**
33
+ * The Curve and World tabs' curves as last read, so a refresh that fails
34
+ * (offline, a service error) shows the copy an earlier read saved instead of
35
+ * an empty tab. Every load still asks the server; the copy is the fallback.
36
+ */
37
+ export const curveViewCache = createPluginCache<CurveData>({
38
+ kind: "yield-curve-view", source: "gloom-cloud", schemaVersion: 1,
39
+ policy: { staleMs: 15 * 60_000, expireMs: 30 * DAY_MS },
40
+ });
41
+ export const worldCurvesCache = createPluginCache<WorldRow[]>({
42
+ kind: "world-curves", source: "gloom-cloud", schemaVersion: 1,
43
+ policy: { staleMs: 15 * 60_000, expireMs: 30 * DAY_MS },
44
+ });
45
+
46
+ /** What a tab shows, and when the copy shown was saved when it is one, its refresh having failed. */
47
+ export interface SavedCopyAnswer<T> {
48
+ data: T;
49
+ savedAt: number | null;
50
+ }
51
+
52
+ const curveKey = (curve: CurveId, requestedDate: string) => `${curve}:${requestedDate || "latest"}`;
53
+ /**
54
+ * A copy is the saved one by where it came from, whatever its failure said. A
55
+ * refusal asking again will not change (access denied, an unknown curve) is
56
+ * said rather than covered by the copy, as `loadCloudResource` does.
57
+ */
58
+ function answer<T>(result: PluginCacheResult<T>): SavedCopyAnswer<T> {
59
+ const saved = result.source === "stale-fallback";
60
+ if (saved && isPermanentClientError(result.error)) throw result.error;
61
+ return { data: result.data, savedAt: saved ? result.fetchedAt : null };
62
+ }
63
+
64
+ /** The curve on the date (its latest without one), or the copy an earlier read saved when it cannot be read. */
65
+ export async function loadSavedCurve(curve: CurveId, requestedDate: string,
66
+ client?: Parameters<typeof loadCurveData>[2]): Promise<SavedCopyAnswer<CurveData>> {
67
+ return answer(await curveViewCache.load(curveKey(curve, requestedDate), () => loadCurveData(curve, requestedDate, client), { force: true }));
68
+ }
69
+
70
+ /** The saved curve, for the tab's first paint while it is read again. */
71
+ export function cachedCurve(curve: CurveId, requestedDate: string): SavedCopyAnswer<CurveData> | null {
72
+ const cached = curveViewCache.get(curveKey(curve, requestedDate), { allowExpired: true });
73
+ return cached ? { data: cached.data, savedAt: null } : null;
74
+ }
75
+
76
+ /** Each market's latest curve, or the copy an earlier read saved when they cannot be read. */
77
+ export async function loadSavedWorldRows(client?: Parameters<typeof loadWorldRows>[0]): Promise<SavedCopyAnswer<WorldRow[]>> {
78
+ return answer(await worldCurvesCache.load("latest", () => loadWorldRows(client), { force: true }));
79
+ }
80
+
81
+ /** The saved world board, for the tab's first paint while it is read again. */
82
+ export function cachedWorldRows(): SavedCopyAnswer<WorldRow[]> | null {
83
+ const cached = worldCurvesCache.get("latest", { allowExpired: true });
84
+ return cached ? { data: cached.data, savedAt: null } : null;
85
+ }
@@ -4,16 +4,17 @@ import type {
4
4
  } from "../../../types/plugin";
5
5
  import {
6
6
  compareDate, curveCutoff, CURVE_OPTIONS, curveIdOf, curveOption, curveSpreadFigures, curveTenorMove, loadComparePoints,
7
- loadCurveData, parseCompareInput, parseCurveArgument, type CurveData, type CurveId,
7
+ parseCompareInput, parseCurveArgument, type CurveData, type CurveId,
8
8
  } from "./curves";
9
9
  import { yieldTenorRows, type YieldLookbackCurves } from "./chart";
10
10
  import { curveAsOf, type YieldPoint, yieldCurveErrors } from "./treasury-data";
11
11
  import { loadYieldCurveLookbacks, yieldCurveDate } from "./history";
12
- import { loadWorldRows, type WorldRow } from "./world";
12
+ import type { WorldRow } from "./world";
13
13
  import { formatBasisPoints, toBasisPoints } from "../../../utils/basis-points";
14
14
  import { DATE_OPTION_EXAMPLES } from "../../../utils/calendar-date";
15
15
  import { SWAP_TENORS, type SwapCurvePayload, type SwapTenor } from "../../../api-client/swap-curve";
16
- import { fetchSwapCurve } from "./client";
16
+ import { fetchSwapCurve, loadSavedCurve, loadSavedWorldRows } from "./client";
17
+ import { savedCopyMark } from "../../../data/saved-copies";
17
18
  import { formatSwapBp, selectedSwapTenor } from "./swaps-model";
18
19
 
19
20
  const percent = (value: unknown) => value == null ? "-" : `${Number(value).toFixed(2)}%`;
@@ -87,13 +88,14 @@ export interface YieldCurveHeadlessDependencies {
87
88
  }
88
89
 
89
90
  const defaultDependencies: Required<YieldCurveHeadlessDependencies> = {
90
- load: (curve, requestedDate, context) => loadCurveData(curve, requestedDate, context.apiClient),
91
+ // A curve that cannot be read is the copy an earlier read saved, marked so the report says so.
92
+ load: async (curve, requestedDate, context) => (await loadSavedCurve(curve, requestedDate, context.apiClient)).data,
91
93
  compare: (curve, date, context) => loadComparePoints(curve, date, context.apiClient),
92
94
  lookbacks: async (session, context) => Object.fromEntries(
93
95
  (await loadYieldCurveLookbacks(session, (id, options) => context.apiClient.getCloudFredSeries(id, options)))
94
96
  .map((lookback) => [lookback.id, lookback.points]),
95
97
  ),
96
- world: (context) => loadWorldRows(context.apiClient),
98
+ world: async (context) => (await loadSavedWorldRows(context.apiClient)).data,
97
99
  swaps: (tenor, context) => fetchSwapCurve(tenor, context.apiClient),
98
100
  };
99
101
 
@@ -184,7 +186,7 @@ export function createYieldCurveHeadless(
184
186
  return {
185
187
  freshness: { source: "Government bond publishers", status: "not-a-feed", basis: "daily curves" },
186
188
  columns: WORLD_COLUMNS,
187
- rows: rows.map((row) => ({ market: row.market, twoYear: row.twoYear, tenYear: row.tenYear, thirtyYear: row.thirtyYear,
189
+ rows: rows.map((row) => ({ ...savedCopyMark(row), market: row.market, twoYear: row.twoYear, tenYear: row.tenYear, thirtyYear: row.thirtyYear,
188
190
  twosTensBasisPoints: bp(row.twosTens), tenYearChangeBasisPoints: bp(row.tenYearChange), asOf: row.asOf })),
189
191
  metadata: { units: "Percent" },
190
192
  };
@@ -244,6 +246,7 @@ export function createYieldCurveHeadless(
244
246
  const day = ranked ? curveTenorMove(data, point.maturity, "1D") : null;
245
247
  const week = ranked ? curveTenorMove(data, point.maturity, "1W") : null;
246
248
  return {
249
+ ...savedCopyMark(data),
247
250
  ...point,
248
251
  change1dBasisPoints: change == null ? null : toBasisPoints(change, 1),
249
252
  ...(ranked ? {
@@ -8,6 +8,7 @@ import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve
8
8
  import { curveLookbackLabel } from "../../../components/chart/curve/model";
9
9
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
10
10
  import { useAsyncResource } from "../../../react/async-resource";
11
+ import { isPermanentClientError } from "../../../api-client/errors";
11
12
  import { usePaneSettingValue } from "../../../state/app/context";
12
13
  import { usePluginPaneState } from "../../../public/react";
13
14
  import { priceColor } from "../../../theme/colors";
@@ -18,6 +19,7 @@ import type { PluginModule } from "../plugin-module";
18
19
  import { GOVERNMENT_BOND_SERIES, TREASURY_SERIES } from "./series";
19
20
  import { useAutoRefresh } from "../../../react/auto-refresh";
20
21
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
22
+ import { savedCopyFooterSegment, type PaneFooterSegment } from "../../../components/layout/pane/footer";
21
23
  import { formatPercentileRank } from "../../../utils/format";
22
24
  import { SERIES_COLORS } from "../../../theme/series-colors";
23
25
  import type { CurveSeries } from "../../../components/chart/curve";
@@ -27,20 +29,28 @@ import {
27
29
  type YieldTenorRow,
28
30
  } from "./chart";
29
31
  import {
30
- compareDate, curveCutoff, curveIdOf, CURVE_OPTIONS, curveSpreadFigures, curveTenorMove, loadComparePoints, loadCurveData,
32
+ compareDate, curveCutoff, curveIdOf, CURVE_OPTIONS, curveSpreadFigures, curveTenorMove, loadComparePoints,
31
33
  parseCompareInput, parseCurveArgument, type CurveId,
32
34
  } from "./curves";
33
35
  import { formatMoveRank, MOVE_HORIZONS } from "../shared/move-rank";
34
36
  import { forwardCurve, type CurveBasis } from "./forward";
35
37
  import { loadYieldCurveLookbacks, yieldCurveDate } from "./history";
36
38
  import { curveAsOf, isYieldObservationDate, type YieldPoint, yieldCurveErrors } from "./treasury-data";
37
- import { loadWorldRows, type WorldRow } from "./world";
39
+ import type { WorldRow } from "./world";
38
40
  import { SwapsTab } from "./swaps";
39
- import { swapCurveCache } from "./client";
41
+ import {
42
+ cachedCurve, cachedWorldRows, curveViewCache, loadSavedCurve, loadSavedWorldRows, swapCurveCache, worldCurvesCache,
43
+ } from "./client";
40
44
 
41
45
  type GcTab = "curve" | "world" | "swaps";
42
46
  const TABS = [{ label: "Curve", value: "curve" }, { label: "World", value: "world" }, { label: "Swaps", value: "swaps" }];
43
47
  const EMPTY_POINTS: YieldPoint[] = [];
48
+
49
+ /** A tab showing the copy an earlier read saved, its refresh having failed, says when that copy is from. */
50
+ function useSavedCopyInfo(savedAt: number | null | undefined): PaneFooterSegment[] | undefined {
51
+ return useMemo(() => savedAt != null ? [savedCopyFooterSegment("saved-copy", savedAt)] : undefined, [savedAt]);
52
+ }
53
+
44
54
  /** Axis word for the loaded curve's basis. */
45
55
  const BASIS_LABEL: Record<CurveBasis, string> = {
46
56
  par: "Par yield",
@@ -165,8 +175,14 @@ function CurveTab({ curve, onCurveChange, focused, width, height }: {
165
175
  const compareField = useDraftField(compareInput);
166
176
  const editing = dateField.active || compareField.active;
167
177
 
168
- const loadCurve = useCallback(async () => loadCurveData(curve, requestedDate), [curve, requestedDate]);
169
- const { data, loading, error, updatedAt: lastUpdated, load } = useAsyncResource(loadCurve);
178
+ // The saved copy paints first and answers when a refresh fails; a failed refresh never empties the tab, unless
179
+ // asking again cannot help (access denied), which is said instead.
180
+ const loadCurve = useCallback(async () => loadSavedCurve(curve, requestedDate), [curve, requestedDate]);
181
+ const { data: answer, loading, error, updatedAt: lastUpdated, load } = useAsyncResource(loadCurve, {
182
+ initialData: () => cachedCurve(curve, requestedDate), clearOnError: isPermanentClientError,
183
+ });
184
+ const data = answer?.data ?? null;
185
+ const savedInfo = useSavedCopyInfo(answer?.savedAt);
170
186
  // A pending curve or date change must never relabel the previous curve as the new one.
171
187
  const current = data?.curve === curve && data.requestedDate === requestedDate ? data : null;
172
188
  const points = current?.points ?? EMPTY_POINTS;
@@ -260,6 +276,7 @@ function CurveTab({ curve, onCurveChange, focused, width, height }: {
260
276
  registrationId: "yield-curve",
261
277
  loading: loading || (compareKey != null && compareResource.loading && !comparePoints),
262
278
  error: error || sourceError || null,
279
+ info: savedInfo,
263
280
  hints: [
264
281
  { id: "date", key: "d", label: "ate", onPress: dateField.focus },
265
282
  { id: "compare", key: "v", label: "s", title: "Compare with a date", onPress: compareField.focus },
@@ -413,11 +430,14 @@ function WorldTab({ onOpen, focused, width, height }: {
413
430
  }) {
414
431
  const colors = useThemeColors();
415
432
  const [selected, setSelected] = usePluginPaneState<string | null>("yield-curve:market", null);
416
- const loadWorld = useCallback(() => loadWorldRows(), []);
417
- const { data, loading, error, updatedAt, load } = useAsyncResource(loadWorld);
433
+ const loadWorld = useCallback(() => loadSavedWorldRows(), []);
434
+ const { data: answer, loading, error, updatedAt, load } = useAsyncResource(loadWorld, {
435
+ initialData: cachedWorldRows, clearOnError: isPermanentClientError,
436
+ });
437
+ const data = answer?.data ?? null;
418
438
  useAutoRefresh(updatedAt, load);
419
439
  usePaneRefreshKey(() => void load(), { focused });
420
- usePaneStatusFooter({ registrationId: "yield-curve:world", loading, error: error || null });
440
+ usePaneStatusFooter({ registrationId: "yield-curve:world", loading, error: error || null, info: useSavedCopyInfo(answer?.savedAt) });
421
441
  const rows = data ?? [];
422
442
  const selectedId = rows.some((row) => row.id === selected) ? selected! : rows[0]?.id ?? null;
423
443
  // Every market's latest curve, each in its own colour; the selected row is
@@ -465,8 +485,12 @@ function WorldTab({ onOpen, focused, width, height }: {
465
485
  }
466
486
 
467
487
  export const yieldCurveModule: PluginModule = {
468
- setup(ctx) { swapCurveCache.attach(ctx.persistence); },
469
- dispose() { swapCurveCache.reset(); },
488
+ setup(ctx) {
489
+ for (const cache of [swapCurveCache, curveViewCache, worldCurvesCache]) cache.attach(ctx.persistence);
490
+ },
491
+ dispose() {
492
+ for (const cache of [swapCurveCache, curveViewCache, worldCurvesCache]) cache.reset();
493
+ },
470
494
  series: [...TREASURY_SERIES, ...GOVERNMENT_BOND_SERIES],
471
495
  panes: [{
472
496
  id: "yield-curve",
@@ -7,8 +7,9 @@ import {
7
7
  import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve";
8
8
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
9
9
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
10
+ import { savedCopyFooterSegment } from "../../../components/layout/pane/footer";
10
11
  import { FILL_REST } from "../../../components/ui/fill";
11
- import { isAccessDenied } from "../../../api-client/errors";
12
+ import { isPermanentClientError } from "../../../api-client/errors";
12
13
  import { usePluginPaneState } from "../../../public/react";
13
14
  import { useAsyncResource } from "../../../react/async-resource";
14
15
  import { useAutoRefresh } from "../../../react/auto-refresh";
@@ -37,17 +38,20 @@ export function SwapsTab({ focused, width, height }: { focused: boolean; width:
37
38
  const tenor = selectedSwapTenor(selected);
38
39
  const loader = useCallback((force: boolean) => loadSwapCurve(tenor, force), [tenor]);
39
40
  const resource = useAsyncResource(loader, {
40
- initialData: () => getCachedSwapCurve(tenor), keepPreviousData: true, clearOnError: isAccessDenied,
41
+ initialData: () => getCachedSwapCurve(tenor), keepPreviousData: true, clearOnError: isPermanentClientError,
41
42
  });
42
43
  const payload = resource.data?.payload;
43
44
  const rows = payload?.asOf ? payload.points : EMPTY_ROWS;
44
45
  // A new tenor keeps the curve/table in place, but never relabels the old tenor's history.
45
46
  const history = payload?.history.tenor === tenor ? payload.history : null;
46
- const stale = !!payload?.asOf && (payload.stale || resource.data?.stale || false);
47
- const error = resource.error || resource.data?.refreshError || null;
47
+ // A refresh that failed shows the copy an earlier read saved, and says when that copy is from.
48
+ const savedAt = resource.data?.savedAt ?? null;
49
+ const savedInfo = useMemo(() => savedAt != null ? [savedCopyFooterSegment("saved-copy", savedAt)] : undefined, [savedAt]);
50
+ const stale = !!payload?.asOf && (payload.stale || (savedAt == null && resource.data?.stale) || false);
51
+ const error = resource.error || (savedAt == null ? resource.data?.refreshError : null) || null;
48
52
  useAutoRefresh(stale ? null : resource.updatedAt, resource.load);
49
53
  usePaneRefreshKey(resource.reload, { focused });
50
- usePaneStatusFooter({ registrationId: "yield-curve:swaps", loading: resource.loading, error, stale });
54
+ usePaneStatusFooter({ registrationId: "yield-curve:swaps", loading: resource.loading, error, stale, info: savedInfo });
51
55
  usePaneNoticeFooter({
52
56
  registrationId: "yield-curve:swaps:notices", focused,
53
57
  notices: payload?.asOf ? [
@@ -45,6 +45,7 @@ import { commentLettersPlugin } from "./builtin/comment-letters";
45
45
  import { openFdaPlugin } from "./builtin/openfda";
46
46
  import { browserGloomberbCloudPlugin } from "./builtin/cloud/browser";
47
47
  import { changelogModule } from "./builtin/changelog";
48
+ import { roadmapModule } from "./builtin/roadmap";
48
49
  import { chartComposerModule } from "./builtin/chart-composer";
49
50
  import { connectionsModule } from "./builtin/connections";
50
51
  import { relativeRotationModule } from "./builtin/relative-rotation";
@@ -126,7 +127,7 @@ import { FIXED_INCOME_HOSTS } from "./builtin/fixed-income/model";
126
127
 
127
128
  const browserApplicationPlugin = composeBuiltinPlugin({
128
129
  ...applicationPluginMeta,
129
- modules: [layoutManagerModule, helpModule, changelogModule, connectionsModule, docsModule],
130
+ modules: [layoutManagerModule, helpModule, changelogModule, connectionsModule, docsModule, roadmapModule],
130
131
  });
131
132
 
132
133
  const runtimeOnly = (module: PluginModule): PluginModule => {
@@ -32,7 +32,7 @@ const LINKED_PACKAGES = ["gloomberb", "react"] as const;
32
32
 
33
33
  /**
34
34
  * The names the host is published under: `gloomberb`, which plugins import
35
- * as, and `@gloom-sh/terminal` once releases are also published under it.
35
+ * as, and `@gloom-sh/terminal`.
36
36
  */
37
37
  const HOST_PACKAGE_NAMES: ReadonlySet<string> = new Set(["gloomberb", "@gloom-sh/terminal"]);
38
38
 
@@ -185,15 +185,19 @@ function positionSideLabel(ticker: TickerRecord, activeTab: string | undefined):
185
185
  return "MIX";
186
186
  }
187
187
 
188
- /** The currency a row is listed in: its position's, else its quote's; an index's level is in points and has none. */
188
+ /**
189
+ * The currency a row is listed in: its position's, else its quote's or the one saved with it. An index's level is in
190
+ * points and has none, and a row whose currency no source gave (a coin pair before its quote) shows none, never the
191
+ * dollars it is valued in meanwhile: a row not held has no position currency, only that fallback.
192
+ */
189
193
  function listedCurrency(
190
- positionCurrency: string | undefined,
191
- quoteCurrency: string,
194
+ metrics: Pick<PortfolioPositionMetrics, "positionCount" | "positionCurrency">,
192
195
  ticker: TickerRecord,
193
196
  quote: TickerFinancials["quote"],
194
197
  ): string {
195
- if (!positionCurrency && isIndexLevel(quote?.instrumentType || ticker.metadata.assetCategory)) return "";
196
- return (positionCurrency || quoteCurrency || ticker.metadata.currency || "").toUpperCase();
198
+ const held = metrics.positionCount > 0 ? metrics.positionCurrency : "";
199
+ if (!held && isIndexLevel(quote?.instrumentType || ticker.metadata.assetCategory)) return "";
200
+ return (held || quote?.currency || ticker.metadata.currency || "").toUpperCase();
197
201
  }
198
202
 
199
203
  function compactText(value: string | null | undefined): string {
@@ -458,7 +462,7 @@ export function getColumnValue(
458
462
  ),
459
463
  };
460
464
  case "currency":
461
- return { text: compactText(currencyCodeLabel(listedCurrency(positionCurrency, quoteCurrency, ticker, quote))) };
465
+ return { text: compactText(currencyCodeLabel(listedCurrency(positionMetrics, ticker, quote))) };
462
466
  case "sector":
463
467
  return { text: compactText(ticker.metadata.sector || financials?.profile?.sector) };
464
468
  case "industry":
@@ -713,7 +717,7 @@ export function getSortValue(
713
717
  ?? ticker.metadata.exchange
714
718
  ?? null;
715
719
  case "currency":
716
- return listedCurrency(positionCurrency, quoteCurrency, ticker, quote) || null;
720
+ return listedCurrency(positionMetrics, ticker, quote) || null;
717
721
  case "sector":
718
722
  return ticker.metadata.sector || financials?.profile?.sector || null;
719
723
  case "industry":