@gloom-sh/terminal 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -5,12 +5,14 @@ import {
5
5
  type CloudEarningsCallPayload,
6
6
  type CloudEarningsTranscriptPayload,
7
7
  } from "../../../api-client";
8
- import { ApiRequestError } from "../../../api-client/errors";
8
+ import { ApiRequestError, isTransientFailure } from "../../../api-client/errors";
9
9
  import { listingAbroad, type IssuerListingParams, type ListingAbroad } from "../../../api-client/paths";
10
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
10
11
  import { areDifferentCompanies } from "../../../sources/sec-registrant";
11
12
  import type { HeadlessPaneApiClient, PluginPersistence } from "../../../types/plugin";
12
13
  import { shortDate } from "../earnings/format";
13
14
  import { callCountdown, callDay, callTimeKnown, isUpcomingCall } from "./upcoming";
15
+ import { type CompanyFrom, readCompanyFrom } from "../../../types/company-from";
14
16
 
15
17
  type EarningsCallsApiClient = Pick<
16
18
  HeadlessPaneApiClient,
@@ -43,6 +45,8 @@ export interface EarningsCallsResult {
43
45
  calls: CloudEarningsCallPayload[];
44
46
  fetchedAt: number;
45
47
  stale: boolean;
48
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
49
+ savedAt?: number;
46
50
  refreshError?: string;
47
51
  /** HTTP status when the request failed, used to pick the right gate. */
48
52
  errorStatus?: number;
@@ -50,6 +54,8 @@ export interface EarningsCallsResult {
50
54
  pending?: boolean;
51
55
  /** The symbol is not one the SEC knows, so there is nothing to look for. */
52
56
  unknownTicker?: boolean;
57
+ /** The company's home line the calls are read from, when not the listing asked. */
58
+ companyFrom?: CompanyFrom | null;
53
59
  /** Maximum rows requested from the server, which supplies no total/hasMore. */
54
60
  sourceLimit?: number;
55
61
  /** The response filled its request; additional calls may exist. */
@@ -118,6 +124,8 @@ export interface UpcomingCallsResult {
118
124
  stale: boolean;
119
125
  /** The server answered its limit's worth: calls later on these days may be missing. */
120
126
  truncated?: boolean;
127
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
128
+ savedAt?: number;
121
129
  refreshError?: string;
122
130
  errorStatus?: number;
123
131
  }
@@ -130,7 +138,7 @@ export interface UpcomingDays {
130
138
  let persistence: PluginPersistence | null = null;
131
139
  const activeUpcomingFetches = new Map<string, Promise<UpcomingCallsResult>>();
132
140
  const activeListFetches = new Map<string, Promise<EarningsCallsResult>>();
133
- const activeTranscriptFetches = new Map<string, Promise<CloudEarningsTranscriptPayload>>();
141
+ const activeTranscriptFetches = new Map<string, Promise<EarningsTranscriptResult>>();
134
142
 
135
143
  export function attachEarningsCallsPersistence(value: PluginPersistence): void {
136
144
  persistence = value;
@@ -216,8 +224,15 @@ export async function loadEarningsCallsWithClient(
216
224
  ...(offset > 0 ? { offset } : {}),
217
225
  })
218
226
  .then((payload) => {
219
- const calls = listingCalls(payload.calls ?? [], abroad);
220
- const value = { calls, pending: payload.pending === true, unknownTicker: payload.unknownTicker === true };
227
+ // Calls the service read from the company's home line are the company's, whatever the line's own name.
228
+ const companyFrom = readCompanyFrom(payload.companyFrom);
229
+ const calls = companyFrom ? payload.calls ?? [] : listingCalls(payload.calls ?? [], abroad);
230
+ const value = {
231
+ calls,
232
+ pending: payload.pending === true,
233
+ unknownTicker: payload.unknownTicker === true,
234
+ ...(companyFrom ? { companyFrom } : {}),
235
+ };
221
236
  // A list with calls still being produced changes by the minute, so it
222
237
  // is not worth keeping; a list of finished transcripts is.
223
238
  const settled = !value.pending && calls.every((call) => call.hasTranscript);
@@ -242,22 +257,24 @@ export async function loadEarningsCallsWithClient(
242
257
  .catch((error: unknown) => {
243
258
  const refreshError = error instanceof Error ? error.message : String(error);
244
259
  const errorStatus = statusOf(error);
245
- // An auth failure must surface its gate rather than silently showing
246
- // a cached list the user is no longer entitled to refresh.
260
+ // A refusal (signed out, no Pro, a route the server stopped serving) or a reply that fails
261
+ // validation is said rather than covered by a cached list.
247
262
  const expired = store?.getResource<CloudEarningsCallListPayload>(LIST_KIND, key, {
248
263
  sourceKey: CACHE_SOURCE,
249
264
  schemaVersion: LIST_CACHE_SCHEMA_VERSION,
250
265
  allowExpired: true,
251
266
  });
252
- if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
253
- return {
267
+ if (expired && isTransientFailure(error)) {
268
+ // A command that shows it ends with when the copy was saved, not with the failure.
269
+ return servedFromSavedCopy({
254
270
  ...expired.value,
255
271
  ...sourceScope(expired.value.calls),
256
272
  fetchedAt: expired.fetchedAt,
257
273
  stale: true,
274
+ savedAt: expired.fetchedAt,
258
275
  refreshError,
259
276
  errorStatus,
260
- };
277
+ }, expired.fetchedAt);
261
278
  }
262
279
  throw error;
263
280
  })
@@ -355,12 +372,13 @@ export async function loadUpcomingCallsWithClient(
355
372
  const errorStatus = statusOf(error);
356
373
  // Losing Pro access re-gates the calendar, as it does the list.
357
374
  const expired = read(true);
358
- if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
359
- return {
375
+ if (expired && isTransientFailure(error)) {
376
+ return servedFromSavedCopy({
360
377
  ...upcomingFromCache(expired.value, expired.fetchedAt, true),
378
+ savedAt: expired.fetchedAt,
361
379
  refreshError: error instanceof Error ? error.message : String(error),
362
380
  errorStatus,
363
- };
381
+ }, expired.fetchedAt);
364
382
  }
365
383
  throw error;
366
384
  })
@@ -397,11 +415,14 @@ export function isPendingTranscript(
397
415
  return record.status === "pending" || !Array.isArray(record.turns);
398
416
  }
399
417
 
418
+ /** A transcript, and when it was saved when its refresh failed and the saved copy answered. */
419
+ export type EarningsTranscriptResult = CloudEarningsTranscriptPayload & { savedAt?: number };
420
+
400
421
  export async function loadTranscriptWithClient(
401
422
  client: EarningsCallsApiClient,
402
423
  callId: string,
403
424
  options?: { force?: boolean },
404
- ): Promise<CloudEarningsTranscriptPayload> {
425
+ ): Promise<EarningsTranscriptResult> {
405
426
  const force = options?.force ?? false;
406
427
  const store = persistence;
407
428
 
@@ -432,7 +453,6 @@ export async function loadTranscriptWithClient(
432
453
  return transcript;
433
454
  })
434
455
  .catch((error: unknown) => {
435
- const status = statusOf(error);
436
456
  const expired = store?.getResource<CloudEarningsTranscriptPayload>(
437
457
  TRANSCRIPT_KIND,
438
458
  callId,
@@ -443,8 +463,8 @@ export async function loadTranscriptWithClient(
443
463
  },
444
464
  );
445
465
  // Losing Pro access must re-gate the transcript, not serve it from cache.
446
- if (expired && status !== 401 && status !== 402 && status !== 403) {
447
- return expired.value;
466
+ if (expired && isTransientFailure(error)) {
467
+ return servedFromSavedCopy({ ...expired.value, savedAt: expired.fetchedAt }, expired.fetchedAt);
448
468
  }
449
469
  throw error;
450
470
  })
@@ -461,7 +481,7 @@ export async function loadTranscriptWithClient(
461
481
  export function loadTranscript(
462
482
  callId: string,
463
483
  options?: { force?: boolean },
464
- ): Promise<CloudEarningsTranscriptPayload> {
484
+ ): Promise<EarningsTranscriptResult> {
465
485
  return loadTranscriptWithClient(apiClient, callId, options);
466
486
  }
467
487
 
@@ -46,6 +46,10 @@ import {
46
46
  type TranscriptSection,
47
47
  } from "./model";
48
48
  import { REPORT_FILTER, REPORT_PAGE, REPORT_ROWS } from "../shared/headless-options";
49
+ import { companyDataNotes } from "../../../types/company-from";
50
+ import { savedCopyMark, unansweredRefreshError } from "../../../data/saved-copies";
51
+
52
+ const optionalErrors = (error: string | null) => error ? [error] : undefined;
49
53
 
50
54
  const CALL_COLUMNS = [
51
55
  { key: "ticker", header: "Ticker" },
@@ -170,9 +174,12 @@ function projectEarningsCallsHeadless(
170
174
  columns: CALL_COLUMNS,
171
175
  rows,
172
176
  ...(next ? { textRows: rows.filter((row) => row.id !== next.id) } : {}),
173
- errors: result.refreshError ? [result.refreshError] : undefined,
177
+ errors: optionalErrors(unansweredRefreshError(result)),
174
178
  ...(sourceLimitReached || result.pending ? { complete: false } : {}),
179
+ ...(result.companyFrom ? { notes: companyDataNotes(result.companyFrom) } : {}),
175
180
  metadata: {
181
+ // A list served from a saved copy ends with when it was saved, instead of its refresh failure.
182
+ ...savedCopyMark(result),
176
183
  ...(next ? { notices: [nextCallNotice(next, now)], nextCall: { id: next.id, callAt: next.callAt, timeKnown: callTimeKnown(next), in: callCountdown(next, now) } } : {}),
177
184
  fetchedAt: result.fetchedAt,
178
185
  stale: result.stale,
@@ -211,9 +218,11 @@ function transcriptResult(
211
218
  return {
212
219
  columns: TRANSCRIPT_COLUMNS,
213
220
  rows,
214
- errors: calls.refreshError ? [calls.refreshError] : undefined,
221
+ errors: optionalErrors(unansweredRefreshError(calls)),
215
222
  ...(calls.stale || calls.pending ? { complete: false } : {}),
223
+ ...(calls.companyFrom ? { notes: companyDataNotes(calls.companyFrom) } : {}),
216
224
  metadata: {
225
+ ...savedCopyMark(calls),
217
226
  stale: calls.stale,
218
227
  callListFetchedAt: calls.fetchedAt,
219
228
  callListPending: calls.pending ?? false,
@@ -283,10 +292,11 @@ function projectCalendar(
283
292
  columns: CALENDAR_COLUMNS,
284
293
  textColumns: sameClock ? CALENDAR_TEXT_COLUMNS.filter((column) => column.key !== "local") : CALENDAR_TEXT_COLUMNS,
285
294
  rows,
286
- errors: result.refreshError ? [result.refreshError] : undefined,
295
+ errors: optionalErrors(unansweredRefreshError(result)),
287
296
  ...(result.stale || result.truncated ? { complete: false } : {}),
288
297
  empty: "No calls on the calendar these days.",
289
298
  metadata: {
299
+ ...savedCopyMark(result),
290
300
  view: "upcoming",
291
301
  fetchedAt: result.fetchedAt,
292
302
  stale: result.stale,
@@ -1,8 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import type {
3
- CloudEarningsCallPayload,
4
- CloudEarningsTranscriptPayload,
5
- } from "../../../api-client";
2
+ import type { CloudEarningsCallPayload } from "../../../api-client";
6
3
  import { recordResearchActivity } from "../../../api-client/research-activity";
7
4
  import {
8
5
  DataTableStackView,
@@ -19,6 +16,7 @@ import {
19
16
  type QueryBarView,
20
17
  type StatItem,
21
18
  } from "../../../components";
19
+ import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
22
20
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
23
21
  import { AudioPlayer, useCanPlayAudio } from "../../../components/ui";
24
22
  import { useShortcut } from "../../../react/input";
@@ -48,7 +46,11 @@ import {
48
46
  loadEarningsCalls,
49
47
  loadTranscript,
50
48
  statusOf,
49
+ type EarningsTranscriptResult,
51
50
  } from "./data";
51
+ import { isPermanentClientError } from "../../../api-client/errors";
52
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
53
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
52
54
  import { callPeriod, callTitle, eventLabelOf, formatCallDate, formatDuration, formatSentiment } from "./format";
53
55
  import { findCallForQuarter, findInLines, MIN_FIND_LENGTH, turnParagraphs } from "./model";
54
56
  import { READER_TABS, TranscriptView, type ReaderTab, type TranscriptPlayback } from "./transcript-view";
@@ -147,6 +149,8 @@ interface TickerLookup {
147
149
  calls: CloudEarningsCallPayload[];
148
150
  fetchedAt?: number;
149
151
  stale?: boolean;
152
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
153
+ savedAt?: number;
150
154
  refreshError?: string;
151
155
  refreshRequest?: number;
152
156
  }
@@ -277,6 +281,10 @@ function CallsListView({
277
281
  const [listStatus, setListStatus] = useState<"idle" | "loading" | "loaded" | "error">(initialShelf ? "loaded" : "idle");
278
282
  const [listError, setListError] = useState<{ message: string; status?: number } | null>(null);
279
283
  const [stale, setStale] = useState(initialShelf?.stale === true);
284
+ // The company's home line the shelf is read from, for a receipt or a foreign line.
285
+ const [companyFrom, setCompanyFrom] = useState(initialShelf?.companyFrom ?? null);
286
+ // The list's refresh failed and a copy saved at this time (ms) answered instead.
287
+ const [listSavedAt, setListSavedAt] = useState<number | null>(null);
280
288
  const [listFetchedAt, setListFetchedAt] = useState(initialShelf?.fetchedAt ?? 0);
281
289
  const listRequestVersion = useRef(0);
282
290
  const [listCompletedRequest, setListCompletedRequest] = useState(0);
@@ -289,7 +297,7 @@ function CallsListView({
289
297
 
290
298
  const [selectedId, setSelectedId] = usePluginPaneState<string | null>(`${stateKey}selectedId`, null);
291
299
  const [detailOpen, setDetailOpen] = usePluginPaneState<boolean>(`${stateKey}detailOpen`, false);
292
- const [transcript, setTranscript] = useState<CloudEarningsTranscriptPayload | null>(null);
300
+ const [transcript, setTranscript] = useState<EarningsTranscriptResult | null>(null);
293
301
  useEffect(() => {
294
302
  if (focused && transcript && detailOpen) recordResearchActivity("pro_feature_used", "transcripts");
295
303
  }, [focused, transcript, detailOpen]);
@@ -340,10 +348,13 @@ function CallsListView({
340
348
  setNextOffset(result.calls.length);
341
349
  setHasMore(result.sourceLimitReached === true);
342
350
  setStale(result.stale);
351
+ setCompanyFrom(result.companyFrom ?? null);
352
+ setListSavedAt(result.savedAt ?? null);
343
353
  setListFetchedAt(result.fetchedAt);
344
354
  setListPending(result.pending === true && result.calls.length === 0);
355
+ // A saved copy says when it was saved, in the footer, rather than why the refresh failed.
345
356
  setListError(
346
- result.refreshError ? { message: result.refreshError, status: result.errorStatus } : null,
357
+ result.refreshError && result.savedAt == null ? { message: result.refreshError, status: result.errorStatus } : null,
347
358
  );
348
359
  setListStatus("loaded");
349
360
  setListCompletedRequest(request);
@@ -353,6 +364,7 @@ function CallsListView({
353
364
  setListPending(false);
354
365
  setHasMore(false);
355
366
  setNextOffset(0);
367
+ setListSavedAt(null);
356
368
  setListError({
357
369
  message: error instanceof Error ? error.message : String(error),
358
370
  status: statusOf(error),
@@ -410,6 +422,8 @@ function CallsListView({
410
422
  setListStatus(held ? "loaded" : "idle");
411
423
  setListPending(held?.pending === true && held.calls.length === 0);
412
424
  setStale(held?.stale === true);
425
+ setCompanyFrom(held?.companyFrom ?? null);
426
+ setListSavedAt(null);
413
427
  setListFetchedAt(held?.fetchedAt ?? 0);
414
428
  setListError(null);
415
429
  setLookup(null);
@@ -477,19 +491,22 @@ function CallsListView({
477
491
  calls: result.calls,
478
492
  fetchedAt: result.fetchedAt,
479
493
  stale: result.stale,
480
- refreshError: result.refreshError,
494
+ savedAt: result.savedAt,
495
+ refreshError: result.savedAt == null ? result.refreshError : undefined,
481
496
  refreshRequest,
482
497
  });
483
498
  })
484
499
  .catch((error: unknown) => {
485
500
  if (cancelled) return;
486
- const retainRows = ![401, 402, 403].includes(statusOf(error) ?? 0);
501
+ // A refusal asking again will not change clears the rows; a passing failure keeps them.
502
+ const retainRows = !isPermanentClientError(error);
487
503
  setLookup((current) => ({
488
504
  ticker: lookupTicker,
489
505
  status: "error",
490
506
  calls: retainRows && current?.ticker === lookupTicker ? current.calls : [],
491
507
  fetchedAt: retainRows && current?.ticker === lookupTicker ? current.fetchedAt : undefined,
492
508
  stale: retainRows && current?.ticker === lookupTicker && current.calls.length > 0,
509
+ savedAt: retainRows && current?.ticker === lookupTicker ? current.savedAt : undefined,
493
510
  refreshError: error instanceof Error ? error.message : String(error),
494
511
  refreshRequest,
495
512
  }));
@@ -817,6 +834,8 @@ function CallsListView({
817
834
  [focusSearch, scrollTranscriptBy, openSource, playerShown, playback.toggle, playback.seekBy],
818
835
  );
819
836
 
837
+ // A receipt's or foreign line's calls are its company's, read from the home line.
838
+ useCompanyDataFooter(companyFrom, !readerOpen);
820
839
  usePaneFooter(
821
840
  footerId,
822
841
  () => {
@@ -857,15 +876,14 @@ function CallsListView({
857
876
  }
858
877
  }
859
878
  const visibleLookup = lookup && (lookup.ticker === lookupTicker || (detailOpen && selected?.ticker === lookup.ticker)) ? lookup : null;
860
- if (visibleLookup?.stale) {
861
- info.push({ id: "lookup-stale", parts: [{ text: `${visibleLookup.ticker} stale cache`, tone: "warning" }] });
862
- }
863
879
  if (visibleLookup?.refreshError && lookupState !== "error") {
864
880
  info.push({ id: "lookup-error", parts: [{ text: `${visibleLookup.ticker}: ${visibleLookup.refreshError}`, tone: "warning" }] });
865
881
  }
866
- if (stale) {
867
- info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] });
868
- }
882
+ // What a failed refresh left on screen: a saved copy says when it was saved; a list
883
+ // held from an earlier session, still refreshing, is only stale.
884
+ const savedCopy = savedCopyFooterInfo([listSavedAt, visibleLookup?.savedAt, readerOpen ? selectedTranscript?.savedAt : null]);
885
+ info.push(...savedCopy);
886
+ if (stale && listSavedAt == null) info.push(STALE_SEGMENT);
869
887
  if (listError) {
870
888
  info.push({ id: "error", parts: [{ text: listError.message, tone: "warning" }] });
871
889
  }
@@ -904,6 +922,8 @@ function CallsListView({
904
922
  lookupTicker,
905
923
  lookup,
906
924
  stale,
925
+ listSavedAt,
926
+ selectedTranscript,
907
927
  listError,
908
928
  proRequired,
909
929
  transcriptProRequired,
@@ -1,7 +1,7 @@
1
1
  import { createPluginCache } from "../../../data/plugin-cache";
2
2
  import type { DataTableColumn } from "../../../components";
3
3
  import { colors } from "../../../theme/colors";
4
- import { displayWidth } from "../../../utils/format";
4
+ import { displayWidth, MISSING_VALUE } from "../../../utils/format";
5
5
  import { fetchEconCalendar } from "./calendar-source";
6
6
  import { parseEconFigure } from "./figures";
7
7
  import { isAllDayEvent, type EconEvent, type EconImpact } from "./types";
@@ -235,6 +235,51 @@ export function eventCountry(event: Pick<EconEvent, "country" | "event">): strin
235
235
  return euroAreaMember(event) ?? event.country;
236
236
  }
237
237
 
238
+ /** What the feeds call the Fed's rate decision. */
239
+ const FED_RATE_DECISION_NAMES = new Set(["federal funds rate", "fed interest rate decision"]);
240
+ /** A figure that is a range (3.75-4.00%) rather than one level. */
241
+ const RANGE_FIGURE = /\d\s*[-\u2013]\s*\d/;
242
+ const FED_FUNDS_UPPER_BOUND = "Federal Funds Rate (upper bound)";
243
+
244
+ /**
245
+ * A row's name as the calendar shows it. The Fed's decision is figured as one
246
+ * level, the top of the target range (4.00% for 3.75-4.00%), so it is named
247
+ * for that bound rather than read as the range or the effective rate; a row
248
+ * whose figures are the range keeps the feed's name. Whatever matches rows by
249
+ * name (alerts, the FRED history) reads the feed's own, `event.event`.
250
+ */
251
+ export function econEventName(event: Pick<EconEvent, "country" | "event" | "actual" | "forecast" | "prior">): string {
252
+ if ((event.country !== "US" && event.country !== "USD") || !FED_RATE_DECISION_NAMES.has(event.event.trim().toLowerCase())) return event.event;
253
+ return [event.actual, event.forecast, event.prior].some((figure) => !!figure && RANGE_FIGURE.test(figure)) ? event.event : FED_FUNDS_UPPER_BOUND;
254
+ }
255
+
256
+ /** Letters and digits only, lower case: "Non-Farm" and "nonfarm" read alike. */
257
+ const compactName = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, "");
258
+
259
+ /**
260
+ * Whether a row's name carries every word of `query`, case, spaces and
261
+ * punctuation aside: `nonfarm` finds Non Farm Payrolls and Non-Farm
262
+ * Employment Change, `fed funds` the Federal Funds Rate. The name shown and the
263
+ * feed's own are both read.
264
+ */
265
+ export function matchesEventSearch(event: Pick<EconEvent, "country" | "event" | "actual" | "forecast" | "prior">, query: string): boolean {
266
+ const words = query.split(/\s+/).map(compactName).filter(Boolean);
267
+ if (words.length === 0) return true;
268
+ const names = [event.event, econEventName(event)].map(compactName);
269
+ return names.some((name) => words.every((word) => name.includes(word)));
270
+ }
271
+
272
+ /**
273
+ * A release still to come that has a prior and no forecast: the consensus is
274
+ * not in yet, which the calendar draws as a dash.
275
+ */
276
+ export function awaitsConsensus(event: Pick<EconEvent, "actual" | "forecast" | "prior">): boolean {
277
+ return !event.actual?.trim() && !event.forecast?.trim() && !!event.prior?.trim();
278
+ }
279
+
280
+ /** What a dash under Forecast means on a row still to come. */
281
+ export const NO_CONSENSUS_NOTE = `${MISSING_VALUE} forecast = no consensus yet`;
282
+
238
283
  /** A national euro-area release matches EU (and G7, as EU) and its own member's code. */
239
284
  export function matchesCountry(event: Pick<EconEvent, "country" | "event">, filter: CountryFilter): boolean {
240
285
  const member = euroAreaMember(event);
@@ -376,6 +421,8 @@ export function subscribeCalendar(listener: () => void): () => void {
376
421
  }
377
422
 
378
423
  export interface EconCalendarLoadResult extends EconCalendarCacheEntry {
424
+ /** `stale-fallback`: the copy an earlier read saved, answering because its refresh failed. */
425
+ source?: "cache" | "network" | "stale-fallback";
379
426
  refreshError?: string;
380
427
  }
381
428
 
@@ -1,5 +1,6 @@
1
1
  import { apiClient, type CloudEconEventPayload } from "../../../api-client";
2
2
  import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
3
+ import { withPercentUnits } from "./figures";
3
4
  import type { EconEvent } from "./types";
4
5
 
5
6
  function toEconEvent(event: CloudEconEventPayload): EconEvent {
@@ -9,9 +10,10 @@ function toEconEvent(event: CloudEconEventPayload): EconEvent {
9
10
  };
10
11
  }
11
12
 
13
+ /** The calendar, a percent indicator's figures in percent on every row (`withPercentUnits`). */
12
14
  export async function fetchEconCalendar(): Promise<EconEvent[]> {
13
15
  const events = await apiClient.getCloudEconomicCalendar();
14
- return events.map(toEconEvent);
16
+ return withPercentUnits(events.map(toEconEvent));
15
17
  }
16
18
 
17
19
  /** The stored calendar from a day, or why it was not read so the caller reads the current calendar instead. */
@@ -40,7 +42,7 @@ export async function fetchEconCalendarFrom(from: string): Promise<EconCalendarF
40
42
  if (!payload || !Array.isArray(payload.events) || !(payload.storedFrom === null || /^\d{4}-\d{2}-\d{2}$/.test(String(payload.storedFrom)))) {
41
43
  return { events: null, failure: "the server returned an unreadable calendar" };
42
44
  }
43
- return { events: payload.events.map(toEconEvent), storedFrom: payload.storedFrom };
45
+ return { events: withPercentUnits(payload.events.map(toEconEvent)), storedFrom: payload.storedFrom };
44
46
  } catch (error) {
45
47
  if (error instanceof ApiRequestError && error.status === 404) return { events: null, failure: null };
46
48
  if (isPermanentClientError(error)) throw error;
@@ -18,6 +18,8 @@ import {
18
18
  type StatItem,
19
19
  } from "../../../components";
20
20
  import { spanDigits } from "../../../components/chart-table";
21
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
22
+ import { isPermanentClientError } from "../../../api-client/errors";
21
23
  import { getTableWidth, hasMeaningfulTableHorizontalOverflow } from "../../../components/ui/table-layout";
22
24
  import type { CompositeAxisDomain } from "../../../components/chart/composite/types";
23
25
  import {
@@ -31,7 +33,7 @@ import { Box, Text } from "../../../ui";
31
33
  import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
32
34
  import { useOpenTickerChoice } from "../shared/ticker-choice";
33
35
  import { resolveFredMapping, projectFredHistory, fredHistoryUnits } from "./fred-series-map";
34
- import { actualColor, CALENDAR_TIME_ZONE_LABEL, timeLabel } from "./calendar-model";
36
+ import { actualColor, CALENDAR_TIME_ZONE_LABEL, econEventName, timeLabel } from "./calendar-model";
35
37
  import { hasNoStatedTime, isAllDayEvent, type EconEvent } from "./types";
36
38
 
37
39
  interface EconDetailViewProps {
@@ -123,6 +125,8 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
123
125
  const [loading, setLoading] = useState(false);
124
126
  const [error, setError] = useState<string | null>(null);
125
127
  const [freshnessWarning, setFreshnessWarning] = useState<string | null>(null);
128
+ // The history's refresh failed and the copy saved at this time (ms) answered instead.
129
+ const [savedAt, setSavedAt] = useState<number | null>(null);
126
130
  const [data, setData] = useState<FredSeriesData | null>(null);
127
131
  // Null follows the latest period.
128
132
  const [selectedDate, setSelectedDate] = useState<string | null>(null);
@@ -146,6 +150,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
146
150
  setLoading(false);
147
151
  setError(null);
148
152
  setFreshnessWarning(null);
153
+ setSavedAt(null);
149
154
  return;
150
155
  }
151
156
 
@@ -156,6 +161,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
156
161
  setLoading(false);
157
162
  setError(null);
158
163
  setFreshnessWarning(null);
164
+ setSavedAt(null);
159
165
  return;
160
166
  }
161
167
  } else {
@@ -165,6 +171,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
165
171
  setLoading(true);
166
172
  setError(null);
167
173
  setFreshnessWarning(null);
174
+ setSavedAt(null);
168
175
 
169
176
  loadCachedFredSeries(
170
177
  request,
@@ -174,12 +181,18 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
174
181
  }),
175
182
  )
176
183
  .then((entry) => {
184
+ // A refusal or a reply that fails validation is thrown by the load, never covered by a saved copy.
185
+ const saved = entry.source === "stale-fallback";
177
186
  setData(entry.data);
178
- setFreshnessWarning(entry.stale
179
- ? `Cached ${new Date(entry.fetchedAt).toISOString().slice(0, 10)} · refresh failed`
187
+ // A copy a failed refresh left says when it was saved, in the footer as every pane does;
188
+ // a series its source has not updated since is a note.
189
+ setSavedAt(saved ? entry.fetchedAt : null);
190
+ setFreshnessWarning(entry.stale && !saved
191
+ ? `Source not updated since ${new Date(entry.fetchedAt).toISOString().slice(0, 10)}.`
180
192
  : null);
181
193
  })
182
194
  .catch((err) => {
195
+ if (isPermanentClientError(err)) setData(null);
183
196
  setError(err instanceof Error ? err.message : String(err));
184
197
  })
185
198
  .finally(() => {
@@ -199,6 +212,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
199
212
  // which asks which one when there are several.
200
213
  const openTickerChoice = useOpenTickerChoice();
201
214
  const relatedTickers = useMemo(() => mapping?.relatedTickers ?? [], [mapping]);
215
+ usePaneFooter("econ-detail:saved", () => ({ info: savedCopyFooterInfo([savedAt]) }), [savedAt]);
202
216
  usePaneFooter("econ-detail:related", () => relatedTickers.length > 0 ? {
203
217
  hints: [{
204
218
  id: "related",
@@ -238,7 +252,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
238
252
  const rows = useMemo<HistoryRow[]>(() => projected
239
253
  .map((obs, index) => ({ date: obs.date, value: obs.value, change: index > 0 ? obs.value - projected[index - 1]!.value : null }))
240
254
  .reverse(), [projected]);
241
- const label = event.event;
255
+ const label = econEventName(event);
242
256
  const series = useMemo(() => [staticSeries(
243
257
  projected.map((obs) => scalarPoint(new Date(`${obs.date}T00:00:00Z`), obs.value)),
244
258
  { id: "fred-history", label, color: colors.positive, calendarSpaced: true },
@@ -70,3 +70,51 @@ export function econRowFigures(row: { actual?: string | null; forecast?: string
70
70
  scale: first?.scale ?? null,
71
71
  };
72
72
  }
73
+
74
+ /** A figure printed as a bare number, with no unit and no scale letter: `0.6`, `-3`, `1,5`. */
75
+ const BARE_NUMBER = /^[+-]?(?:\d+(?:[.,]\d+)?|\.\d+)$/;
76
+
77
+ /**
78
+ * Release names that are a percent whatever one row's figures say: a change
79
+ * month on month, quarter on quarter or year on year, and the rates published
80
+ * in percent. "Consumer Inflation Expectations" is a balance, not a rate, and
81
+ * is not one of them.
82
+ */
83
+ const PERCENT_NAME = /\b(?:mom|qoq|yoy)\b|(?:^|\s)[mqy]\/[mqy](?=\s|$)|\b(?:inflation|unemployment|jobless|interest|growth|mortgage|funds|participation|savings) rate\b/i;
84
+
85
+ type EconRowText = { country: string; event: string; indicatorId?: string | null; actual: string | null; forecast: string | null; prior: string | null };
86
+
87
+ const FIGURE_KEYS = ["actual", "forecast", "prior"] as const;
88
+
89
+ /** Every release of one indicator: its id, else its country and name. */
90
+ const indicatorKey = (event: Pick<EconRowText, "country" | "event" | "indicatorId">) => event.indicatorId || `${event.country}|${event.event}`;
91
+
92
+ /**
93
+ * The calendar with every figure of a percent indicator in percent. The feeds
94
+ * drop the sign on some rows: US Inflation Rate MoM's prior came as `0.6`, and
95
+ * the 30-year mortgage rate's as `7.32` beside its actual `7.41%`. An
96
+ * indicator is in percent when any of its figures is, or its name says so
97
+ * (`PERCENT_NAME`), and none of its figures is in another unit or scale; its
98
+ * bare figures then carry `%`. Anything else is left as the feed wrote it.
99
+ */
100
+ export function withPercentUnits<T extends EconRowText>(events: readonly T[]): T[] {
101
+ const indicators = new Map<string, { percent: boolean; other: boolean }>();
102
+ for (const event of events) {
103
+ const key = indicatorKey(event);
104
+ const seen = indicators.get(key) ?? { percent: false, other: false };
105
+ if (PERCENT_NAME.test(event.event)) seen.percent = true;
106
+ for (const field of FIGURE_KEYS) {
107
+ const figure = parseEconFigure(event[field]);
108
+ if (!figure) continue;
109
+ if (figure.unit === "%") seen.percent = true;
110
+ else if (figure.unit || figure.scale !== 1) seen.other = true;
111
+ }
112
+ indicators.set(key, seen);
113
+ }
114
+ return events.map((event) => {
115
+ const seen = indicators.get(indicatorKey(event))!;
116
+ if (!seen.percent || seen.other) return event;
117
+ const bare = FIGURE_KEYS.filter((field) => BARE_NUMBER.test(event[field]?.trim() ?? ""));
118
+ return bare.length ? { ...event, ...Object.fromEntries(bare.map((field) => [field, `${event[field]!.trim()}%`])) } : event;
119
+ });
120
+ }