@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
|
@@ -11,6 +11,7 @@ const STUDY_FIELDS = [
|
|
|
11
11
|
"protocolSection.designModule.phases",
|
|
12
12
|
"protocolSection.designModule.enrollmentInfo",
|
|
13
13
|
"protocolSection.conditionsModule.conditions",
|
|
14
|
+
"protocolSection.armsInterventionsModule.interventions",
|
|
14
15
|
"protocolSection.descriptionModule.briefSummary",
|
|
15
16
|
].join(",");
|
|
16
17
|
const CLINICAL_TRIALS_API_BASE_URL = "https://clinicaltrials.gov/api/v2";
|
|
@@ -45,6 +46,8 @@ export interface ClinicalTrial {
|
|
|
45
46
|
/** Lead sponsor class, e.g. "INDUSTRY". */
|
|
46
47
|
sponsorClass: string;
|
|
47
48
|
conditions: string[];
|
|
49
|
+
/** What is tested, in the registry's order: a drug, a device, a procedure; a placebo arm is left out. */
|
|
50
|
+
interventions: TrialIntervention[];
|
|
48
51
|
summary: string;
|
|
49
52
|
studyType: string;
|
|
50
53
|
enrollment: number | null;
|
|
@@ -62,12 +65,18 @@ export interface ClinicalTrial {
|
|
|
62
65
|
firstSubmitDate: Date | null;
|
|
63
66
|
/** When the registry first posted the study. */
|
|
64
67
|
firstPostDate: Date | null;
|
|
65
|
-
/** When the registry last posted a change to the study; the
|
|
68
|
+
/** When the registry last posted a change to the study; the registry hands studies over newest first by it. */
|
|
66
69
|
lastUpdatePostDate: Date | null;
|
|
67
70
|
/** Deep link to the study page for [o]pen. */
|
|
68
71
|
url: string;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
interface TrialIntervention {
|
|
75
|
+
name: string;
|
|
76
|
+
/** Raw registry type, e.g. "DRUG" or "DEVICE". */
|
|
77
|
+
type: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
export type DatePrecision = "year" | "month" | "day";
|
|
72
81
|
export type TrialDateType = "ACTUAL" | "ESTIMATED";
|
|
73
82
|
|
|
@@ -107,6 +116,28 @@ function asDate(value: unknown): Date | null {
|
|
|
107
116
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/** Arms that test nothing: a placebo, a sham or the usual treatment. */
|
|
120
|
+
const CONTROL_INTERVENTION = /^(?:placebo|sham|vehicle|saline|standard (?:of )?care|usual care|no intervention|control)\b/i;
|
|
121
|
+
|
|
122
|
+
function parseInterventions(value: unknown): TrialIntervention[] {
|
|
123
|
+
if (!Array.isArray(value)) return [];
|
|
124
|
+
return value.flatMap((item) => {
|
|
125
|
+
if (!item || typeof item !== "object") return [];
|
|
126
|
+
const record = item as Record<string, unknown>;
|
|
127
|
+
const name = asString(record.name);
|
|
128
|
+
if (!name || CONTROL_INTERVENTION.test(name)) return [];
|
|
129
|
+
return [{ name, type: asString(record.type) ?? "" }];
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Kinds of intervention that are a product being tested, ahead of a device, a procedure or a behaviour. */
|
|
134
|
+
const PRODUCT_TYPES = new Set(["DRUG", "BIOLOGICAL", "COMBINATION_PRODUCT", "GENETIC", "DIETARY_SUPPLEMENT"]);
|
|
135
|
+
|
|
136
|
+
/** The one intervention the list names: the first drug or biologic, else the first thing tested; empty when none. */
|
|
137
|
+
export function trialDrug(trial: Pick<ClinicalTrial, "interventions">): string {
|
|
138
|
+
return (trial.interventions.find((item) => PRODUCT_TYPES.has(item.type)) ?? trial.interventions[0])?.name ?? "";
|
|
139
|
+
}
|
|
140
|
+
|
|
110
141
|
function moduleOf(record: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
111
142
|
const value = record[key];
|
|
112
143
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
@@ -144,6 +175,7 @@ export function parseClinicalTrial(raw: unknown): ClinicalTrial | null {
|
|
|
144
175
|
const conditionsModule = moduleOf(protocol, "conditionsModule");
|
|
145
176
|
const description = moduleOf(protocol, "descriptionModule");
|
|
146
177
|
const enrollmentInfo = moduleOf(design, "enrollmentInfo");
|
|
178
|
+
const armsInterventions = moduleOf(protocol, "armsInterventionsModule");
|
|
147
179
|
|
|
148
180
|
const start = trialDate(moduleOf(statusModule, "startDateStruct"));
|
|
149
181
|
const primaryCompletion = trialDate(moduleOf(statusModule, "primaryCompletionDateStruct"));
|
|
@@ -161,6 +193,7 @@ export function parseClinicalTrial(raw: unknown): ClinicalTrial | null {
|
|
|
161
193
|
sponsor: asString(leadSponsor.name) ?? "Unknown sponsor",
|
|
162
194
|
sponsorClass: asString(leadSponsor.class) ?? "",
|
|
163
195
|
conditions: asStringArray(conditionsModule.conditions),
|
|
196
|
+
interventions: parseInterventions(armsInterventions.interventions),
|
|
164
197
|
summary: asString(description.briefSummary) ?? "",
|
|
165
198
|
studyType: asString(design.studyType) ?? "",
|
|
166
199
|
enrollment: asCount(enrollmentInfo.count),
|
|
@@ -204,6 +237,8 @@ export interface StudiesQuery {
|
|
|
204
237
|
phases?: readonly string[];
|
|
205
238
|
/** Lead sponsor classes, any of, e.g. `INDUSTRY`. */
|
|
206
239
|
sponsorClasses?: readonly string[];
|
|
240
|
+
/** Lead sponsor classes to leave out, e.g. `INDUSTRY` for everything but companies. */
|
|
241
|
+
excludedSponsorClasses?: readonly string[];
|
|
207
242
|
pageSize?: number;
|
|
208
243
|
/** The previous page's `nextPageToken`; without it, the first page. */
|
|
209
244
|
pageToken?: string;
|
|
@@ -222,10 +257,16 @@ function areaClause(area: string, values: string[]): string | null {
|
|
|
222
257
|
return values.length === 1 ? `AREA[${area}]${values[0]}` : `AREA[${area}](${values.join(" OR ")})`;
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
/** `NOT AREA[LeadSponsorClass]INDUSTRY`, or `NOT AREA[LeadSponsorClass](NIH OR FED)` for several. */
|
|
261
|
+
function excludeClause(area: string, values: string[]): string | null {
|
|
262
|
+
const clause = areaClause(area, values);
|
|
263
|
+
return clause ? `NOT ${clause}` : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
225
266
|
/**
|
|
226
267
|
* Build the v2 studies URL. Pure: no network, no API key. The most recently
|
|
227
|
-
* updated studies come first,
|
|
228
|
-
*
|
|
268
|
+
* updated studies come first, so each page continues the one before it; the
|
|
269
|
+
* pane orders what has loaded. Filters go to the registry with the search, so
|
|
229
270
|
* every page it returns already matches them.
|
|
230
271
|
*/
|
|
231
272
|
export function buildStudiesUrl(query: StudiesQuery = {}): string {
|
|
@@ -240,7 +281,11 @@ export function buildStudiesUrl(query: StudiesQuery = {}): string {
|
|
|
240
281
|
if (sponsor) params.set("query.spons", sponsor);
|
|
241
282
|
const statuses = codes(query.statuses);
|
|
242
283
|
if (statuses.length > 0) params.set("filter.overallStatus", statuses.join(","));
|
|
243
|
-
const advanced = [
|
|
284
|
+
const advanced = [
|
|
285
|
+
areaClause("Phase", codes(query.phases)),
|
|
286
|
+
areaClause("LeadSponsorClass", codes(query.sponsorClasses)),
|
|
287
|
+
excludeClause("LeadSponsorClass", codes(query.excludedSponsorClasses)),
|
|
288
|
+
]
|
|
244
289
|
.filter((clause): clause is string => clause !== null);
|
|
245
290
|
if (advanced.length > 0) params.set("filter.advanced", advanced.join(" AND "));
|
|
246
291
|
if (query.pageToken) params.set("pageToken", query.pageToken);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { DataTableColumn } from "../../../components";
|
|
2
2
|
import { blendHex, type ThemeColors } from "../../../theme/colors";
|
|
3
3
|
import type { PaneReportOptionDef } from "../../../types/plugin";
|
|
4
|
-
import type { StudiesQuery } from "./client";
|
|
4
|
+
import type { ClinicalTrial, StudiesQuery } from "./client";
|
|
5
|
+
import type { SponsorTicker } from "./sponsor-tickers";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Where a study stands, as a reader of the list sorts it: open to patients,
|
|
@@ -109,6 +110,55 @@ export function trialPhaseLabel(phases: readonly string[], compact = false): str
|
|
|
109
110
|
return `${compact ? "Ph" : "Phase"} ${numbers.join("/")}`;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/** Whether the study has a phase to show: not an observational study with none, nor a device study the registry calls not applicable. */
|
|
114
|
+
export function hasTrialPhase(phases: readonly string[]): boolean {
|
|
115
|
+
const label = trialPhaseLabel(phases);
|
|
116
|
+
return label !== "--" && label !== "N/A";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How far along a study's phase is, for ordering: Phase 4 before 3 before 2
|
|
121
|
+
* before 1 before early phase 1, a study of two phases (2/3) by its later
|
|
122
|
+
* one, and a study with no phase to show after them all.
|
|
123
|
+
*/
|
|
124
|
+
export function trialPhaseRank(phases: readonly string[]): number {
|
|
125
|
+
let rank = 0;
|
|
126
|
+
for (const phase of phases) {
|
|
127
|
+
const code = phase.trim().toUpperCase();
|
|
128
|
+
const value = code === "EARLY_PHASE1" ? 0.5 : Number(code.replace("PHASE", ""));
|
|
129
|
+
if (Number.isFinite(value) && value > rank) rank = value;
|
|
130
|
+
}
|
|
131
|
+
return rank;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The three kinds of lead sponsor the list is ordered by: a company whose
|
|
136
|
+
* ticker Gloom Cloud found (0), any other company (1), and everything else:
|
|
137
|
+
* universities, hospitals, agencies, networks and individuals (2).
|
|
138
|
+
*/
|
|
139
|
+
type TrialSponsorGroup = 0 | 1 | 2;
|
|
140
|
+
|
|
141
|
+
function trialSponsorGroup(trial: Pick<ClinicalTrial, "sponsorClass">, ticker: SponsorTicker | undefined): TrialSponsorGroup {
|
|
142
|
+
if (ticker?.kind === "listed") return 0;
|
|
143
|
+
return trial.sponsorClass === "INDUSTRY" ? 1 : 2;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The studies as the pane lists them: listed sponsors first, then other
|
|
148
|
+
* companies, then everything else, the latest phase first within each; studies
|
|
149
|
+
* of one group and phase stay in the registry's order (most recently updated
|
|
150
|
+
* first). A new array, so the loaded rows keep their order.
|
|
151
|
+
*/
|
|
152
|
+
export function orderTrials<T extends Pick<ClinicalTrial, "sponsorClass" | "phases" | "sponsor">>(
|
|
153
|
+
trials: readonly T[],
|
|
154
|
+
tickerOf: (trial: T) => SponsorTicker | undefined,
|
|
155
|
+
): T[] {
|
|
156
|
+
return trials
|
|
157
|
+
.map((trial, index) => ({ trial, index, group: trialSponsorGroup(trial, tickerOf(trial)), phase: trialPhaseRank(trial.phases) }))
|
|
158
|
+
.sort((a, b) => a.group - b.group || b.phase - a.phase || a.index - b.index)
|
|
159
|
+
.map(({ trial }) => trial);
|
|
160
|
+
}
|
|
161
|
+
|
|
112
162
|
/** The phase as the detail view says it. */
|
|
113
163
|
export function trialPhaseDetail(phases: readonly string[]): string {
|
|
114
164
|
const label = trialPhaseLabel(phases);
|
|
@@ -228,7 +278,7 @@ export const TRIAL_REPORT_OPTIONS: readonly PaneReportOptionDef[] = [
|
|
|
228
278
|
},
|
|
229
279
|
];
|
|
230
280
|
|
|
231
|
-
type TrialColumnId = "updated" | "phase" | "status" | "title" | "sponsor" | "ticker";
|
|
281
|
+
type TrialColumnId = "updated" | "phase" | "status" | "title" | "drug" | "sponsor" | "ticker";
|
|
232
282
|
export type TrialColumn = DataTableColumn & { id: TrialColumnId };
|
|
233
283
|
|
|
234
284
|
export interface TrialLayout {
|
|
@@ -242,28 +292,42 @@ export interface TrialLayout {
|
|
|
242
292
|
/** The gap after each column plus the table's padding, as the terminal table draws them. */
|
|
243
293
|
const TABLE_CHROME = 1 + 2;
|
|
244
294
|
|
|
295
|
+
/** The title is what the list is read by: the drug and then the date give way before it has less than this. */
|
|
296
|
+
const TITLE_ROOM_WITH_DRUG = 24;
|
|
297
|
+
const TITLE_ROOM_WITH_DATE = 36;
|
|
298
|
+
/** An ISO day, as every date column reads. */
|
|
299
|
+
const DATE_WIDTH = 10;
|
|
300
|
+
|
|
245
301
|
/**
|
|
246
|
-
* The title takes what is left;
|
|
247
|
-
* sponsor keeps a readable width. Status reads in full, and the sponsor
|
|
248
|
-
* ticker widen, only once the title has room of its own. The columns add
|
|
249
|
-
* to the pane, so nothing scrolls sideways.
|
|
302
|
+
* The title takes what is left; phase, status and ticker stay narrow and the
|
|
303
|
+
* sponsor keeps a readable width. Status reads in full, and the sponsor, drug
|
|
304
|
+
* and ticker widen, only once the title has room of its own. The columns add
|
|
305
|
+
* up to the pane, so nothing scrolls sideways. Narrower than a terminal the
|
|
306
|
+
* drug, and before it the update date (the detail has it, and the list's
|
|
307
|
+
* dates run close together), give way to the title.
|
|
250
308
|
*/
|
|
251
309
|
export function trialLayout(width: number): TrialLayout {
|
|
252
|
-
const compact = width <
|
|
310
|
+
const compact = width < 100;
|
|
253
311
|
const wide = width >= 140;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
];
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
312
|
+
const phase: TrialColumn = { id: "phase", label: "Phase", width: compact ? 7 : 9, align: "left" };
|
|
313
|
+
const status: TrialColumn = { id: "status", label: "Status", width: wide ? 23 : 11, align: "left" };
|
|
314
|
+
const drug: TrialColumn = { id: "drug", label: "Drug", width: wide ? 18 : compact ? 12 : 14, align: "left" };
|
|
315
|
+
const sponsor: TrialColumn = { id: "sponsor", label: "Sponsor", width: wide ? 28 : compact ? 15 : 22, align: "left" };
|
|
316
|
+
const ticker: TrialColumn = { id: "ticker", label: "Ticker", width: wide ? 11 : 6, align: "left" };
|
|
317
|
+
const updated: TrialColumn = { id: "updated", label: "Updated", width: DATE_WIDTH, align: "left" };
|
|
318
|
+
const cells = (columns: TrialColumn[]) => columns.reduce((sum, column) => sum + column.width + 1, 0) + TABLE_CHROME;
|
|
319
|
+
// What the title keeps beside the other columns: its own gap, and the pane's last column's.
|
|
320
|
+
const titleWidth = (columns: TrialColumn[]) => width - cells(columns) - 1;
|
|
321
|
+
const base = [phase, status, sponsor, ticker];
|
|
322
|
+
const withDrug = titleWidth([...base, drug]) >= TITLE_ROOM_WITH_DRUG;
|
|
323
|
+
const lead = withDrug ? [...base, drug] : base;
|
|
324
|
+
const timed = titleWidth([...lead, updated]) >= TITLE_ROOM_WITH_DATE;
|
|
325
|
+
const rest = timed ? [...lead, updated] : lead;
|
|
326
|
+
const title: TrialColumn = { id: "title", label: "Title", width: Math.max(12, titleWidth(rest)), align: "left", flexGrow: 1 };
|
|
327
|
+
return {
|
|
328
|
+
compact,
|
|
329
|
+
showVenue: wide,
|
|
330
|
+
// Phase and status, the title, then what is about the study (drug, sponsor, ticker) and when it was updated.
|
|
331
|
+
columns: [phase, status, title, ...(withDrug ? [drug] : []), sponsor, ticker, ...(timed ? [updated] : [])],
|
|
332
|
+
};
|
|
269
333
|
}
|
|
@@ -12,11 +12,9 @@ import {
|
|
|
12
12
|
type DataTableCell,
|
|
13
13
|
type DataTableKeyEvent,
|
|
14
14
|
type DataTableRootKeyContext,
|
|
15
|
-
type PageRequest,
|
|
16
15
|
type PaneFooterSegment,
|
|
17
16
|
type PaneHint,
|
|
18
17
|
type QueryBarFilter,
|
|
19
|
-
type RowPage,
|
|
20
18
|
} from "../../../components";
|
|
21
19
|
import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
|
|
22
20
|
import { DetailReader, type DetailFact } from "../../../components/reader/detail-reader";
|
|
@@ -34,14 +32,17 @@ import { Box, Text, TextAttributes, type ScrollBoxRenderable } from "../../../ui
|
|
|
34
32
|
import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
|
|
35
33
|
import { useOpenDescription } from "../shared/open-description";
|
|
36
34
|
import { useResearchCloudSession } from "../shared/research-cloud-session";
|
|
37
|
-
import {
|
|
35
|
+
import { missingCell, NO_VALUE } from "../shared/research-cells";
|
|
36
|
+
import { ClinicalTrialsClient, trialDrug, type ClinicalTrial, type DatePrecision, type TrialDateType } from "./client";
|
|
38
37
|
import {
|
|
39
38
|
ALL,
|
|
40
39
|
PHASE_FILTER_OPTIONS,
|
|
41
40
|
SPONSOR_FILTER_OPTIONS,
|
|
42
41
|
STATUS_FILTER_OPTIONS,
|
|
42
|
+
hasTrialPhase,
|
|
43
43
|
keepsToneWhenSelected,
|
|
44
44
|
normalizeTrialFilters,
|
|
45
|
+
orderTrials,
|
|
45
46
|
sponsorClassLabel,
|
|
46
47
|
trialFiltersNarrow,
|
|
47
48
|
trialFiltersQuery,
|
|
@@ -55,16 +56,19 @@ import {
|
|
|
55
56
|
type TrialFilterOption,
|
|
56
57
|
type TrialFilters,
|
|
57
58
|
} from "./model";
|
|
58
|
-
import { useSponsorTickers, type SponsorTicker } from "./sponsor-tickers";
|
|
59
|
+
import { sponsorTickerStore, useSponsorTickers, type SponsorTicker } from "./sponsor-tickers";
|
|
60
|
+
import { COMPANIES_FIRST, createTrialPageLoader } from "./trial-pages";
|
|
59
61
|
import { looksLikeTicker, useTickerCompanySearch } from "../shared/ticker-company";
|
|
60
62
|
import { CLINICAL_TRIALS_PANE_ID } from "./types";
|
|
61
63
|
|
|
62
64
|
const SEARCH_DEBOUNCE_MS = 250;
|
|
63
65
|
const DEFAULT_PAGE_SIZE = 50;
|
|
66
|
+
/** How long a page waits for its sponsors' tickers, so the list draws already in order; a slower lookup fills in after. */
|
|
67
|
+
const TICKER_WAIT_MS = 2000;
|
|
64
68
|
|
|
65
69
|
/** As precise as the registry gave it, marked when it is the sponsor's estimate. */
|
|
66
70
|
function formatDate(date: Date | null, precision: DatePrecision = "day", type?: TrialDateType): string {
|
|
67
|
-
if (!date || Number.isNaN(date.getTime()) || date.getTime() === 0) return
|
|
71
|
+
if (!date || Number.isNaN(date.getTime()) || date.getTime() === 0) return NO_VALUE;
|
|
68
72
|
const text = date.toISOString().slice(0, precision === "year" ? 4 : precision === "month" ? 7 : 10);
|
|
69
73
|
return type === "ESTIMATED" ? `${text} (est.)` : text;
|
|
70
74
|
}
|
|
@@ -74,7 +78,7 @@ function trialPosted(trial: ClinicalTrial): Date | null {
|
|
|
74
78
|
return trial.firstPostDate ?? trial.firstSubmitDate;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
/** The ticker cell: the symbol, its venue when there is room; blank when there is none; `?` when it was not looked up. */
|
|
81
|
+
/** The ticker cell: the symbol, its venue when there is room; the blank mark when there is none; `?` when it was not looked up; nothing while it is being looked up. */
|
|
78
82
|
function tickerCell(ticker: SponsorTicker | undefined, showVenue: boolean, colors: ThemeColors, selected: boolean, open: () => void): DataTableCell {
|
|
79
83
|
if (ticker?.kind === "listed") {
|
|
80
84
|
const venue = showVenue && ticker.exchange && !/^(NYSE|NASDAQ|AMEX|ARCA|BATS|CBOE)$/.test(ticker.exchange) ? ticker.exchange : "";
|
|
@@ -90,6 +94,7 @@ function tickerCell(ticker: SponsorTicker | undefined, showVenue: boolean, color
|
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
if (ticker?.kind === "failed") return { text: "?", value: null, color: colors.textMuted };
|
|
97
|
+
if (ticker?.kind === "none") return missingCell(colors);
|
|
93
98
|
return { text: "", value: null };
|
|
94
99
|
}
|
|
95
100
|
|
|
@@ -121,7 +126,7 @@ function trialFacts(trial: ClinicalTrial, ticker: SponsorTicker | undefined, col
|
|
|
121
126
|
ticker?.kind === "listed"
|
|
122
127
|
? { label: "Ticker", value: tickerDetail(ticker), color: colors.textBright, link: { label: tf("Open {ticker}", { ticker: ticker.ticker }), onOpen: openTicker } }
|
|
123
128
|
: { label: "Ticker", value: tickerDetail(ticker), color: colors.textDim },
|
|
124
|
-
{ label: "Enrollment", value: trial.enrollment != null ? trial.enrollment.toLocaleString("en-US") :
|
|
129
|
+
{ label: "Enrollment", value: trial.enrollment != null ? trial.enrollment.toLocaleString("en-US") : NO_VALUE },
|
|
125
130
|
{ label: "Registry ID", value: trial.nctId },
|
|
126
131
|
{ label: "Start", value: formatDate(trial.startDate, trial.startDatePrecision, trial.startDateType) },
|
|
127
132
|
{ label: "Posted", value: formatDate(trialPosted(trial)) },
|
|
@@ -168,24 +173,27 @@ export function TrialsPane({ width, height, focused }: PaneProps) {
|
|
|
168
173
|
|
|
169
174
|
// The registry pages by an opaque token, which the next page's offset keys.
|
|
170
175
|
// A new search or filter makes a new loader and so starts over with no tokens.
|
|
176
|
+
// With no sponsor class chosen, the companies' studies page first and the rest
|
|
177
|
+
// after them, so the list opens on the companies instead of on whatever was
|
|
178
|
+
// updated last; the pane orders what has loaded (`orderTrials`).
|
|
171
179
|
const term = query.trim();
|
|
172
180
|
const filtersKey = `${filters.status}|${filters.phase}|${filters.sponsorClass}`;
|
|
173
|
-
const loadPage = useMemo(() => {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
const loadPage = useMemo(() => createTrialPageLoader({
|
|
182
|
+
list: (studiesQuery, signal) => client.listTrials(studiesQuery, signal),
|
|
183
|
+
query: { term: term || undefined, ...trialFiltersQuery(filters), pageSize: DEFAULT_PAGE_SIZE },
|
|
184
|
+
stages: filters.sponsorClass === ALL ? COMPANIES_FIRST : [{}],
|
|
185
|
+
// A page's sponsors are asked for before it shows, so it lands in order; a slow lookup fills in after.
|
|
186
|
+
onPage: (pageTrials, signal) => sponsorTickerStore.loadWithin(
|
|
187
|
+
pageTrials.map((trial) => trial.sponsor),
|
|
188
|
+
TICKER_WAIT_MS,
|
|
189
|
+
signal,
|
|
190
|
+
{ companies: pageTrials.filter((trial) => trial.sponsorClass === "INDUSTRY").map((trial) => trial.sponsor) },
|
|
191
|
+
),
|
|
184
192
|
// `filtersKey` stands for `filters`, which is rebuilt each render.
|
|
185
193
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
186
|
-
}, [client, filtersKey, term]);
|
|
194
|
+
}), [client, filtersKey, term]);
|
|
187
195
|
const studies = usePagedRows(loadPage, { getId: (trial) => trial.nctId, keepPreviousRows: true });
|
|
188
|
-
const
|
|
196
|
+
const loadedTrials = studies.rows;
|
|
189
197
|
const loadMore = useTableLoadMore(scrollRef, studies.hasMore && !openItemId, studies.loadMore);
|
|
190
198
|
// Scrolling asks for the next page, so a page that does not fill a tall
|
|
191
199
|
// pane asks for it once it is laid out.
|
|
@@ -197,12 +205,19 @@ export function TrialsPane({ width, height, focused }: PaneProps) {
|
|
|
197
205
|
if (box?.viewport && box.scrollHeight > 0 && box.scrollHeight <= box.viewport.height) loadNextPage();
|
|
198
206
|
}, 0);
|
|
199
207
|
return () => clearTimeout(timer);
|
|
200
|
-
}, [hasMore, height, loadNextPage, loadingMore, openItemId,
|
|
208
|
+
}, [hasMore, height, loadNextPage, loadingMore, openItemId, loadedTrials.length]);
|
|
201
209
|
|
|
202
|
-
// Tickers come from Gloom Cloud a page of sponsors at a time;
|
|
203
|
-
|
|
204
|
-
const
|
|
210
|
+
// Tickers come from Gloom Cloud a page of sponsors at a time; a page waits a moment for them (see the loader).
|
|
211
|
+
// A company its ticker lookup has no name for is looked up in its search (Bayer is BAYN on Xetra).
|
|
212
|
+
const sponsors = useMemo(() => [...new Set(loadedTrials.map((trial) => trial.sponsor))], [loadedTrials]);
|
|
213
|
+
const companies = useMemo(
|
|
214
|
+
() => [...new Set(loadedTrials.filter((trial) => trial.sponsorClass === "INDUSTRY").map((trial) => trial.sponsor))],
|
|
215
|
+
[loadedTrials],
|
|
216
|
+
);
|
|
217
|
+
const sponsorTickers = useSponsorTickers(sponsors, session.requestKey, { companies });
|
|
205
218
|
const tickerOf = useCallback((trial: ClinicalTrial) => sponsorTickers.tickers.get(trial.sponsor), [sponsorTickers.tickers]);
|
|
219
|
+
// Listed sponsors first, then other companies, then the rest, the latest phase first in each.
|
|
220
|
+
const trials = useMemo(() => orderTrials(loadedTrials, tickerOf), [loadedTrials, tickerOf]);
|
|
206
221
|
|
|
207
222
|
const selectedTrial = trials.find((item) => item.nctId === selectedId) ?? trials[0] ?? null;
|
|
208
223
|
const openTrial = openItemId
|
|
@@ -307,11 +322,11 @@ export function TrialsPane({ width, height, focused }: PaneProps) {
|
|
|
307
322
|
switch (column.id) {
|
|
308
323
|
case "updated": {
|
|
309
324
|
const updated = formatDate(trial.lastUpdatePostDate);
|
|
310
|
-
return trial.lastUpdatePostDate ? { text: updated, value: updated, color: colors.textDim } :
|
|
325
|
+
return trial.lastUpdatePostDate ? { text: updated, value: updated, color: colors.textDim } : missingCell(colors);
|
|
311
326
|
}
|
|
312
327
|
case "phase": {
|
|
313
|
-
|
|
314
|
-
return { text
|
|
328
|
+
if (!hasTrialPhase(trial.phases)) return missingCell(colors);
|
|
329
|
+
return { text: trialPhaseLabel(trial.phases, layout.compact), color: colors.text };
|
|
315
330
|
}
|
|
316
331
|
case "status": {
|
|
317
332
|
const status = trialStatus(trial.status);
|
|
@@ -324,6 +339,10 @@ export function TrialsPane({ width, height, focused }: PaneProps) {
|
|
|
324
339
|
}
|
|
325
340
|
case "title":
|
|
326
341
|
return { text: trial.title, color: colors.text, attributes: rowState.selected ? TextAttributes.BOLD : undefined };
|
|
342
|
+
case "drug": {
|
|
343
|
+
const drug = trialDrug(trial);
|
|
344
|
+
return drug ? { text: drug, color: colors.text } : missingCell(colors);
|
|
345
|
+
}
|
|
327
346
|
case "sponsor":
|
|
328
347
|
return { text: trial.sponsor, color: trial.sponsorClass === "INDUSTRY" ? colors.textBright : colors.textDim };
|
|
329
348
|
case "ticker": {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { apiClient } from "../../../api-client";
|
|
2
|
+
import { companyNameExtraWords, shortCompanyName } from "../../../tickers/company-name";
|
|
3
|
+
import { classifyInstrumentKind } from "../../../tickers/search/ranking";
|
|
4
|
+
import type { InstrumentSearchResult } from "../../../types/instrument";
|
|
5
|
+
import { canonicalExchange } from "../../../utils/exchanges";
|
|
6
|
+
|
|
7
|
+
/** A company's listing as Gloom Cloud's search marks its primary one. */
|
|
8
|
+
export interface FoundListing {
|
|
9
|
+
ticker: string;
|
|
10
|
+
exchange: string | null;
|
|
11
|
+
entityName: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The listing of each name asked, null for a name with none that qualifies; rejects when the search could not answer. */
|
|
15
|
+
export type ListingFinder = (names: readonly string[], signal?: AbortSignal) => Promise<Map<string, FoundListing | null>>;
|
|
16
|
+
|
|
17
|
+
/** Rows asked of the search for each name: enough to see every company's primary listing that carries it. */
|
|
18
|
+
const SEARCH_LIMIT = 30;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Legal forms that say which country's company a name is. The search drops
|
|
22
|
+
* them when it compares names, so "Merck KGaA" reads as "Merck & Co.": a
|
|
23
|
+
* sponsor filed with one of them is only matched to a listing that has it.
|
|
24
|
+
*/
|
|
25
|
+
const COUNTRY_FORMS = new Set([
|
|
26
|
+
"ag", "kgaa", "gmbh", "kg", "se", "sa", "sas", "sarl", "srl", "spa", "nv", "bv", "ab", "as", "asa", "aps", "oy", "oyj", "plc", "pcl", "bhd", "berhad",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Legal forms and titles written out in a listing's name ("Bayer Aktiengesellschaft", "Koninklijke Philips"), which the search reads as words of the name. */
|
|
30
|
+
const WRITTEN_FORMS = new Set([
|
|
31
|
+
"aktiengesellschaft", "aktieselskab", "aktiebolag", "naamloze", "vennootschap", "societe", "anonyme", "sociedad", "anonima",
|
|
32
|
+
"kabushiki", "kaisha", "spolka", "akcyjna", "osakeyhtio", "koninklijke",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function wordsOf(name: string): string[] {
|
|
36
|
+
return name.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listedExchange(result: InstrumentSearchResult): string {
|
|
40
|
+
return canonicalExchange(result.exchange === "SMART" ? result.primaryExchange : result.exchange || result.primaryExchange);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a sponsor's name is searched as: the name without its legal form, when that is one word; null for any longer name. */
|
|
44
|
+
export function sponsorSearchName(sponsor: string): string | null {
|
|
45
|
+
const short = shortCompanyName(sponsor.trim());
|
|
46
|
+
return short && !/\s/.test(short) ? short : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The listing of the company a sponsor's name stands for, from the rows the
|
|
51
|
+
* search gave for `sponsorSearchName`, for a sponsor Gloom Cloud has no
|
|
52
|
+
* reviewed name for (`Bayer` is Bayer AG, BAYN on Xetra). Careful by design,
|
|
53
|
+
* since a wrong ticker beside a sponsor is worse than none:
|
|
54
|
+
* - only a name that is one word once its legal form is dropped is asked;
|
|
55
|
+
* - the listing is a primary listing whose name is that word and nothing
|
|
56
|
+
* more, apart from legal forms (Bayer AG, but not Evolv Technologies
|
|
57
|
+
* Holdings for Evolv);
|
|
58
|
+
* - when more than one company's primary listing fits (Siemens), none does;
|
|
59
|
+
* - a sponsor filed under a legal form of its own country (Merck KGaA) never
|
|
60
|
+
* takes a listing without it.
|
|
61
|
+
* A longer name (a university, a hospital, a start-up with its subsidiary
|
|
62
|
+
* line) is never matched.
|
|
63
|
+
*/
|
|
64
|
+
export function pickSponsorListing(sponsor: string, rows: readonly InstrumentSearchResult[]): FoundListing | null {
|
|
65
|
+
const short = sponsorSearchName(sponsor);
|
|
66
|
+
if (!short) return null;
|
|
67
|
+
const sponsorForms = wordsOf(sponsor).filter((word) => COUNTRY_FORMS.has(word));
|
|
68
|
+
const fits = rows.filter((row) => {
|
|
69
|
+
if (row.homeListing !== true || classifyInstrumentKind(row.type) !== "equity" || !listedExchange(row)) return false;
|
|
70
|
+
const words = wordsOf(row.name);
|
|
71
|
+
if (sponsorForms.some((form) => !words.includes(form))) return false;
|
|
72
|
+
return companyNameExtraWords(short, row.name) === words.filter((word) => WRITTEN_FORMS.has(word)).length;
|
|
73
|
+
});
|
|
74
|
+
if (fits.length !== 1) return null;
|
|
75
|
+
const [row] = fits;
|
|
76
|
+
return { ticker: row!.symbol.toUpperCase(), exchange: listedExchange(row!), entityName: row!.name };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One batched search of Gloom Cloud for every sponsor name that can be matched; the rest answer null without a request. */
|
|
80
|
+
export const findSponsorListings: ListingFinder = async (sponsors, signal) => {
|
|
81
|
+
const found = new Map<string, FoundListing | null>(sponsors.map((sponsor) => [sponsor, null]));
|
|
82
|
+
const searched = new Map(sponsors.flatMap((sponsor) => {
|
|
83
|
+
const name = sponsorSearchName(sponsor);
|
|
84
|
+
return name ? [[sponsor, name] as const] : [];
|
|
85
|
+
}));
|
|
86
|
+
if (searched.size === 0) return found;
|
|
87
|
+
const answers = await apiClient.searchInstrumentsBatch([...new Set(searched.values())], SEARCH_LIMIT, signal ? { signal } : {});
|
|
88
|
+
for (const [sponsor, name] of searched) found.set(sponsor, pickSponsorListing(sponsor, answers.get(name) ?? []));
|
|
89
|
+
return found;
|
|
90
|
+
};
|