@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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/cli/commands/market.ts +35 -12
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +86 -20
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +6 -0
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/listing-arg.ts +57 -10
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +1 -1
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +6 -7
- package/src/components/command-bar/selection-runtime.ts +52 -3
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +22 -6
- package/src/components/layout/pane/footer/model.ts +83 -15
- package/src/components/layout/pane/status-footer.ts +35 -2
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +18 -3
- package/src/components/ui/icon.tsx +4 -1
- package/src/data/plugin-cache.ts +5 -3
- package/src/data/saved-copies.ts +8 -0
- package/src/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- package/src/plugins/builtin/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -1
- package/src/plugins/builtin/credit-documents/headless.ts +3 -3
- package/src/plugins/builtin/doe/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- package/src/plugins/builtin/earnings-calls/data.ts +18 -6
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +2 -2
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- package/src/plugins/builtin/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +2 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/screener.ts +8 -4
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +4 -2
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- package/src/plugins/builtin/thirteenf/api.ts +12 -1
- package/src/plugins/builtin/thirteenf/data.ts +7 -7
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/pane.tsx +5 -2
- package/src/plugins/builtin/yield-curve/client.ts +61 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/create.ts +43 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +8 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- package/src/tickers/company-name-listing.ts +53 -13
- package/src/tickers/company-name.ts +6 -1
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/index.ts +6 -2
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- package/src/types/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +27 -0
- package/src/types/headless.ts +6 -0
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/fundamentals.ts +36 -5
- package/src/version.ts +2 -2
|
@@ -7,16 +7,19 @@ import {
|
|
|
7
7
|
type OrganizationTicker,
|
|
8
8
|
} from "../../../api-client/organization-tickers";
|
|
9
9
|
import { createTtlCache, type TtlCache } from "../../../data/ttl-cache";
|
|
10
|
+
import { findSponsorListings, type ListingFinder } from "./sponsor-listing";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* A lead sponsor's ticker,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* A lead sponsor's ticker, as Gloom Cloud resolved it, or, for a company it
|
|
14
|
+
* has no reviewed name for, as the listing search finds it (`listingSearch`).
|
|
15
|
+
* `none` is an answer (no listed company, or a name that fits several);
|
|
16
|
+
* `failed` means it was not looked up at all and says nothing about the
|
|
17
|
+
* sponsor.
|
|
15
18
|
*/
|
|
16
19
|
export type SponsorTicker =
|
|
17
20
|
| { kind: "pending" }
|
|
18
21
|
| { kind: "listed"; ticker: string; exchange: string | null; entityName: string | null }
|
|
19
|
-
| { kind: "none"; entityName: string | null; ambiguous: boolean }
|
|
22
|
+
| { kind: "none"; entityName: string | null; ambiguous: boolean; listingSearch?: "done" }
|
|
20
23
|
| { kind: "failed"; reason: string };
|
|
21
24
|
|
|
22
25
|
/** A ticker only when one company and its listing were found; an ambiguous name never gets one. */
|
|
@@ -50,6 +53,8 @@ export class SponsorTickerStore {
|
|
|
50
53
|
private readonly answers: TtlCache<SponsorTicker>;
|
|
51
54
|
private readonly failures = new Map<string, string>();
|
|
52
55
|
private readonly inFlight = new Set<string>();
|
|
56
|
+
private readonly searching = new Set<string>();
|
|
57
|
+
private readonly searchFailures = new Set<string>();
|
|
53
58
|
private readonly listeners = new Set<() => void>();
|
|
54
59
|
|
|
55
60
|
constructor(private readonly lookup: Lookup, now: () => number = Date.now) {
|
|
@@ -104,24 +109,94 @@ export class SponsorTickerStore {
|
|
|
104
109
|
}));
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
/**
|
|
113
|
+
* `load`, then `searchListings` for the companies among the names, but the
|
|
114
|
+
* caller waits no longer than `ms`: the lookups go on and tickers fill in
|
|
115
|
+
* when they answer.
|
|
116
|
+
*/
|
|
117
|
+
async loadWithin(
|
|
118
|
+
names: readonly string[],
|
|
119
|
+
ms: number,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
listings?: { companies: readonly string[]; find?: ListingFinder },
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
const patience = new Promise<void>((resolve) => { timer = setTimeout(resolve, ms); });
|
|
125
|
+
const lookups = this.load(names, signal).then(() => listings ? this.searchListings(listings.companies, listings.find, signal) : undefined);
|
|
126
|
+
try {
|
|
127
|
+
await Promise.race([lookups, patience]);
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Looks the companies Gloom Cloud had no name for up in Gloom Cloud's
|
|
135
|
+
* search, the one that finds `Bayer` when it is typed, and keeps a primary
|
|
136
|
+
* listing as the sponsor's ticker (`findSponsorListings` says which names
|
|
137
|
+
* qualify). Only names Cloud answered with no company, no listing and no
|
|
138
|
+
* ambiguity are asked: a company it knows, and a name that fits several,
|
|
139
|
+
* stay as it said. Each name is searched once, all in one request; a search
|
|
140
|
+
* that failed waits for `retryFailed`, and tells nothing about the sponsor.
|
|
141
|
+
*/
|
|
142
|
+
async searchListings(names: readonly string[], find: ListingFinder = findSponsorListings, signal?: AbortSignal): Promise<void> {
|
|
143
|
+
const asked = new Map<string, string>();
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
const trimmed = name.trim();
|
|
146
|
+
const key = sponsorKey(trimmed);
|
|
147
|
+
const answer = this.answers.peek(key);
|
|
148
|
+
if (!trimmed || asked.has(key) || this.searching.has(key) || this.searchFailures.has(key)) continue;
|
|
149
|
+
if (answer?.kind !== "none" || answer.ambiguous || answer.entityName || answer.listingSearch) continue;
|
|
150
|
+
asked.set(key, trimmed);
|
|
151
|
+
}
|
|
152
|
+
if (asked.size === 0) return;
|
|
153
|
+
const keys = [...asked.keys()];
|
|
154
|
+
keys.forEach((key) => this.searching.add(key));
|
|
155
|
+
try {
|
|
156
|
+
const found = await find([...asked.values()], signal);
|
|
157
|
+
if (signal?.aborted) return;
|
|
158
|
+
let changed = false;
|
|
159
|
+
for (const [key, name] of asked) {
|
|
160
|
+
const listing = found.get(name) ?? null;
|
|
161
|
+
this.answers.set(key, listing
|
|
162
|
+
? { kind: "listed", ticker: listing.ticker, exchange: listing.exchange, entityName: listing.entityName }
|
|
163
|
+
: { kind: "none", entityName: null, ambiguous: false, listingSearch: "done" });
|
|
164
|
+
changed ||= listing !== null;
|
|
165
|
+
}
|
|
166
|
+
if (changed) this.listeners.forEach((listener) => listener());
|
|
167
|
+
} catch {
|
|
168
|
+
// A failed search says nothing about the sponsor; it is asked again on retry.
|
|
169
|
+
if (!signal?.aborted) keys.forEach((key) => this.searchFailures.add(key));
|
|
170
|
+
} finally {
|
|
171
|
+
keys.forEach((key) => this.searching.delete(key));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
107
175
|
/** Forgets failed lookups, so the next `load` asks again. */
|
|
108
176
|
retryFailed(): void {
|
|
109
|
-
if (this.failures.size === 0) return;
|
|
177
|
+
if (this.failures.size === 0 && this.searchFailures.size === 0) return;
|
|
110
178
|
this.failures.clear();
|
|
179
|
+
this.searchFailures.clear();
|
|
111
180
|
this.listeners.forEach((listener) => listener());
|
|
112
181
|
}
|
|
113
182
|
}
|
|
114
183
|
|
|
115
|
-
const sponsorTickerStore = new SponsorTickerStore(async (names, signal) => (
|
|
184
|
+
export const sponsorTickerStore = new SponsorTickerStore(async (names, signal) => (
|
|
116
185
|
validateOrganizationTickers(await apiClient.getCloudOrganizationTickers(names, signal), names)
|
|
117
186
|
));
|
|
118
187
|
|
|
188
|
+
export interface SponsorTickersOptions {
|
|
189
|
+
/** The company sponsors among `names`: the ones Cloud's search may look up when its ticker lookup has no name for them. */
|
|
190
|
+
companies?: readonly string[];
|
|
191
|
+
store?: SponsorTickerStore;
|
|
192
|
+
}
|
|
193
|
+
|
|
119
194
|
/**
|
|
120
195
|
* The tickers for the sponsors on screen. Rows draw at once and tickers fill
|
|
121
196
|
* in when the lookup answers; `retry` asks again for the ones that failed.
|
|
122
197
|
* `sessionKey` changing (a sign-in) retries them too.
|
|
123
198
|
*/
|
|
124
|
-
export function useSponsorTickers(names: readonly string[], sessionKey: string, store:
|
|
199
|
+
export function useSponsorTickers(names: readonly string[], sessionKey: string, { companies, store = sponsorTickerStore }: SponsorTickersOptions = {}) {
|
|
125
200
|
const [version, setVersion] = useState(0);
|
|
126
201
|
const [epoch, setEpoch] = useState(0);
|
|
127
202
|
const [signal, setSignal] = useState<AbortSignal | null>(null);
|
|
@@ -136,10 +211,13 @@ export function useSponsorTickers(names: readonly string[], sessionKey: string,
|
|
|
136
211
|
store.retryFailed();
|
|
137
212
|
}, [sessionKey, store]);
|
|
138
213
|
const namesKey = names.join("\n");
|
|
214
|
+
const companiesKey = (companies ?? []).join("\n");
|
|
139
215
|
useEffect(() => {
|
|
140
216
|
if (!signal || signal.aborted || !namesKey) return;
|
|
141
|
-
void store.load(namesKey.split("\n"), signal)
|
|
142
|
-
|
|
217
|
+
void store.load(namesKey.split("\n"), signal).then(() => {
|
|
218
|
+
if (companiesKey) return store.searchListings(companiesKey.split("\n"), findSponsorListings, signal);
|
|
219
|
+
});
|
|
220
|
+
}, [companiesKey, epoch, namesKey, sessionKey, signal, store, version]);
|
|
143
221
|
const retry = useCallback(() => {
|
|
144
222
|
store.retryFailed();
|
|
145
223
|
setEpoch((current) => current + 1);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PageRequest, RowPage } from "../../../components";
|
|
2
|
+
import type { ClinicalTrial, ClinicalTrialsPage, StudiesQuery } from "./client";
|
|
3
|
+
|
|
4
|
+
/** What one stage of the list asks the registry for on top of the search: a sponsor class to keep or to leave out. */
|
|
5
|
+
export type TrialStage = Pick<StudiesQuery, "sponsorClasses" | "excludedSponsorClasses">;
|
|
6
|
+
|
|
7
|
+
/** The companies' studies first and everything else after them. */
|
|
8
|
+
export const COMPANIES_FIRST: readonly TrialStage[] = [{ sponsorClasses: ["INDUSTRY"] }, { excludedSponsorClasses: ["INDUSTRY"] }];
|
|
9
|
+
|
|
10
|
+
/** Where a page continues: which stage it is in, and the registry's token inside it. */
|
|
11
|
+
interface StageCursor {
|
|
12
|
+
stage: number;
|
|
13
|
+
token?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pages the registry through `stages` one after the other: a stage's studies
|
|
18
|
+
* come newest first, and when it has no more the next stage starts, so the
|
|
19
|
+
* list reaches a university's study only once the companies' are in. The
|
|
20
|
+
* registry pages by an opaque token, which the next page's offset keys; the
|
|
21
|
+
* first page always starts at the first stage, so a reload starts over. A
|
|
22
|
+
* stage with nothing in it goes straight on to the next, so an empty page is
|
|
23
|
+
* never the end of the list while a stage is left. `onPage` runs on a page's
|
|
24
|
+
* studies before it is handed over.
|
|
25
|
+
*/
|
|
26
|
+
export function createTrialPageLoader({ list, query, stages, onPage }: {
|
|
27
|
+
list: (query: StudiesQuery, signal: AbortSignal) => Promise<ClinicalTrialsPage>;
|
|
28
|
+
query: StudiesQuery;
|
|
29
|
+
stages: readonly TrialStage[];
|
|
30
|
+
onPage?: (trials: ClinicalTrial[], signal: AbortSignal) => Promise<void>;
|
|
31
|
+
}): (request: Pick<PageRequest, "offset" | "signal">) => Promise<RowPage<ClinicalTrial>> {
|
|
32
|
+
const cursors = new Map<number, StageCursor>();
|
|
33
|
+
return async ({ offset, signal }) => {
|
|
34
|
+
let cursor: StageCursor | undefined = offset === 0 ? { stage: 0 } : cursors.get(offset);
|
|
35
|
+
while (cursor) {
|
|
36
|
+
const page: ClinicalTrialsPage = await list({ ...query, ...stages[cursor.stage], pageToken: cursor.token }, signal);
|
|
37
|
+
const next: StageCursor | null = page.nextPageToken ? { stage: cursor.stage, token: page.nextPageToken }
|
|
38
|
+
: cursor.stage + 1 < stages.length ? { stage: cursor.stage + 1 } : null;
|
|
39
|
+
if (page.trials.length === 0 && next && !page.nextPageToken) {
|
|
40
|
+
cursor = next;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const nextOffset = offset + page.trials.length;
|
|
44
|
+
if (next) cursors.set(nextOffset, next);
|
|
45
|
+
await onPage?.(page.trials, signal);
|
|
46
|
+
return { rows: page.trials, hasMore: !!next, nextOffset };
|
|
47
|
+
}
|
|
48
|
+
return { rows: [], hasMore: false };
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { DataTableColumn } from "../../../components";
|
|
|
3
3
|
import type { AppAttentionPayload, AppChart } from "../../../api-client/app-attention";
|
|
4
4
|
import type { HiringBoard, HiringPayload } from "../../../api-client/hiring";
|
|
5
5
|
import { fetchAppRank, fetchAttention, fetchBuilding, fetchMoves, fetchSignals, type BuildingPayload, type SignalsPayload } from "./client";
|
|
6
|
+
import { companyDataNotes } from "../../../types/company-from";
|
|
6
7
|
import { BUILDING_FILTERS, BUILDING_VIEWS, buildingBoardModel, buildingNumber, buildingPreviewNote, companyBuildingModel, isBuildingFilter, type BuildingSection } from "./building-model";
|
|
7
8
|
import { appsModel, appsPreviewNote, attentionNumber, attentionSectionTitle, attentionTabs, hiringModel, hiringPreviewNote, remoteText, type AttentionKind } from "./model";
|
|
8
9
|
import { companyMovesModel, isMovesFilter, movesBoardModel, movesNumber, movesPreviewNote, MOVES_FILTERS, MOVES_VIEWS, type MovesSection } from "./moves-model";
|
|
@@ -146,7 +147,7 @@ export function attentionHeadless(kind: AttentionKind): HeadlessPaneDefinition<"
|
|
|
146
147
|
const why = movesModel?.latest?.why;
|
|
147
148
|
const company = args.symbols[0] ? signalsReads[0]?.data : undefined;
|
|
148
149
|
const held = company && !("group" in company) && company.held ? holdText(company.held) : null;
|
|
149
|
-
const notes = [...model.notices, ...(nextOffset !== null ? [`Additional observations available with --offset ${nextOffset}.`] : []),
|
|
150
|
+
const notes = [...model.notices, ...companyDataNotes("companyFrom" in data ? data.companyFrom : null), ...(nextOffset !== null ? [`Additional observations available with --offset ${nextOffset}.`] : []),
|
|
150
151
|
...(movesData instanceof Error ? [movesData.message] : []), ...(why ? [why] : []),
|
|
151
152
|
...(signalsData instanceof Error ? [signalsData.message] : []), ...(held ? [`No expansion or anomaly signal this week: ${held.toLowerCase()}.`] : []),
|
|
152
153
|
...(buildingData instanceof Error ? [buildingData.message] : []), ...(buildingHeld ? [`No mix to compare this week: ${buildingHeld.toLowerCase()}.`] : [])];
|
|
@@ -5,6 +5,7 @@ import { usePlanAccess } from "../../../api-client/plan-access";
|
|
|
5
5
|
import type { AppAttentionFilter, AppAttentionPayload, AppChart } from "../../../api-client/app-attention";
|
|
6
6
|
import type { HiringBoard, HiringPayload } from "../../../api-client/hiring";
|
|
7
7
|
import { ChartTableHeader, DataTableView, PageStackView, PaneFooterScope, PaneLinkMenu, QueryBar, useChartTableSelection, usePagedRows, useTableLoadMore, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type PaneHint } from "../../../components";
|
|
8
|
+
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
8
9
|
import { DetailReader, type DetailFact } from "../../../components/reader/detail-reader";
|
|
9
10
|
import type { CloudJobsPostingDetail } from "../../../api-client/types";
|
|
10
11
|
import { descriptionText, postingFacts, postingTitle } from "../shared/job-posting";
|
|
@@ -184,6 +185,8 @@ function AttentionView({ kind, symbol, width, height, focused, initialTab }: Siz
|
|
|
184
185
|
usePaneStatusFooter({ registrationId: `${kind}:status`, enabled: !openCompany && !app && !moves, loading: resource.loading || paged.loadingMore || (!!evidence && postingResource.loading), error: data ? resource.error ?? paged.moreError?.message ?? paged.error?.message : null, stale: resource.data?.stale,
|
|
185
186
|
info: [...(model?.asOf ? [{ id: "observed", asOf: true, parts: [{ text: `as of ${model.asOf.slice(0, 10)}`, tone: "muted" as const }] }] : []), ...(model?.preview ? [{ id: "preview", parts: [{ text: "Pro preview", tone: "warning" as const }] }] : [])], hints });
|
|
186
187
|
usePaneNoticeFooter({ registrationId: `${kind}:notices`, enabled: !openCompany && !app && !moves, focused, notices: [...(model?.notices ?? []), ...(resource.data?.refreshError ? [resource.data.refreshError] : [])] });
|
|
188
|
+
// A receipt's or foreign line's hiring or apps are its company's, read from the home line.
|
|
189
|
+
useCompanyDataFooter(data && "companyFrom" in data ? data.companyFrom : null, !openCompany && !app && !moves);
|
|
187
190
|
if (signInWall) return <SignInWall placement={kind === "hiring" ? "hiring-momentum-signin" : "app-attention-signin"} action={kind === "hiring" ? "view hiring momentum" : "view app attention"} needsVerification={session.needsVerification} />;
|
|
188
191
|
const countryOptions = data && kind === "apps" ? (data as AppAttentionPayload).coverage.configuredCountries : [];
|
|
189
192
|
const query = kind === "hiring" && tab !== "mix" ? undefined : <QueryBar width={width} filters={kind === "apps" ? [
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { DataTableColumn } from "../../../components";
|
|
2
2
|
import { DATE_OPTION_EXAMPLES, isoDateOption } from "../../../utils/calendar-date";
|
|
3
3
|
import { optionFlag } from "../../../cli/pane-functions/capabilities";
|
|
4
|
+
import { companyDataNotes } from "../../../types/company-from";
|
|
4
5
|
import type { HeadlessBundleSection, HeadlessPaneColumn, HeadlessPaneDefinition } from "../../../types/plugin";
|
|
5
6
|
import { withoutQuietColumns } from "../shared/research-cells";
|
|
6
7
|
import { resolveHeadlessIssuerListing } from "../shared/headless-market-data";
|
|
7
8
|
import { companyQuery, fetchCompanyData, type CompanyDataset, type CompanyMode } from "./client";
|
|
8
|
-
import { allGuidance, allObservations, companyCellText, companyColumns, companyPreviewNote, KPI_COVERAGE_NOTE, kpiFreeLimitNote, QUIET_COLUMNS,
|
|
9
|
+
import { allGuidance, allObservations, companyCellText, companyColumns, companyEmptyState, companyPreviewNote, KPI_COVERAGE_NOTE, kpiFreeLimitNote, QUIET_COLUMNS,
|
|
9
10
|
type CompanyRow } from "./model";
|
|
10
11
|
import type { KpiObservation } from "../../../api-client/company-kpis";
|
|
11
12
|
|
|
@@ -107,8 +108,12 @@ export function companyHeadless(mode: CompanyMode): HeadlessPaneDefinition<"bund
|
|
|
107
108
|
const latest = ("series" in data ? data.series.map((series) => series.latest) : data.guidance).filter(keep);
|
|
108
109
|
const ignored = data.access === "preview" ? PRO_FILTERS.filter((key) => typeof args.options[key] === "string" && args.options[key] !== "") : [];
|
|
109
110
|
const notes = [ageNote(data, latest, mode),
|
|
110
|
-
ignored.length ? `The free preview is fixed: ${ignored.map(optionFlag).join(", ")} ${ignored.length > 1 ? "need" : "needs"} Gloom Pro.` : null
|
|
111
|
-
|
|
111
|
+
ignored.length ? `The free preview is fixed: ${ignored.map(optionFlag).join(", ")} ${ignored.length > 1 ? "need" : "needs"} Gloom Pro.` : null,
|
|
112
|
+
...companyDataNotes(data.companyFrom)].filter((note): note is string => !!note);
|
|
113
|
+
// A company with nothing on file says so once, as the pane does, rather than "not covered".
|
|
114
|
+
const nothingOnFile = datasetRows(data).length === 0 && data.lockedRows === 0 && !requested;
|
|
115
|
+
const { title, hint } = companyEmptyState(mode, symbol, { tab: "table", hasRows: !nothingOnFile }, "cli");
|
|
116
|
+
return { complete: !data.truncated, ...(nothingOnFile ? { empty: `${title} ${hint}` } : {}), errors: data.truncated ? [data.access === "preview" ? companyPreviewNote(data, mode === "guidance") : "Response limit reached; narrow the metric or date range"] : [],
|
|
112
117
|
...(notes.length ? { notes } : {}),
|
|
113
118
|
sections: "series" in data ? [
|
|
114
119
|
section("Latest KPIs", data.series.map((series) => series.latest), { first: true }),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { GuidancePayload, KpiGuidance, KpiObservation, KpiSeries, KpiUnit, KpisPayload } from "../../../api-client/company-kpis";
|
|
2
2
|
import type { DataTableColumn } from "../../../components";
|
|
3
3
|
import { scalarPoint, staticSeries } from "../../../components/chart/static/series";
|
|
4
|
+
import { getTableWidth, tableColumnWidth } from "../../../components/ui/table-layout";
|
|
4
5
|
import type { CompositeChartXAxis } from "../../../components/chart/composite/types";
|
|
5
6
|
import { formatCompact, formatNumber } from "../../../utils/format";
|
|
6
7
|
import { freePreviewNote } from "../shared/free-preview";
|
|
8
|
+
import type { CompanyMode } from "./client";
|
|
7
9
|
|
|
8
10
|
export type CompanyRow = KpiObservation | KpiGuidance;
|
|
9
11
|
export const isGuidance = (row: CompanyRow): row is KpiGuidance => "issuedDate" in row;
|
|
@@ -89,13 +91,30 @@ function unitSuffix(row: Pick<CompanyRow, "unit" | "currency" | "dimensions">):
|
|
|
89
91
|
if (row.unit === "currency_per_unit" || row.unit === "volume") return ` ${unitLabel(row)}`;
|
|
90
92
|
return "";
|
|
91
93
|
}
|
|
92
|
-
/** Raised reads as good news, a cut as bad, a reiteration as no news. */
|
|
93
|
-
export const directionTone = (
|
|
94
|
-
: direction === "withdrawn" ? "warning" as const :
|
|
94
|
+
/** Raised reads as good news, a cut as bad, a reiteration as no news, and a New guide as itself whichever way the service reached it. */
|
|
95
|
+
export const directionTone = (guide: Pick<KpiGuidance, "direction" | "previousId">) => guide.direction === "raised" ? "positive" as const : guide.direction === "cut" ? "negative" as const
|
|
96
|
+
: guide.direction === "withdrawn" ? "warning" as const : directionLabel(guide) === "New" ? "text" as const : "muted" as const;
|
|
95
97
|
/** A change is good when it moves the way the metric's owner wants it to. */
|
|
96
98
|
export const changeTone = (row: KpiObservation, value: number | null) => value === null || value === 0 || row.metric.favorable === "neutral" ? "muted" as const
|
|
97
99
|
: (value > 0) === (row.metric.favorable === "higher") ? "positive" as const : "negative" as const;
|
|
98
|
-
|
|
100
|
+
/**
|
|
101
|
+
* What a guide did against the one before it for the same period, in plain
|
|
102
|
+
* words. A guide with no earlier one to set it against is New, whether it is
|
|
103
|
+
* the company's first or the first for its period. The service sets a guide it
|
|
104
|
+
* cannot rank (a different hedge or conditions) beside its predecessor as not
|
|
105
|
+
* comparable: that one Changed, with no word for up or down.
|
|
106
|
+
*/
|
|
107
|
+
export function directionLabel(guide: Pick<KpiGuidance, "direction" | "previousId">): string {
|
|
108
|
+
switch (guide.direction) {
|
|
109
|
+
case "raised": return "Raised";
|
|
110
|
+
case "cut": return "Lowered";
|
|
111
|
+
case "reiterated": return "Maintained";
|
|
112
|
+
case "mixed": return "Mixed";
|
|
113
|
+
case "withdrawn": return "Withdrawn";
|
|
114
|
+
case "initiated": return "New";
|
|
115
|
+
case "not_comparable": return guide.previousId ? "Changed" : "New";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
99
118
|
export const outcomeLabel = (row: KpiGuidance) => !row.actual ? "Pending" : row.actual.favorable === "in_line" ? "In range"
|
|
100
119
|
: row.actual.favorable === "neutral" ? row.actual.outcome === "above" ? "Above" : row.actual.outcome === "below" ? "Below" : "In range"
|
|
101
120
|
: row.actual.favorable === "beat" ? "Beat" : "Miss";
|
|
@@ -163,7 +182,7 @@ export const QUIET_COLUMNS = { basis: ["Reported"], scope: ["Consolidated"] };
|
|
|
163
182
|
/** The text of a table cell as the pane draws it; the report prints the same words. */
|
|
164
183
|
export function companyCellText(row: CompanyRow, column: string, observations: KpiObservation[]): string {
|
|
165
184
|
const guide = isGuidance(row) ? row : null;
|
|
166
|
-
if (column === "change") return guide ? directionLabel(guide
|
|
185
|
+
if (column === "change") return guide ? directionLabel(guide) : changeText(observationChange(row as KpiObservation, observations));
|
|
167
186
|
return column === "metric" ? row.metric.name : column === "period" ? fiscalLabel(row) : column === "value" ? `${guide ? rangeText(guide) : observationValue(row as KpiObservation)}${guide?.status === "withdrawn" || guide?.hedge === "qualitative" ? "" : unitSuffix(row)}`
|
|
168
187
|
: column === "unit" ? unitLabel(row)
|
|
169
188
|
: column === "basis" ? basisLabel(row.basis) : column === "scope" ? dimensionLabel(row.dimensions) || "Consolidated"
|
|
@@ -195,6 +214,55 @@ export function companyColumns(guidance: boolean, evidence: boolean, history: bo
|
|
|
195
214
|
return order ? shown.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : shown;
|
|
196
215
|
}
|
|
197
216
|
|
|
217
|
+
/** Cells a column gives up on a narrow pane, before any column is dropped: a date needs 10, a range about 19. */
|
|
218
|
+
const NARROW_WIDTHS: Record<string, number> = { value: 19, change: 11, issued: 10, actual: 12, outcome: 9, basis: 12, scope: 18 };
|
|
219
|
+
/** The metric keeps at least this much: a name such as "Revenue growth", and its series detail only when there is room. */
|
|
220
|
+
const METRIC_FLOOR = 14;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The table's columns at a pane `width` in cells, so none is cut at the right edge.
|
|
224
|
+
* A table that already fits is left as it is. Otherwise the columns take their
|
|
225
|
+
* narrow widths, and the metric, which flexes, keeps what is left down to its
|
|
226
|
+
* floor. Below that a column whose meaning is in the detail goes (Action in History,
|
|
227
|
+
* where the outcome says it, then Issued), never one that names the row. A table that
|
|
228
|
+
* cannot fit even so keeps its narrow widths and scrolls sideways, as it always has.
|
|
229
|
+
*/
|
|
230
|
+
export function fitCompanyColumns(columns: readonly DataTableColumn[], width: number, history = false): DataTableColumn[] {
|
|
231
|
+
const room = width - 1;
|
|
232
|
+
if (getTableWidth(columns) <= room) return [...columns];
|
|
233
|
+
const narrow = (list: readonly DataTableColumn[]) => list.map((column) => column.id in NARROW_WIDTHS ? { ...column, width: Math.min(column.width, NARROW_WIDTHS[column.id]!) } : column);
|
|
234
|
+
const attempt = (list: readonly DataTableColumn[]): DataTableColumn[] | null => {
|
|
235
|
+
const tight = narrow(list);
|
|
236
|
+
const metric = tight.find((column) => column.id === "metric");
|
|
237
|
+
if (!metric) return getTableWidth(tight) <= room ? tight : null;
|
|
238
|
+
const fitted = Math.min(metric.width, room - (getTableWidth(tight) - tableColumnWidth(metric)));
|
|
239
|
+
return fitted >= METRIC_FLOOR ? tight.map((column) => column === metric ? { ...column, width: fitted } : column) : null;
|
|
240
|
+
};
|
|
241
|
+
let shown = [...columns];
|
|
242
|
+
for (const drop of [null, ...(history ? ["change", "issued"] : ["issued"])]) {
|
|
243
|
+
if (drop) shown = shown.filter((column) => column.id !== drop);
|
|
244
|
+
const fitted = attempt(shown);
|
|
245
|
+
if (fitted) return fitted;
|
|
246
|
+
}
|
|
247
|
+
return narrow(columns);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Companies to point an empty pane at, one that has data and one for when the pane is for the first. */
|
|
251
|
+
const EXAMPLE_COMPANIES: Record<CompanyMode, readonly string[]> = { guidance: ["WMT", "DAL"], kpis: ["AAPL", "NVDA"] };
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What an empty table says. A company with nothing on file for the function says so
|
|
255
|
+
* plainly and names one that has some; a selection or tab that narrows what the company
|
|
256
|
+
* has says what is missing from it. `cli` types `fn GUIDE WMT`, the app's command bar `GUIDE WMT`.
|
|
257
|
+
*/
|
|
258
|
+
export function companyEmptyState(mode: CompanyMode, symbol: string, view: { tab: string; hasRows: boolean }, surface: "cli" | "pane" = "pane"): { title: string; hint?: string } {
|
|
259
|
+
if (view.hasRows) return { title: view.tab === "history" ? "Actuals have not been matched to these guidance periods yet." : "No disclosed observations for this selection yet." };
|
|
260
|
+
const examples = EXAMPLE_COMPANIES[mode];
|
|
261
|
+
const example = examples.find((candidate) => candidate !== symbol.trim().toUpperCase()) ?? examples[0]!;
|
|
262
|
+
const command = `${mode === "kpis" ? "KPIS" : "GUIDE"} ${example}`;
|
|
263
|
+
return { title: `No ${mode === "kpis" ? "KPIs" : "guidance"} on file for ${symbol}.`, hint: `Try ${surface === "cli" ? `\`fn ${command}\`` : command}.` };
|
|
264
|
+
}
|
|
265
|
+
|
|
198
266
|
/** What a missing KPI means, as KPIS states it among its limitations and EQS beside a KPI column with gaps. */
|
|
199
267
|
export const KPI_COVERAGE_NOTE = "Coverage follows available company disclosures; missing data is not zero.";
|
|
200
268
|
|
|
@@ -5,6 +5,7 @@ import type { KpiObservation } from "../../../api-client/company-kpis";
|
|
|
5
5
|
import { listingAbroad } from "../../../api-client/paths";
|
|
6
6
|
import { ChartTableHeader, DataTableStackView, DetailScrollBody, EmptyState, KeyValueRow, PaneStatusBody, QueryBar, SectionHeading,
|
|
7
7
|
useChartTableSelection, usePaneMenuItems, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type DataTableCell, type PaneHint } from "../../../components";
|
|
8
|
+
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
8
9
|
import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
|
|
9
10
|
import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginAppActions, usePluginPaneState } from "../../../public/react";
|
|
10
11
|
import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
|
|
@@ -20,7 +21,7 @@ import { Blurred, LockedOverlay, UpgradeLabel } from "../shared/locked-rows";
|
|
|
20
21
|
import { isCloudSessionRequired, useResearchCloudSession } from "../shared/research-cloud-session";
|
|
21
22
|
import { cachedCompanyData, loadCompanyData, KPI_UNAVAILABLE, validateCompanyData, type CompanyDataset, type CompanyMode } from "./client";
|
|
22
23
|
import { useCompanyEvidence } from "./screenshot-evidence";
|
|
23
|
-
import { allGuidance, allObservations, basisLabel, changeText, changeTone, companyCellText, companyColumns, dimensionLabel, directionLabel, directionTone, guideGroupKey, guidanceSeries, fiscalLabel,
|
|
24
|
+
import { allGuidance, allObservations, basisLabel, changeText, changeTone, companyCellText, companyColumns, companyEmptyState, dimensionLabel, directionLabel, directionTone, fitCompanyColumns, guideGroupKey, guidanceSeries, fiscalLabel,
|
|
24
25
|
isGuidance, exactObservation, observationValue, observationChange, observationChart, periodOrder, outcomeLabel, QUIET_COLUMNS, rangeText, scopeWords, seriesDetail, seriesLabel, seriesLabels, seriesName,
|
|
25
26
|
seriesOrder, unitLabel, valueText, companyPreviewNote, type CompanyRow } from "./model";
|
|
26
27
|
import { PriceSparkline } from "../../../components/price-sparkline/view";
|
|
@@ -47,7 +48,7 @@ function DisclosureEvidence({ row, sourceId, onSource, width }: { row: CompanyRo
|
|
|
47
48
|
<KeyValueRow label="Revision" value={`${row.revision}${row.current ? " · current" : " · superseded"}${!isGuidance(row) && (row.conflict || row.contested) ? " · conflicting disclosures" : ""}`} labelWidth={18} />
|
|
48
49
|
{row.revisionReason ? <KeyValueRow label="Correction" value={row.revisionReason} labelWidth={18} /> : null}
|
|
49
50
|
{isGuidance(row) ? <>
|
|
50
|
-
<KeyValueRow label="Issued" value={row.issuedDate} detail={directionLabel(row
|
|
51
|
+
<KeyValueRow label="Issued" value={row.issuedDate} detail={directionLabel(row)} labelWidth={18} />
|
|
51
52
|
<KeyValueRow label="Source range" value={row.rangeText} labelWidth={18} />
|
|
52
53
|
{row.conditions ? <KeyValueRow label="Conditions" value={row.conditions} labelWidth={18} /> : null}
|
|
53
54
|
<KeyValueRow label="Later actual" value={row.actual ? valueText(row, row.actual.value) : "Pending"} detail={row.actual ? outcomeLabel(row) : undefined} labelWidth={18} />
|
|
@@ -130,7 +131,7 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
|
|
|
130
131
|
: sort.column === "actual" ? guide?.actual?.value ?? null : sort.column === "outcome" ? guide ? outcomeLabel(guide) : null
|
|
131
132
|
: sort.column === "basis" ? row.basis : sort.column === "unit" ? unitLabel(row) : sort.column === "scope" ? dimensionLabel(row.dimensions)
|
|
132
133
|
: sort.column === "published" ? row.evidence[0]!.publishedAt : sort.column === "revision" ? row.revision
|
|
133
|
-
: sort.column === "quote" ? row.evidence[0]!.quote : sort.column === "change" ? guide ? guide
|
|
134
|
+
: sort.column === "quote" ? row.evidence[0]!.quote : sort.column === "change" ? guide ? directionLabel(guide) : observationChange(row as KpiObservation, observations).value
|
|
134
135
|
: periodOrder(row);
|
|
135
136
|
};
|
|
136
137
|
const direction = sort.direction === "asc" ? 1 : -1;
|
|
@@ -158,8 +159,13 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
|
|
|
158
159
|
}
|
|
159
160
|
return map;
|
|
160
161
|
}, [kpis]);
|
|
161
|
-
const columns = useMemo(() =>
|
|
162
|
-
|
|
162
|
+
const columns = useMemo(() => {
|
|
163
|
+
const shown = withoutQuietColumns(companyColumns(mode === "guidance", tab === "evidence", tab === "history", mode === "kpis" && tab === "table" && trends.size > 0 && width >= 100, desktop),
|
|
164
|
+
rows, (row, id) => id === "basis" ? basisLabel(row.basis) : id === "scope" ? dimensionLabel(row.dimensions) || "Consolidated" : "", QUIET_COLUMNS);
|
|
165
|
+
// Evidence is a wide ledger that scrolls; the other tabs fit the pane.
|
|
166
|
+
return tab === "evidence" ? shown : fitCompanyColumns(shown, width, tab === "history");
|
|
167
|
+
}, [mode, tab, trends, rows, width, desktop]);
|
|
168
|
+
const emptyState = companyEmptyState(mode, symbol ?? "", { tab, hasRows: allRows.length > 0 });
|
|
163
169
|
const metricOptions = useMemo(() => [{ value: "all", label: "All metrics" }, ...[...new Map(allRows.map((row) => [row.metricId, { value: row.metricId, label: row.metric.name }])).values()]], [allRows]);
|
|
164
170
|
const kpiChart = useMemo(() => observationChart(rows.filter((row): row is KpiObservation => !isGuidance(row)), colors.warning), [rows, colors.warning]);
|
|
165
171
|
const series = useMemo(() => tab !== "chart" ? [] : mode === "kpis" ? kpiChart.series
|
|
@@ -190,6 +196,8 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
|
|
|
190
196
|
usePaneNoticeFooter({ registrationId: "company-kpis:notices", focused, notices: [resource.data?.refreshError,
|
|
191
197
|
data?.truncated && data.access === "full" ? "The response reached its stored-record limit. Narrow the metric or date range in a CLI or REST query." : null,
|
|
192
198
|
data?.coverage.conflicts ? "Conflicting disclosures are retained in Evidence and excluded from comparable trends." : null].filter((value): value is string => !!value) });
|
|
199
|
+
// A receipt's or foreign line's disclosures are its company's, read from the home line.
|
|
200
|
+
useCompanyDataFooter(data?.companyFrom);
|
|
193
201
|
if (!symbol) return <EmptyState title="Select a ticker." />;
|
|
194
202
|
if (signInWall) return <SignInWall placement="company-kpis-signin" action={mode === "guidance" ? "view company guidance" : "view company KPIs"} needsVerification={session.needsVerification} />;
|
|
195
203
|
const bodyHeight = Math.max(3, height - tabRows);
|
|
@@ -212,7 +220,7 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
|
|
|
212
220
|
return { text: "", content: <PriceSparkline priceHistory={points} width={column.width} period="all" trend={tone === "muted" ? "neutral" : tone} /> };
|
|
213
221
|
}
|
|
214
222
|
if (column.id === "change") {
|
|
215
|
-
if (guide) return { text: directionLabel(guide
|
|
223
|
+
if (guide) return { text: directionLabel(guide), color: toneColor(directionTone(guide), colors), keepColorWhenSelected: guide.direction === "raised" || guide.direction === "cut" };
|
|
216
224
|
const change = observationChange(row as KpiObservation, observations);
|
|
217
225
|
return { text: changeText(change), value: change.value, color: toneColor(changeTone(row as KpiObservation, change.value), colors) };
|
|
218
226
|
}
|
|
@@ -244,7 +252,7 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
|
|
|
244
252
|
detailScrollRef={detailScrollRef} detailContent={openRow ? <DetailScrollBody ref={detailScrollRef} resetScrollKey={openRow.id}><DisclosureEvidence row={openRow} sourceId={sourceId} onSource={setSource} width={width} /></DetailScrollBody> : null}
|
|
245
253
|
sortColumnId={sort.column} sortDirection={sort.direction} onHeaderClick={(column) => setSort((previous) => ({ column, direction: previous.column === column && previous.direction === "desc" ? "asc" : "desc" }))}
|
|
246
254
|
resetScrollKey={`${mode}:${symbol}:${tab}:${metric}:${activePlot}`}
|
|
247
|
-
emptyStateTitle={
|
|
255
|
+
emptyStateTitle={emptyState.title} emptyStateHint={emptyState.hint}
|
|
248
256
|
rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={query}
|
|
249
257
|
chart={tab === "chart" ? { series, ...(mode === "kpis" ? { xAxis: kpiChart.xAxis, viewport: kpiChart.viewport } : {}), empty: preview ? "Full history is available with Pro." : "History is accumulating. Two comparable disclosures are needed for a trend.",
|
|
250
258
|
formatValue: (value) => selected ? `${valueText(selected, value)}${selected.currency ? ` ${unitLabel(selected)}` : ""}` : String(value),
|
|
@@ -14,6 +14,7 @@ import { portfolioAnalyticsModule } from "./analytics";
|
|
|
14
14
|
import { brokerTradingModule } from "./broker-trading";
|
|
15
15
|
import { brokerManagerModule } from "./broker-manager";
|
|
16
16
|
import { changelogModule } from "./changelog";
|
|
17
|
+
import { roadmapModule } from "./roadmap";
|
|
17
18
|
import { connectionsModule } from "./connections";
|
|
18
19
|
import { pluginMarketplaceModule } from "./plugin-marketplace";
|
|
19
20
|
import { relativeRotationModule } from "./relative-rotation";
|
|
@@ -116,7 +117,7 @@ import { FIXED_INCOME_HOSTS } from "./fixed-income/model";
|
|
|
116
117
|
|
|
117
118
|
export const applicationPlugin = composeBuiltinPlugin({
|
|
118
119
|
...applicationPluginMeta,
|
|
119
|
-
modules: [layoutManagerModule, pluginMarketplaceModule, helpModule, changelogModule, connectionsModule, docsModule],
|
|
120
|
+
modules: [layoutManagerModule, pluginMarketplaceModule, helpModule, changelogModule, connectionsModule, docsModule, roadmapModule],
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
export const portfolioPlugin = composeBuiltinPlugin({
|
|
@@ -31,10 +31,10 @@ export const creditHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
31
31
|
const data = await fetchCreditDocuments(args.symbols[0]!, ctx.apiClient);
|
|
32
32
|
const instrument = args.options.instrument ? await fetchCreditInstrument(data.symbol, String(args.options.instrument), ctx.apiClient) : null;
|
|
33
33
|
const reason = instrument ? null : creditEmptyReason(data, args.symbols[0]!);
|
|
34
|
-
// Nothing on file says why once, instead of four empty sections.
|
|
34
|
+
// Nothing on file says why once, as the report's one reason, instead of four empty sections and a generic "not covered".
|
|
35
35
|
if (reason) {
|
|
36
|
-
return { complete: false, errors: data.warnings, sections: [],
|
|
37
|
-
metadata: { symbol: data.symbol, issuer: data.issuer, asOf: data.asOf, access: data.access, lockedRows: data.lockedRows, status: data.status, reason
|
|
36
|
+
return { complete: false, errors: data.warnings, sections: [], empty: reason,
|
|
37
|
+
metadata: { symbol: data.symbol, issuer: data.issuer, asOf: data.asOf, access: data.access, lockedRows: data.lockedRows, status: data.status, reason } };
|
|
38
38
|
}
|
|
39
39
|
return { complete: data.status === "available" && data.lockedRows === 0, errors: [...data.warnings, ...(data.lockedRows ? ["Full credit documents and history require Gloom Pro"] : [])],
|
|
40
40
|
sections: instrument ? instrumentSections(data, instrument) : [capitalSection(data), covenantSection(data), maturitySection(data), controlSection(data)],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DoeSeriesRow, DoeTab } from "../../../api-client/doe";
|
|
2
2
|
import type { HeadlessPaneDefinition } from "../../../types/plugin";
|
|
3
|
-
import {
|
|
3
|
+
import { loadDoeBoard } from "./client";
|
|
4
4
|
import {
|
|
5
5
|
DOE_TABS,
|
|
6
6
|
DOE_UNIT_LABEL,
|
|
@@ -61,7 +61,8 @@ export function doeHeadless(defaultTab: DoeTab | "all"): HeadlessPaneDefinition<
|
|
|
61
61
|
"Five-year ranges read earlier years on the same calendar day.", "A range holds fewer than five years where a series is younger."] },
|
|
62
62
|
describe: "EIA weekly petroleum and natural gas storage",
|
|
63
63
|
async load(args) {
|
|
64
|
-
|
|
64
|
+
// Read afresh; a failed read answers from the board an earlier one saved, which the report says it is.
|
|
65
|
+
const data = (await loadDoeBoard(true)).payload;
|
|
65
66
|
// Each tab is dated by its own EIA report (petroleum Wednesday, gas Thursday);
|
|
66
67
|
// once the next one is a day overdue the tables have missed it. The basis
|
|
67
68
|
// names what Vs 1Y and Vs 5Y avg are measured against.
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
usePaneTabs,
|
|
14
14
|
type ChartTableChart,
|
|
15
15
|
type DataTableColumn,
|
|
16
|
+
type PaneFooterSegment,
|
|
16
17
|
} from "../../../components";
|
|
17
18
|
import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
|
|
19
|
+
import { savedCopyFooterSegment } from "../../../components/layout/pane/footer";
|
|
18
20
|
import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginPaneState } from "../../../public/react";
|
|
19
21
|
import { usePaneInstance } from "../../../state/app/context";
|
|
20
22
|
import { useThemeColors } from "../../../theme/theme-context";
|
|
@@ -136,13 +138,21 @@ export function DoePane({ width, height, focused }: PaneProps) {
|
|
|
136
138
|
const { strip, rows: tabRows } = usePaneTabs({ tabs: [...DOE_TABS], activeValue: tab, onSelect: setTab, focused, dense: true });
|
|
137
139
|
useAutoRefresh(resource.updatedAt, resource.load);
|
|
138
140
|
usePaneRefreshKey(() => void resource.reload(), { focused });
|
|
141
|
+
// A refresh that failed shows the board an earlier read saved, and says when that copy is from.
|
|
142
|
+
const savedAt = resource.data?.savedAt ?? null;
|
|
139
143
|
usePaneNoticeFooter({ registrationId: "doe:notices", focused,
|
|
140
|
-
notices: [...(data?.gaps ?? []), ...(resource.data?.refreshError ? [resource.data.refreshError] : [])] });
|
|
144
|
+
notices: [...(data?.gaps ?? []), ...(resource.data?.refreshError && savedAt == null ? [resource.data.refreshError] : [])] });
|
|
141
145
|
// What Vs 1Y and Vs 5Y are measured against on this tab: the EIA's published figures for gas storage.
|
|
142
146
|
const basis = useMemo(() => data ? doeComparisonBasis(doeRows(data, tab)) : null, [data, tab]);
|
|
143
|
-
const info = useMemo(() =>
|
|
147
|
+
const info = useMemo(() => {
|
|
148
|
+
const segments: PaneFooterSegment[] = [
|
|
149
|
+
...(basis ? [{ id: "basis", parts: [{ text: basis, tone: "muted" as const }] }] : []),
|
|
150
|
+
...(savedAt != null ? [savedCopyFooterSegment("saved-copy", savedAt)] : []),
|
|
151
|
+
];
|
|
152
|
+
return segments.length ? segments : undefined;
|
|
153
|
+
}, [basis, savedAt]);
|
|
144
154
|
usePaneStatusFooter({ registrationId: "doe", loading: resource.loading, error: notAvailable ? null : resource.error,
|
|
145
|
-
stale: !!data && resource.data?.stale, info });
|
|
155
|
+
stale: !!data && !!resource.data?.stale && savedAt == null, info });
|
|
146
156
|
return (
|
|
147
157
|
<Box width={width} height={height} flexDirection="column">
|
|
148
158
|
{strip}
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
} from "../../../api-client";
|
|
8
8
|
import { ApiRequestError } from "../../../api-client/errors";
|
|
9
9
|
import { listingAbroad, type IssuerListingParams, type ListingAbroad } from "../../../api-client/paths";
|
|
10
|
+
import { servedFromSavedCopy } from "../../../data/saved-copies";
|
|
10
11
|
import { areDifferentCompanies } from "../../../sources/sec-registrant";
|
|
11
12
|
import type { HeadlessPaneApiClient, PluginPersistence } from "../../../types/plugin";
|
|
12
13
|
import { shortDate } from "../earnings/format";
|
|
13
14
|
import { callCountdown, callDay, callTimeKnown, isUpcomingCall } from "./upcoming";
|
|
15
|
+
import { type CompanyFrom, readCompanyFrom } from "../../../types/company-from";
|
|
14
16
|
|
|
15
17
|
type EarningsCallsApiClient = Pick<
|
|
16
18
|
HeadlessPaneApiClient,
|
|
@@ -50,6 +52,8 @@ export interface EarningsCallsResult {
|
|
|
50
52
|
pending?: boolean;
|
|
51
53
|
/** The symbol is not one the SEC knows, so there is nothing to look for. */
|
|
52
54
|
unknownTicker?: boolean;
|
|
55
|
+
/** The company's home line the calls are read from, when not the listing asked. */
|
|
56
|
+
companyFrom?: CompanyFrom | null;
|
|
53
57
|
/** Maximum rows requested from the server, which supplies no total/hasMore. */
|
|
54
58
|
sourceLimit?: number;
|
|
55
59
|
/** The response filled its request; additional calls may exist. */
|
|
@@ -216,8 +220,15 @@ export async function loadEarningsCallsWithClient(
|
|
|
216
220
|
...(offset > 0 ? { offset } : {}),
|
|
217
221
|
})
|
|
218
222
|
.then((payload) => {
|
|
219
|
-
|
|
220
|
-
const
|
|
223
|
+
// Calls the service read from the company's home line are the company's, whatever the line's own name.
|
|
224
|
+
const companyFrom = readCompanyFrom(payload.companyFrom);
|
|
225
|
+
const calls = companyFrom ? payload.calls ?? [] : listingCalls(payload.calls ?? [], abroad);
|
|
226
|
+
const value = {
|
|
227
|
+
calls,
|
|
228
|
+
pending: payload.pending === true,
|
|
229
|
+
unknownTicker: payload.unknownTicker === true,
|
|
230
|
+
...(companyFrom ? { companyFrom } : {}),
|
|
231
|
+
};
|
|
221
232
|
// A list with calls still being produced changes by the minute, so it
|
|
222
233
|
// is not worth keeping; a list of finished transcripts is.
|
|
223
234
|
const settled = !value.pending && calls.every((call) => call.hasTranscript);
|
|
@@ -250,14 +261,15 @@ export async function loadEarningsCallsWithClient(
|
|
|
250
261
|
allowExpired: true,
|
|
251
262
|
});
|
|
252
263
|
if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
|
|
253
|
-
|
|
264
|
+
// A command that shows it ends with when the copy was saved, not with the failure.
|
|
265
|
+
return servedFromSavedCopy({
|
|
254
266
|
...expired.value,
|
|
255
267
|
...sourceScope(expired.value.calls),
|
|
256
268
|
fetchedAt: expired.fetchedAt,
|
|
257
269
|
stale: true,
|
|
258
270
|
refreshError,
|
|
259
271
|
errorStatus,
|
|
260
|
-
};
|
|
272
|
+
}, expired.fetchedAt);
|
|
261
273
|
}
|
|
262
274
|
throw error;
|
|
263
275
|
})
|
|
@@ -356,11 +368,11 @@ export async function loadUpcomingCallsWithClient(
|
|
|
356
368
|
// Losing Pro access re-gates the calendar, as it does the list.
|
|
357
369
|
const expired = read(true);
|
|
358
370
|
if (expired && errorStatus !== 401 && errorStatus !== 402 && errorStatus !== 403) {
|
|
359
|
-
return {
|
|
371
|
+
return servedFromSavedCopy({
|
|
360
372
|
...upcomingFromCache(expired.value, expired.fetchedAt, true),
|
|
361
373
|
refreshError: error instanceof Error ? error.message : String(error),
|
|
362
374
|
errorStatus,
|
|
363
|
-
};
|
|
375
|
+
}, expired.fetchedAt);
|
|
364
376
|
}
|
|
365
377
|
throw error;
|
|
366
378
|
})
|