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.
- package/package.json +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- 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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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 {
|
|
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),
|
|
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),
|
|
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),
|
|
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 {
|
|
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
|
-
...(
|
|
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
|
-
|
|
69
|
-
throw error;
|
|
68
|
+
throw unavailableOnServer(error, DOE_NOT_AVAILABLE, [404, 503]);
|
|
70
69
|
}
|
|
71
70
|
const board = validateDoeBoard(payload);
|
|
72
|
-
|
|
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 {
|
|
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
|
-
...(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
//
|
|
257
|
-
//
|
|
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 &&
|
|
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 &&
|
|
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<
|
|
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 &&
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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); });
|