gloomberb 0.19.1 → 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 (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -66,20 +66,28 @@ async function loadSeries(
66
66
  loader: CreditSeriesLoader,
67
67
  ) {
68
68
  const request = requestFor(definition.seriesId);
69
- const result = await loadCachedFredSeries(
70
- request,
71
- async () => {
72
- const data = trimHistory(await loader(definition.seriesId, {
73
- limit: HISTORY_LIMIT,
74
- sortOrder: "desc",
75
- }));
76
- // Reject incompatible source responses before they replace usable cache.
77
- // The shared cache can then retain the dated prior value as stale fallback.
78
- normalizeCreditSeries(definition, data);
79
- return data;
80
- },
81
- { force },
82
- );
69
+ let result: Awaited<ReturnType<typeof loadCachedFredSeries>>;
70
+ try {
71
+ result = await loadCachedFredSeries(
72
+ request,
73
+ async () => {
74
+ const data = trimHistory(await loader(definition.seriesId, {
75
+ limit: HISTORY_LIMIT,
76
+ sortOrder: "desc",
77
+ }));
78
+ // Reject incompatible source responses before they replace usable cache.
79
+ normalizeCreditSeries(definition, data);
80
+ return data;
81
+ },
82
+ { force },
83
+ );
84
+ } catch (error) {
85
+ // A refusal or a reply that fails validation is no saved copy's to answer: it is this row's
86
+ // error, beside the dated prior value already read, which stays as stale rather than vanish.
87
+ const kept = getCachedFredSeries(request, { allowExpired: true });
88
+ if (!kept) throw error;
89
+ return { row: normalizeCreditSeries(definition, kept.data, true), refreshError: error instanceof Error ? error.message : String(error) };
90
+ }
83
91
  return {
84
92
  row: normalizeCreditSeries(definition, result.data, result.stale),
85
93
  refreshError: result.refreshError,
@@ -145,10 +145,10 @@ export async function fetchCryptoMarkets(
145
145
  try {
146
146
  return validateCryptoMarkets(await client.getCloudCryptoMarkets());
147
147
  } catch (error) {
148
- if (error instanceof ApiRequestError && error.status === 503) {
149
- throw new Error("Crypto prices are temporarily unavailable");
150
- }
151
- throw unavailableOnServer(error, "Crypto markets are not available yet.");
148
+ // Each keeps its status: a 503 passes with time and a saved copy answers it; a 404 does not.
149
+ throw error instanceof ApiRequestError && error.status === 503
150
+ ? unavailableOnServer(error, "Crypto prices are temporarily unavailable", [503])
151
+ : unavailableOnServer(error, "Crypto markets are not available yet.");
152
152
  }
153
153
  }
154
154
 
@@ -248,7 +248,7 @@ function validateStablecoinSupplyHistory(data: StablecoinSupplyHistoryPayload):
248
248
  }
249
249
 
250
250
  function supplyError(error: unknown, what: string): unknown {
251
- if (error instanceof ApiRequestError && error.status === 503) return new Error(`${what} is temporarily unavailable`);
251
+ if (error instanceof ApiRequestError && error.status === 503) return unavailableOnServer(error, `${what} is temporarily unavailable`, [503]);
252
252
  return unavailableOnServer(error, `${what} is not available yet.`);
253
253
  }
254
254
 
@@ -1,5 +1,5 @@
1
1
  import type { SeriesDefinition, SeriesPoint } from "../../../series/types";
2
- import { cachedSeriesWarning } from "../shared/series-warning";
2
+ import { cachedSeriesNote } from "../shared/series-warning";
3
3
  import { loadDefiTvl, loadStablecoinSupply, loadStablecoinSupplyHistory } from "./client";
4
4
 
5
5
  const DAY_MS = 86_400_000;
@@ -29,7 +29,7 @@ const STABLECOIN_SUPPLY_SERIES: readonly SeriesDefinition[] = [
29
29
  const resource = await loadStablecoinSupply();
30
30
  const history = resource.payload.history;
31
31
  if (!history) throw new Error("Stablecoin supply is not available yet.");
32
- return { points: dailyPoints(history.start, history.total), warning: cachedSeriesWarning(resource) };
32
+ return { points: dailyPoints(history.start, history.total), ...cachedSeriesNote(resource) };
33
33
  },
34
34
  },
35
35
  {
@@ -45,7 +45,7 @@ const STABLECOIN_SUPPLY_SERIES: readonly SeriesDefinition[] = [
45
45
  const resource = await loadStablecoinSupplyHistory(stablecoinSymbol(ticker!));
46
46
  const history = resource.payload.history;
47
47
  if (!history) throw new Error(`${resource.payload.symbol} supply history is not available yet.`);
48
- return { points: dailyPoints(history.start, history.values), warning: cachedSeriesWarning(resource) };
48
+ return { points: dailyPoints(history.start, history.values), ...cachedSeriesNote(resource) };
49
49
  },
50
50
  },
51
51
  ];
@@ -63,7 +63,7 @@ const DEFI_TVL_SERIES: SeriesDefinition = {
63
63
  const resource = await loadDefiTvl();
64
64
  const history = resource.payload.history;
65
65
  if (!history) throw new Error("DeFi TVL is not available yet.");
66
- return { points: dailyPoints(history.start, history.total), warning: cachedSeriesWarning(resource) };
66
+ return { points: dailyPoints(history.start, history.total), ...cachedSeriesNote(resource) };
67
67
  },
68
68
  };
69
69
 
@@ -1,6 +1,6 @@
1
1
  import type { DebtHistoryPoint } from "../../../api-client/debt-maturities";
2
2
  import type { SeriesDefinition } from "../../../series/types";
3
- import { cachedSeriesWarning } from "../shared/series-warning";
3
+ import { cachedSeriesNote } from "../shared/series-warning";
4
4
  import { loadDebtMaturities } from "./client";
5
5
 
6
6
  type HistoryField = "totalPrincipal" | "next12MonthsShare" | "next3YearsShare";
@@ -36,10 +36,10 @@ export const DEBT_MATURITY_SERIES: readonly SeriesDefinition[] = FIELDS.map(({ i
36
36
  // An amount stays in the unit it is listed in: a filing in another currency is a gap, not a converted figure.
37
37
  const listed = (point: DebtHistoryPoint) => unit !== "USD" || point.currency === "USD";
38
38
  const other = resource.payload.history.find((point) => !listed(point))?.currency;
39
- const warning = other ? `Filings reported in ${other} are left out.` : cachedSeriesWarning(resource);
40
39
  return {
41
40
  points: resource.payload.history.map((point) => ({ date: point.asOf, value: listed(point) ? point[field] : null })),
42
- ...(warning ? { warning } : {}),
41
+ ...cachedSeriesNote(resource),
42
+ ...(other ? { warning: `Filings reported in ${other} are left out.` } : {}),
43
43
  };
44
44
  },
45
45
  }));
@@ -1,8 +1,8 @@
1
1
  import { apiClient } from "../../../api-client";
2
+ import { sourceDidNotAnswer } from "../../../api-client/errors";
2
3
  import type { DoeBoardPayload, DoeSeriesRow } from "../../../api-client/doe";
3
- import { ApiRequestError } from "../../../api-client/errors";
4
4
  import { createPluginCache } from "../../../data/plugin-cache";
5
- import { cachedCloudResource, loadCloudResource } from "../shared/cloud-resource";
5
+ import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
6
6
 
7
7
  /** What the pane says while the server has no EIA weeks to serve: before the backend ships, or before its first read. */
8
8
  export const DOE_NOT_AVAILABLE = "Oil and gas inventories are not available yet.";
@@ -65,11 +65,12 @@ export async function fetchDoeBoard(client: Pick<typeof apiClient, "getCloudDoeB
65
65
  try {
66
66
  payload = await client.getCloudDoeBoard();
67
67
  } catch (error) {
68
- if (error instanceof ApiRequestError && (error.status === 404 || error.status === 503)) throw new Error(DOE_NOT_AVAILABLE);
69
- throw error;
68
+ throw unavailableOnServer(error, DOE_NOT_AVAILABLE, [404, 503]);
70
69
  }
71
70
  const board = validateDoeBoard(payload);
72
- if (board.status === "unavailable" || !board.series.some((row) => row.value != null)) throw new Error(DOE_NOT_AVAILABLE);
71
+ // The server's source did not answer this time: a saved copy answers, as it does a 5xx.
72
+ if (board.status === "unavailable") throw sourceDidNotAnswer(DOE_NOT_AVAILABLE);
73
+ if (!board.series.some((row) => row.value != null)) throw new Error(DOE_NOT_AVAILABLE);
73
74
  return board;
74
75
  }
75
76
 
@@ -2,7 +2,7 @@ import { apiClient } from "../../../api-client";
2
2
  import type { DoePricesPayload } from "../../../api-client/doe";
3
3
  import { createPluginCache } from "../../../data/plugin-cache";
4
4
  import type { SeriesDefinition } from "../../../series/types";
5
- import { cachedSeriesWarning } from "../shared/series-warning";
5
+ import { cachedSeriesNote } from "../shared/series-warning";
6
6
  import { loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
7
7
 
8
8
  /**
@@ -73,10 +73,9 @@ export const DOE_PRICE_SERIES: readonly SeriesDefinition[] = PRICE_SERIES.map((s
73
73
  const resource = await loadCloudResource(doePriceCache, "prices", () => fetchDoePrices(), { validate: validatePrices });
74
74
  const row = resource.payload.series.find((entry) => entry.id === series.id);
75
75
  if (!row?.points.length) throw new Error(`${series.name} is not available yet.`);
76
- const warning = cachedSeriesWarning(resource);
77
76
  return {
78
77
  points: row.points.map(([date, value]) => ({ date, value })),
79
- ...(warning ? { warning } : {}),
78
+ ...cachedSeriesNote(resource),
80
79
  // Cited as the EIA series it is, not as the route that served it.
81
80
  attribution: { source: `EIA series ${series.key}`, publisher: "EIA" },
82
81
  };
@@ -1,6 +1,6 @@
1
1
  import type { DoeSeriesRow, DoeUnit } from "../../../api-client/doe";
2
2
  import type { SeriesDefinition, SeriesPoint } from "../../../series/types";
3
- import { cachedSeriesWarning } from "../shared/series-warning";
3
+ import { cachedSeriesNote } from "../shared/series-warning";
4
4
  import { loadDoeBoard } from "./client";
5
5
  import { DOE_SERIES_OPTIONS } from "./model";
6
6
  import { DOE_PRICE_SERIES } from "./prices";
@@ -50,14 +50,14 @@ const DOE_SERIES: readonly SeriesDefinition[] = DOE_SERIES_OPTIONS.map((option):
50
50
  unit: UNITS[unit].unit,
51
51
  frequency: "weekly",
52
52
  group: "Energy",
53
- tags: gas ? ["natural gas", "storage", "inventories"] : ["oil", "petroleum", "inventories", "US"],
53
+ // Jet fuel is kerosene, the word a reader may search it by.
54
+ tags: gas ? ["natural gas", "storage", "inventories"] : ["oil", "petroleum", "inventories", "US", ...option.value === "jet" ? ["kerosene"] : []],
54
55
  publisher: "EIA",
55
56
  load: async () => {
56
57
  const resource = await loadDoeBoard();
57
58
  const row = resource.payload.series.find((entry) => entry.id === option.value);
58
59
  if (!row) throw new Error(`${option.label} is not in this week's report.`);
59
- const warning = cachedSeriesWarning(resource);
60
- return { points: doeRowPoints(row), ...(warning ? { warning } : {}) };
60
+ return { points: doeRowPoints(row), ...cachedSeriesNote(resource) };
61
61
  },
62
62
  };
63
63
  });
@@ -19,6 +19,8 @@ import {
19
19
  type SectionedRow,
20
20
  } from "../../../components";
21
21
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
22
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
23
+ import { isPermanentClientError } from "../../../api-client/errors";
22
24
  import { TABLE_COLUMN_GAP, tableColumnWidth } from "../../../components/ui/table-layout";
23
25
  import { useAsyncResource } from "../../../react/async-resource";
24
26
  import { useAgeClock, useAutoRefresh } from "../../../react/auto-refresh";
@@ -162,6 +164,8 @@ export function UpcomingCallsView({ focused, width, height, view, renderCompany
162
164
  }, [range]);
163
165
  const resource = useAsyncResource(entitled ? loader : null, {
164
166
  keepPreviousData: true,
167
+ // A refusal asking again will not change takes the calendar off screen, for its wall or its error.
168
+ clearOnError: isPermanentClientError,
165
169
  initialData: () => cachedUpcomingCalls(range),
166
170
  });
167
171
  const data = resource.data;
@@ -204,12 +208,16 @@ export function UpcomingCallsView({ focused, width, height, view, renderCompany
204
208
  const proRequired = !signInRequired && !verificationRequired && (!access.hasProAccess || (!data && refused === 402));
205
209
 
206
210
  const hints = useMemo<PaneHint[]>(() => [{ id: "search", key: "/", label: "search", onPress: focusSearch }], [focusSearch]);
211
+ // A refresh that failed shows the calendar an earlier read saved, and says when that copy is from.
212
+ const savedAt = data?.savedAt ?? null;
213
+ const savedInfo = useMemo(() => savedCopyFooterInfo([savedAt]), [savedAt]);
207
214
  usePaneStatusFooter({
208
215
  registrationId: "earnings-calls:calendar",
209
216
  enabled: !detailOpen && entitled,
210
217
  loading: resource.loading && !!data,
211
- error: data ? data.refreshError ?? resource.error : null,
212
- stale: !!data && (data.stale || !!resource.error),
218
+ error: data ? (savedAt == null ? data.refreshError : null) ?? resource.error : null,
219
+ stale: !!data && savedAt == null && (data.stale || !!resource.error),
220
+ info: savedInfo,
213
221
  hints,
214
222
  });
215
223
  // A calendar cut at the server's limit says so, rather than end the week early.
@@ -5,7 +5,7 @@ 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
10
  import { servedFromSavedCopy } from "../../../data/saved-copies";
11
11
  import { areDifferentCompanies } from "../../../sources/sec-registrant";
@@ -45,6 +45,8 @@ export interface EarningsCallsResult {
45
45
  calls: CloudEarningsCallPayload[];
46
46
  fetchedAt: number;
47
47
  stale: boolean;
48
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
49
+ savedAt?: number;
48
50
  refreshError?: string;
49
51
  /** HTTP status when the request failed, used to pick the right gate. */
50
52
  errorStatus?: number;
@@ -122,6 +124,8 @@ export interface UpcomingCallsResult {
122
124
  stale: boolean;
123
125
  /** The server answered its limit's worth: calls later on these days may be missing. */
124
126
  truncated?: boolean;
127
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
128
+ savedAt?: number;
125
129
  refreshError?: string;
126
130
  errorStatus?: number;
127
131
  }
@@ -134,7 +138,7 @@ export interface UpcomingDays {
134
138
  let persistence: PluginPersistence | null = null;
135
139
  const activeUpcomingFetches = new Map<string, Promise<UpcomingCallsResult>>();
136
140
  const activeListFetches = new Map<string, Promise<EarningsCallsResult>>();
137
- const activeTranscriptFetches = new Map<string, Promise<CloudEarningsTranscriptPayload>>();
141
+ const activeTranscriptFetches = new Map<string, Promise<EarningsTranscriptResult>>();
138
142
 
139
143
  export function attachEarningsCallsPersistence(value: PluginPersistence): void {
140
144
  persistence = value;
@@ -253,20 +257,21 @@ export async function loadEarningsCallsWithClient(
253
257
  .catch((error: unknown) => {
254
258
  const refreshError = error instanceof Error ? error.message : String(error);
255
259
  const errorStatus = statusOf(error);
256
- // An auth failure must surface its gate rather than silently showing
257
- // 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.
258
262
  const expired = store?.getResource<CloudEarningsCallListPayload>(LIST_KIND, key, {
259
263
  sourceKey: CACHE_SOURCE,
260
264
  schemaVersion: LIST_CACHE_SCHEMA_VERSION,
261
265
  allowExpired: true,
262
266
  });
263
- if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
267
+ if (expired && isTransientFailure(error)) {
264
268
  // A command that shows it ends with when the copy was saved, not with the failure.
265
269
  return servedFromSavedCopy({
266
270
  ...expired.value,
267
271
  ...sourceScope(expired.value.calls),
268
272
  fetchedAt: expired.fetchedAt,
269
273
  stale: true,
274
+ savedAt: expired.fetchedAt,
270
275
  refreshError,
271
276
  errorStatus,
272
277
  }, expired.fetchedAt);
@@ -367,9 +372,10 @@ export async function loadUpcomingCallsWithClient(
367
372
  const errorStatus = statusOf(error);
368
373
  // Losing Pro access re-gates the calendar, as it does the list.
369
374
  const expired = read(true);
370
- if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
375
+ if (expired && isTransientFailure(error)) {
371
376
  return servedFromSavedCopy({
372
377
  ...upcomingFromCache(expired.value, expired.fetchedAt, true),
378
+ savedAt: expired.fetchedAt,
373
379
  refreshError: error instanceof Error ? error.message : String(error),
374
380
  errorStatus,
375
381
  }, expired.fetchedAt);
@@ -409,11 +415,14 @@ export function isPendingTranscript(
409
415
  return record.status === "pending" || !Array.isArray(record.turns);
410
416
  }
411
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
+
412
421
  export async function loadTranscriptWithClient(
413
422
  client: EarningsCallsApiClient,
414
423
  callId: string,
415
424
  options?: { force?: boolean },
416
- ): Promise<CloudEarningsTranscriptPayload> {
425
+ ): Promise<EarningsTranscriptResult> {
417
426
  const force = options?.force ?? false;
418
427
  const store = persistence;
419
428
 
@@ -444,7 +453,6 @@ export async function loadTranscriptWithClient(
444
453
  return transcript;
445
454
  })
446
455
  .catch((error: unknown) => {
447
- const status = statusOf(error);
448
456
  const expired = store?.getResource<CloudEarningsTranscriptPayload>(
449
457
  TRANSCRIPT_KIND,
450
458
  callId,
@@ -455,8 +463,8 @@ export async function loadTranscriptWithClient(
455
463
  },
456
464
  );
457
465
  // Losing Pro access must re-gate the transcript, not serve it from cache.
458
- if (expired && status !== 401 && status !== 402 && status !== 403) {
459
- return expired.value;
466
+ if (expired && isTransientFailure(error)) {
467
+ return servedFromSavedCopy({ ...expired.value, savedAt: expired.fetchedAt }, expired.fetchedAt);
460
468
  }
461
469
  throw error;
462
470
  })
@@ -473,7 +481,7 @@ export async function loadTranscriptWithClient(
473
481
  export function loadTranscript(
474
482
  callId: string,
475
483
  options?: { force?: boolean },
476
- ): Promise<CloudEarningsTranscriptPayload> {
484
+ ): Promise<EarningsTranscriptResult> {
477
485
  return loadTranscriptWithClient(apiClient, callId, options);
478
486
  }
479
487
 
@@ -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,
@@ -49,7 +46,11 @@ import {
49
46
  loadEarningsCalls,
50
47
  loadTranscript,
51
48
  statusOf,
49
+ type EarningsTranscriptResult,
52
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";
53
54
  import { callPeriod, callTitle, eventLabelOf, formatCallDate, formatDuration, formatSentiment } from "./format";
54
55
  import { findCallForQuarter, findInLines, MIN_FIND_LENGTH, turnParagraphs } from "./model";
55
56
  import { READER_TABS, TranscriptView, type ReaderTab, type TranscriptPlayback } from "./transcript-view";
@@ -148,6 +149,8 @@ interface TickerLookup {
148
149
  calls: CloudEarningsCallPayload[];
149
150
  fetchedAt?: number;
150
151
  stale?: boolean;
152
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
153
+ savedAt?: number;
151
154
  refreshError?: string;
152
155
  refreshRequest?: number;
153
156
  }
@@ -280,6 +283,8 @@ function CallsListView({
280
283
  const [stale, setStale] = useState(initialShelf?.stale === true);
281
284
  // The company's home line the shelf is read from, for a receipt or a foreign line.
282
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);
283
288
  const [listFetchedAt, setListFetchedAt] = useState(initialShelf?.fetchedAt ?? 0);
284
289
  const listRequestVersion = useRef(0);
285
290
  const [listCompletedRequest, setListCompletedRequest] = useState(0);
@@ -292,7 +297,7 @@ function CallsListView({
292
297
 
293
298
  const [selectedId, setSelectedId] = usePluginPaneState<string | null>(`${stateKey}selectedId`, null);
294
299
  const [detailOpen, setDetailOpen] = usePluginPaneState<boolean>(`${stateKey}detailOpen`, false);
295
- const [transcript, setTranscript] = useState<CloudEarningsTranscriptPayload | null>(null);
300
+ const [transcript, setTranscript] = useState<EarningsTranscriptResult | null>(null);
296
301
  useEffect(() => {
297
302
  if (focused && transcript && detailOpen) recordResearchActivity("pro_feature_used", "transcripts");
298
303
  }, [focused, transcript, detailOpen]);
@@ -344,10 +349,12 @@ function CallsListView({
344
349
  setHasMore(result.sourceLimitReached === true);
345
350
  setStale(result.stale);
346
351
  setCompanyFrom(result.companyFrom ?? null);
352
+ setListSavedAt(result.savedAt ?? null);
347
353
  setListFetchedAt(result.fetchedAt);
348
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.
349
356
  setListError(
350
- result.refreshError ? { message: result.refreshError, status: result.errorStatus } : null,
357
+ result.refreshError && result.savedAt == null ? { message: result.refreshError, status: result.errorStatus } : null,
351
358
  );
352
359
  setListStatus("loaded");
353
360
  setListCompletedRequest(request);
@@ -357,6 +364,7 @@ function CallsListView({
357
364
  setListPending(false);
358
365
  setHasMore(false);
359
366
  setNextOffset(0);
367
+ setListSavedAt(null);
360
368
  setListError({
361
369
  message: error instanceof Error ? error.message : String(error),
362
370
  status: statusOf(error),
@@ -415,6 +423,7 @@ function CallsListView({
415
423
  setListPending(held?.pending === true && held.calls.length === 0);
416
424
  setStale(held?.stale === true);
417
425
  setCompanyFrom(held?.companyFrom ?? null);
426
+ setListSavedAt(null);
418
427
  setListFetchedAt(held?.fetchedAt ?? 0);
419
428
  setListError(null);
420
429
  setLookup(null);
@@ -482,19 +491,22 @@ function CallsListView({
482
491
  calls: result.calls,
483
492
  fetchedAt: result.fetchedAt,
484
493
  stale: result.stale,
485
- refreshError: result.refreshError,
494
+ savedAt: result.savedAt,
495
+ refreshError: result.savedAt == null ? result.refreshError : undefined,
486
496
  refreshRequest,
487
497
  });
488
498
  })
489
499
  .catch((error: unknown) => {
490
500
  if (cancelled) return;
491
- 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);
492
503
  setLookup((current) => ({
493
504
  ticker: lookupTicker,
494
505
  status: "error",
495
506
  calls: retainRows && current?.ticker === lookupTicker ? current.calls : [],
496
507
  fetchedAt: retainRows && current?.ticker === lookupTicker ? current.fetchedAt : undefined,
497
508
  stale: retainRows && current?.ticker === lookupTicker && current.calls.length > 0,
509
+ savedAt: retainRows && current?.ticker === lookupTicker ? current.savedAt : undefined,
498
510
  refreshError: error instanceof Error ? error.message : String(error),
499
511
  refreshRequest,
500
512
  }));
@@ -864,15 +876,14 @@ function CallsListView({
864
876
  }
865
877
  }
866
878
  const visibleLookup = lookup && (lookup.ticker === lookupTicker || (detailOpen && selected?.ticker === lookup.ticker)) ? lookup : null;
867
- if (visibleLookup?.stale) {
868
- info.push({ id: "lookup-stale", parts: [{ text: `${visibleLookup.ticker} stale cache`, tone: "warning" }] });
869
- }
870
879
  if (visibleLookup?.refreshError && lookupState !== "error") {
871
880
  info.push({ id: "lookup-error", parts: [{ text: `${visibleLookup.ticker}: ${visibleLookup.refreshError}`, tone: "warning" }] });
872
881
  }
873
- if (stale) {
874
- info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] });
875
- }
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);
876
887
  if (listError) {
877
888
  info.push({ id: "error", parts: [{ text: listError.message, tone: "warning" }] });
878
889
  }
@@ -911,6 +922,8 @@ function CallsListView({
911
922
  lookupTicker,
912
923
  lookup,
913
924
  stale,
925
+ listSavedAt,
926
+ selectedTranscript,
914
927
  listError,
915
928
  proRequired,
916
929
  transcriptProRequired,
@@ -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 {
@@ -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",
@@ -3,7 +3,7 @@ import {
3
3
  type CloudProxyStatementListPayload,
4
4
  type CloudProxyStatementPayload,
5
5
  } from "../../../api-client";
6
- import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
6
+ import { ApiRequestError, isPermanentClientError, isTransientFailure } from "../../../api-client/errors";
7
7
  import type { IssuerListingParams } from "../../../api-client/paths";
8
8
  import type { PluginPersistence } from "../../../types/plugin";
9
9
  import { secListingKey } from "../../../utils/sec";
@@ -46,6 +46,8 @@ interface ProxyResult<T> {
46
46
  /** Null is an explicit 404, not a transient failure or access denial. */
47
47
  data: T | null;
48
48
  fetchedAt: number | null;
49
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
50
+ savedAt?: number;
49
51
  refreshError?: string;
50
52
  }
51
53
  type ActiveRequest<T> = { store: PluginPersistence | null; promise: Promise<ProxyResult<T>> };
@@ -105,11 +107,14 @@ function loadCached<T>(
105
107
  }
106
108
  throw error;
107
109
  }
110
+ // A saved copy answers only for a failure asking again may get past, not for a reply that fails validation.
111
+ if (!isTransientFailure(error)) throw error;
108
112
  const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
109
113
  if (!fallback || tierOf(fallback.value) !== tier) throw error;
110
114
  return {
111
115
  data: fallback.value,
112
116
  fetchedAt: fallback.fetchedAt,
117
+ savedAt: fallback.fetchedAt,
113
118
  refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
114
119
  };
115
120
  }).finally(() => { if (active.get(activeKey) === request) active.delete(activeKey); });