@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
@@ -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,
@@ -19,6 +19,7 @@ import {
19
19
  type QueryBarView,
20
20
  type StatItem,
21
21
  } from "../../../components";
22
+ import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
22
23
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
23
24
  import { AudioPlayer, useCanPlayAudio } from "../../../components/ui";
24
25
  import { useShortcut } from "../../../react/input";
@@ -277,6 +278,8 @@ function CallsListView({
277
278
  const [listStatus, setListStatus] = useState<"idle" | "loading" | "loaded" | "error">(initialShelf ? "loaded" : "idle");
278
279
  const [listError, setListError] = useState<{ message: string; status?: number } | null>(null);
279
280
  const [stale, setStale] = useState(initialShelf?.stale === true);
281
+ // The company's home line the shelf is read from, for a receipt or a foreign line.
282
+ const [companyFrom, setCompanyFrom] = useState(initialShelf?.companyFrom ?? null);
280
283
  const [listFetchedAt, setListFetchedAt] = useState(initialShelf?.fetchedAt ?? 0);
281
284
  const listRequestVersion = useRef(0);
282
285
  const [listCompletedRequest, setListCompletedRequest] = useState(0);
@@ -340,6 +343,7 @@ function CallsListView({
340
343
  setNextOffset(result.calls.length);
341
344
  setHasMore(result.sourceLimitReached === true);
342
345
  setStale(result.stale);
346
+ setCompanyFrom(result.companyFrom ?? null);
343
347
  setListFetchedAt(result.fetchedAt);
344
348
  setListPending(result.pending === true && result.calls.length === 0);
345
349
  setListError(
@@ -410,6 +414,7 @@ function CallsListView({
410
414
  setListStatus(held ? "loaded" : "idle");
411
415
  setListPending(held?.pending === true && held.calls.length === 0);
412
416
  setStale(held?.stale === true);
417
+ setCompanyFrom(held?.companyFrom ?? null);
413
418
  setListFetchedAt(held?.fetchedAt ?? 0);
414
419
  setListError(null);
415
420
  setLookup(null);
@@ -817,6 +822,8 @@ function CallsListView({
817
822
  [focusSearch, scrollTranscriptBy, openSource, playerShown, playback.toggle, playback.seekBy],
818
823
  );
819
824
 
825
+ // A receipt's or foreign line's calls are its company's, read from the home line.
826
+ useCompanyDataFooter(companyFrom, !readerOpen);
820
827
  usePaneFooter(
821
828
  footerId,
822
829
  () => {
@@ -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;
@@ -31,7 +31,7 @@ import { Box, Text } from "../../../ui";
31
31
  import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
32
32
  import { useOpenTickerChoice } from "../shared/ticker-choice";
33
33
  import { resolveFredMapping, projectFredHistory, fredHistoryUnits } from "./fred-series-map";
34
- import { actualColor, CALENDAR_TIME_ZONE_LABEL, timeLabel } from "./calendar-model";
34
+ import { actualColor, CALENDAR_TIME_ZONE_LABEL, econEventName, timeLabel } from "./calendar-model";
35
35
  import { hasNoStatedTime, isAllDayEvent, type EconEvent } from "./types";
36
36
 
37
37
  interface EconDetailViewProps {
@@ -238,7 +238,7 @@ export function EconDetailView({ event, width, height, focused }: EconDetailView
238
238
  const rows = useMemo<HistoryRow[]>(() => projected
239
239
  .map((obs, index) => ({ date: obs.date, value: obs.value, change: index > 0 ? obs.value - projected[index - 1]!.value : null }))
240
240
  .reverse(), [projected]);
241
- const label = event.event;
241
+ const label = econEventName(event);
242
242
  const series = useMemo(() => [staticSeries(
243
243
  projected.map((obs) => scalarPoint(new Date(`${obs.date}T00:00:00Z`), obs.value)),
244
244
  { 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
+ }
@@ -36,10 +36,12 @@ import {
36
36
  FILTER_CYCLE,
37
37
  attachEconCalendarPersistence,
38
38
  actualColor,
39
+ awaitsConsensus,
39
40
  calendarDisplayRows,
40
41
  CALENDAR_TIME_ZONE_LABEL,
41
42
  dateKey,
42
43
  dayLabel,
44
+ econEventName,
43
45
  filteredEmptyTitle,
44
46
  formatStaleness,
45
47
  getCalendarCache,
@@ -50,6 +52,7 @@ import {
50
52
  eventCountry,
51
53
  matchesCountry,
52
54
  matchesImpact,
55
+ NO_CONSENSUS_NOTE,
53
56
  noScheduledReleasesMessage,
54
57
  parseCountryFilter,
55
58
  resetEconCalendarPersistence,
@@ -322,7 +325,7 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
322
325
  : formatCountdown(nextEvent.date.getTime() - now);
323
326
  // The countdown changes every tick, so it is footer status, not query bar
324
327
  // context; the footer ellipsizes a long event name.
325
- const nextText = nextEvent && nextCountdown ? `next ${nextEvent.event} ${nextCountdown}` : null;
328
+ const nextText = nextEvent && nextCountdown ? `next ${econEventName(nextEvent)} ${nextCountdown}` : null;
326
329
  // The cached first paint is being replaced; its age only counts once a load has answered.
327
330
  const showStale = stale && (!loading || calendar.updatedAt !== null);
328
331
  // An empty list says it in its empty state instead.
@@ -330,6 +333,9 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
330
333
  // What the High filter, where the calendar opens, leaves out, and the key that lists it (Shift+F steps back to All).
331
334
  const hiddenCount = useMemo(() => lowerImpactHidden(events, impactFilter, countryFilter), [countryFilter, events, impactFilter]);
332
335
  const hiddenStatus = hiddenCount > 0 && !detailEvent ? `${hiddenCount} lower-impact hidden` : null;
336
+ // The dash under Forecast on the row in hand, while its consensus is not in yet.
337
+ const focusedEvent = detailEvent ?? filtered[selectedIdx];
338
+ const noConsensus = !!focusedEvent && awaitsConsensus(focusedEvent);
333
339
  const calendarStatus = useMemo<PaneFooterSegment[]>(() => [
334
340
  ...(hiddenStatus ? [{
335
341
  id: "hidden-impact",
@@ -341,8 +347,11 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
341
347
  ...(nextText && !detailEvent ? [{ id: "next", parts: [{ text: nextText, tone: "muted" as const }] }] : []),
342
348
  ...(listedAfterStatus ? [{ id: "listed-after", parts: [{ text: listedAfterStatus, tone: "warning" as const }] }] : []),
343
349
  ...(showStale ? [{ id: "stale", parts: [{ text: "STALE", tone: "warning" as const }] }] : []),
350
+ // Behind the warnings, so a narrow footer gives it up before them, and ahead of the update time, which goes first.
351
+ ...(noConsensus ? [{ id: "no-consensus", parts: [{ text: NO_CONSENSUS_NOTE, tone: "muted" as const }],
352
+ variants: [[{ text: "no consensus yet", tone: "muted" as const }]] }] : []),
344
353
  ...(staleness ? [{ id: "updated", parts: [{ text: staleness, tone: "muted" as const }] }] : []),
345
- ], [detailEvent, hiddenStatus, listedAfterStatus, nextText, showStale, staleness]);
354
+ ], [detailEvent, hiddenStatus, listedAfterStatus, nextText, noConsensus, showStale, staleness]);
346
355
  // A report of the pane (`gloom fn ECO`) says it too.
347
356
  const listedThrough = listedEnd ? dateKey(listedEnd) : null;
348
357
  useRemoteUiNode(listedAfter && listedThrough ? {
@@ -433,7 +442,7 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
433
442
  return withCountryFlag({ text: country, color: colors.textMuted }, country, flagged);
434
443
  }
435
444
  case "event":
436
- return { text: ev.event, color: colors.text };
445
+ return { text: econEventName(ev), color: colors.text };
437
446
  case "actual":
438
447
  return {
439
448
  text: ev.actual ?? "—",
@@ -480,7 +489,7 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
480
489
  focused={focused}
481
490
  detailOpen={!!detailEvent}
482
491
  onBack={() => setOpenKey(null)}
483
- detailTitle={detailEvent?.event}
492
+ detailTitle={detailEvent ? econEventName(detailEvent) : undefined}
484
493
  detailContent={detailContent}
485
494
  rootWidth={width}
486
495
  rootHeight={Math.max(1, height - 1)}
@@ -576,7 +585,7 @@ export const economicCalendarModule: PluginModule = {
576
585
  description: "Upcoming economic events, releases, and indicators.",
577
586
  // "econ" stays a keyword so the old ECON prefix still finds this pane.
578
587
  keywords: ["econ", "economic", "calendar", "events", "macro", "releases", "fed", "cpi", "gdp", "rate decision", "ipca",
579
- "brazil inflation", "pmi", "ism", "purchasing managers", "flash pmi"],
588
+ "brazil inflation", "pmi", "ism", "purchasing managers", "flash pmi", "jobs report", "nfp", "payrolls", "nonfarm"],
580
589
  // ECO MX,BR opens the calendar on those countries.
581
590
  shortcut: {
582
591
  prefix: "ECO",
@@ -1,5 +1,6 @@
1
1
  import type { GloomPluginContext } from "../../../types/plugin";
2
2
  import {
3
+ econEventName,
3
4
  eventCountry,
4
5
  getCalendarCache,
5
6
  landedHighImpactPrints,
@@ -57,7 +58,8 @@ const DEFAULT_DEPS: PrintWatchDeps = {
57
58
  };
58
59
 
59
60
  function printBody(event: EconEvent): string {
60
- return event.forecast ? `${event.event} ${event.actual} vs ${event.forecast}` : `${event.event} ${event.actual}`;
61
+ const name = econEventName(event);
62
+ return event.forecast ? `${name} ${event.actual} vs ${event.forecast}` : `${name} ${event.actual}`;
61
63
  }
62
64
 
63
65
  type PrintWatchContext = Pick<GloomPluginContext, "notify" | "showPane">;
@@ -101,6 +101,17 @@ function bundleFrom(unaligned: StatBuild[], errors: string[]): StatsBundle {
101
101
  };
102
102
  }
103
103
 
104
+ /**
105
+ * The bundle's errors less the refresh failures of statistics the copy an
106
+ * earlier read saved answered for: those say when that copy is from instead.
107
+ */
108
+ export function unansweredErrors(bundle: StatsBundle): string[] {
109
+ const answered = new Set(bundle.builds.flatMap(({ stat, cache }) => (
110
+ cache?.source === "stale-fallback" && cache.refreshError ? [`${stat.seriesId}: ${cache.refreshError}`] : []
111
+ )));
112
+ return bundle.errors.filter((error) => !answered.has(error));
113
+ }
114
+
104
115
  /** Builds whatever the on-disk cache can already answer, for an instant first paint. */
105
116
  export function getCachedStatsBundle(stats: readonly StatDef[] = STATS): StatsBundle | null {
106
117
  const builds: StatBuild[] = [];
@@ -3,6 +3,7 @@ import type {
3
3
  HeadlessPaneDefinition,
4
4
  HeadlessPaneEntry,
5
5
  } from "../../../types/plugin";
6
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
6
7
  import { fredSourceLabel } from "../../../sources/gloomberb-cloud/fred-series";
7
8
  import { formatBasisPoints, toBasisPoints } from "../../../utils/basis-points";
8
9
  import { formatNumber } from "../../../utils/format";
@@ -10,6 +11,7 @@ import type { StatDef } from "./defs";
10
11
  import {
11
12
  createStatSeriesLoader,
12
13
  loadStatsBundle,
14
+ unansweredErrors,
13
15
  type StatsBundle,
14
16
  } from "./client";
15
17
  import { historyStartNotice } from "../shared/history-span";
@@ -84,6 +86,11 @@ function detailEntries(view: StatViewModel, cutoffShown: boolean, text: boolean)
84
86
  ];
85
87
  }
86
88
 
89
+ /** A statistic answered from the copy saved by an earlier read, its refresh having failed, says so in its row. */
90
+ function savedCopyRow<T extends object>(view: StatViewModel, row: T): T {
91
+ return view.cacheSource === "stale-fallback" && view.fetchedAt != null ? servedFromSavedCopy(row, view.fetchedAt) : row;
92
+ }
93
+
87
94
  /** The window the %ile column counts over, in words. */
88
95
  const PERCENTILE_WINDOW: Record<StatRangeId, string> = {
89
96
  "5Y": "the last 5 years",
@@ -163,7 +170,7 @@ export function projectStatsHeadlessBundle(
163
170
  textColumns: columns(true),
164
171
  // `as_of` is the release; a figure with none stated is as of its own day when it is observed daily or weekly, never its period's first day.
165
172
  rowAsOfKey: "exportAsOf",
166
- rows: categoryViews.map((view) => ({
173
+ rows: categoryViews.map((view) => savedCopyRow(view, {
167
174
  id: view.stat.id,
168
175
  indicator: view.stat.shortLabel,
169
176
  latest: view.latest.value,
@@ -208,7 +215,8 @@ export function projectStatsHeadlessBundle(
208
215
  ...exportDating(selected),
209
216
  }] : []),
210
217
  ],
211
- errors: bundle.errors,
218
+ // A statistic answered from its saved copy says so in the source line, not as an error.
219
+ errors: unansweredErrors(bundle),
212
220
  notes: [
213
221
  `%ile is the share of past readings at or below the latest one, over ${PERCENTILE_WINDOW[range]}.`
214
222
  + (range === "ALL" ? "" : " Full history: --range ALL."),
@@ -12,8 +12,9 @@ import type { PaneProps } from "../../../types/plugin";
12
12
  import { formatNumber } from "../../../utils/format";
13
13
  import { useAutoRefresh } from "../../../react/auto-refresh";
14
14
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
15
+ import { savedCopyFooterSegment } from "../../../components/layout/pane/footer";
15
16
  import { SeriesListDetail, useSeriesList } from "../shared/series-list-detail";
16
- import { getCachedStatsBundle, loadStatsBundle } from "./client";
17
+ import { getCachedStatsBundle, loadStatsBundle, unansweredErrors } from "./client";
17
18
  import { categoryLabel, changeColor, type StatCategoryId } from "./defs";
18
19
  import { StatDetail } from "./detail";
19
20
  import { DEFAULT_STAT_ID } from "./stats";
@@ -145,7 +146,7 @@ export function EconStatisticsPane({ focused, width, height }: PaneProps) {
145
146
  const { selected, searchFocused } = list;
146
147
  const rows = useMemo(() => withCategoryHeaders(list.visible), [list.visible]);
147
148
 
148
- const error = resource.error ?? bundle?.errors[0] ?? null;
149
+ const error = resource.error ?? (bundle ? unansweredErrors(bundle)[0] : null) ?? null;
149
150
  const footerInfo = useMemo<PaneFooterSegment[]>(() => {
150
151
  if (!selected) return [];
151
152
  // A Treasury yield names when in the day it was taken, as GC does, so it is not read against ^TNX's earlier close.
@@ -153,11 +154,14 @@ export function EconStatisticsPane({ focused, width, height }: PaneProps) {
153
154
  const info: PaneFooterSegment[] = [
154
155
  { id: "as-of", parts: [{ text: `reading for ${cutoff ? selected.reading : selected.readingReleased}${cutoff}`, tone: "muted" }] },
155
156
  ];
156
- // The cached first paint is being replaced; its age only counts once that load fails.
157
+ // The cached first paint is being replaced; its age only counts once that load fails, and then it is the
158
+ // copy an earlier read saved, which says when that was.
157
159
  const seeding = resource.loading && resource.updatedAt === null && !selected.refreshError;
158
- if (selected.observationStale || (selected.cacheStale && !seeding)) {
160
+ const savedAt = selected.cacheSource === "stale-fallback" ? selected.fetchedAt : null;
161
+ if (selected.observationStale || (selected.cacheStale && !seeding && savedAt == null)) {
159
162
  info.push({ id: "stale", parts: [{ text: "STALE", tone: "warning", bold: true }] });
160
163
  }
164
+ if (savedAt != null) info.push(savedCopyFooterSegment("saved-copy", savedAt));
161
165
  return info;
162
166
  }, [resource.loading, resource.updatedAt, selected]);
163
167
 
@@ -144,6 +144,9 @@ export const STATS: readonly StatDef[] = [
144
144
  reference: { value: 0, label: "flat" },
145
145
  staleAfterMs: MONTHLY_EARLY,
146
146
  note: "Jobs added or lost last month, the single most watched release.",
147
+ // The jobs report as it is called: `ECST nfp` opens it.
148
+ aliases: ["nfp", "nonfarm", "nonfarm payrolls", "jobs report"],
149
+ keywords: ["jobs report", "nfp", "nonfarm"],
147
150
  limit: 900,
148
151
  }),
149
152
  stat({