@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HeadlessBundleSection, HeadlessPaneDefinition } from "../../../types/plugin";
|
|
2
|
+
import { savedCopyMark, unansweredRefreshError } from "../../../data/saved-copies";
|
|
2
3
|
import { loadRiskReportWithClient, loadRiskReportsWithClient, type RiskReportsResult } from "./data";
|
|
3
4
|
import { SEC_FILINGS } from "../shared/report-freshness";
|
|
4
5
|
import { resolveHeadlessIssuerListing } from "../shared/headless-market-data";
|
|
@@ -39,7 +40,8 @@ export const riskFactorsHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
39
40
|
}
|
|
40
41
|
const report = await loadRiskReportWithClient(context.apiClient, ticker, year, { force, listing });
|
|
41
42
|
const sections: HeadlessBundleSection[] = [
|
|
42
|
-
|
|
43
|
+
// A report served from a saved copy dates the report by when it was saved (the list does in the metadata).
|
|
44
|
+
{ title: "Filing", ...savedCopyMark(report), entries: [
|
|
43
45
|
{ label: "Ticker", value: report.ticker },
|
|
44
46
|
{ label: "Filing year", value: report.reportYear },
|
|
45
47
|
{ label: "Filed", value: report.filedAt },
|
|
@@ -62,12 +64,13 @@ export const riskFactorsHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
62
64
|
{ key: "group", header: "Group" }, { key: "heading", header: "Heading" },
|
|
63
65
|
{ key: "excerpt", header: "Prior source excerpt" }, { key: "note", header: "Analysis" },
|
|
64
66
|
], rows: report.diff.removed.map((risk, index) => ({ ...risk, note: report.notes.removed.find((note) => note.index === index)?.text ?? null })) });
|
|
65
|
-
const errors = [list
|
|
67
|
+
const errors = [unansweredRefreshError(list), unansweredRefreshError(report)].filter((error): error is string => !!error);
|
|
66
68
|
return {
|
|
67
69
|
sections,
|
|
68
70
|
errors: errors.length ? errors : undefined,
|
|
69
71
|
...(list?.stale || report.stale ? { complete: false } : {}),
|
|
70
72
|
metadata: {
|
|
73
|
+
...savedCopyMark(list),
|
|
71
74
|
ticker: report.ticker, requestedYear: selection, reportYear: report.reportYear,
|
|
72
75
|
filedAt: report.filedAt, updatedAt: report.updatedAt, docUrl: report.docUrl,
|
|
73
76
|
reportFetchedAt: report.fetchedAt, reportStale: report.stale,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PluginModule } from "../plugin-module";
|
|
2
|
+
import { RoadmapPane } from "./pane";
|
|
3
|
+
|
|
4
|
+
export const roadmapModule: PluginModule = {
|
|
5
|
+
panes: [
|
|
6
|
+
{
|
|
7
|
+
id: "roadmap",
|
|
8
|
+
name: "Roadmap",
|
|
9
|
+
icon: "R",
|
|
10
|
+
component: RoadmapPane,
|
|
11
|
+
reportFreshness: { status: "not-a-feed", basis: "curated list" },
|
|
12
|
+
defaultPosition: "right",
|
|
13
|
+
defaultMode: "floating",
|
|
14
|
+
defaultFloatingSize: { width: 100, height: 30 },
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
paneTemplates: [
|
|
19
|
+
{
|
|
20
|
+
id: "roadmap-pane",
|
|
21
|
+
paneId: "roadmap",
|
|
22
|
+
label: "Roadmap",
|
|
23
|
+
description: "What is being built, what is planned and what shipped. Pro members vote on what comes next.",
|
|
24
|
+
keywords: ["roadmap", "planned", "building", "shipped", "upcoming", "features", "vote", "votes", "request"],
|
|
25
|
+
shortcut: { prefix: "ROADMAP", aliases: ["RMAP"] },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RoadmapItem, RoadmapPayload, RoadmapStatus, RoadmapVote } from "../../../api-client/roadmap";
|
|
2
|
+
import type { TableSection } from "../../../components";
|
|
3
|
+
|
|
4
|
+
export const ROADMAP_STATUS_LABELS: Record<RoadmapStatus, string> = {
|
|
5
|
+
building: "Building",
|
|
6
|
+
planned: "Planned",
|
|
7
|
+
shipped: "Shipped",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const SECTION_ORDER: readonly RoadmapStatus[] = ["building", "planned", "shipped"];
|
|
11
|
+
|
|
12
|
+
/** Building, then Planned, then Shipped, each in the order the service sent; an empty group is left out. */
|
|
13
|
+
export function roadmapSections(items: readonly RoadmapItem[]): TableSection<RoadmapItem>[] {
|
|
14
|
+
return SECTION_ORDER.map((status) => ({
|
|
15
|
+
label: ROADMAP_STATUS_LABELS[status],
|
|
16
|
+
key: status,
|
|
17
|
+
items: items.filter((item) => item.status === status),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Votes are taken on what is not shipped yet; a shipped item's count is final. */
|
|
22
|
+
export function isVotable(item: RoadmapItem): boolean {
|
|
23
|
+
return item.status !== "shipped";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The items with this session's own votes on top: the count and mark a vote
|
|
28
|
+
* changed, held until the next read of the list replaces them.
|
|
29
|
+
*/
|
|
30
|
+
export function withLocalVotes(
|
|
31
|
+
payload: RoadmapPayload | null,
|
|
32
|
+
local: Readonly<Record<string, RoadmapVote>>,
|
|
33
|
+
): RoadmapItem[] {
|
|
34
|
+
if (!payload) return [];
|
|
35
|
+
return payload.items.map((item) => {
|
|
36
|
+
const vote = local[item.id];
|
|
37
|
+
return vote ? { ...item, votes: vote.votes, voted: vote.voted } : item;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the row shows while a vote is on its way: the mark flipped and the count moved by one. */
|
|
42
|
+
export function optimisticVote(item: RoadmapItem, voted: boolean): RoadmapVote {
|
|
43
|
+
const step = voted === item.voted ? 0 : voted ? 1 : -1;
|
|
44
|
+
return { id: item.id, votes: Math.max(0, item.votes + step), voted };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The votes cell: the count, with a check before it when this account voted. */
|
|
48
|
+
export function votesText(item: RoadmapItem): string {
|
|
49
|
+
return item.voted ? `✓ ${item.votes}` : String(item.votes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What one vote that failed is called on its row, and the reason the detail gives. */
|
|
53
|
+
export interface VoteFailure {
|
|
54
|
+
status: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function voteFailure(error: unknown, voted: boolean): VoteFailure {
|
|
59
|
+
const status = typeof error === "object" && error && "status" in error ? (error as { status?: unknown }).status : undefined;
|
|
60
|
+
const action = voted ? "The vote" : "Taking the vote back";
|
|
61
|
+
if (status === 401) return { status: "sign in", reason: `${action} did not go through: sign in again to vote.` };
|
|
62
|
+
if (status === 402 || status === 403) return { status: "Pro only", reason: `${action} did not go through: voting is for verified Pro members.` };
|
|
63
|
+
if (status === 404) return { status: "removed", reason: "This item is no longer on the roadmap." };
|
|
64
|
+
if (status === 409) return { status: "closed", reason: "This item shipped, so its votes are final." };
|
|
65
|
+
const message = error instanceof Error && error.message.trim() ? error.message.trim() : "the request failed";
|
|
66
|
+
return { status: "failed", reason: `${action} did not go through: ${message.replace(/\.$/, "")}.` };
|
|
67
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { apiClient } from "../../../api-client";
|
|
3
|
+
import { usePlanAccess } from "../../../api-client/plan-access";
|
|
4
|
+
import type { RoadmapItem, RoadmapVote } from "../../../api-client/roadmap";
|
|
5
|
+
import {
|
|
6
|
+
buildSectionedRows,
|
|
7
|
+
DataTableStackView,
|
|
8
|
+
EMPTY_TABLE_CELL,
|
|
9
|
+
isSectionedItemRow,
|
|
10
|
+
renderSectionedRowHeader,
|
|
11
|
+
usePaneStatusFooter,
|
|
12
|
+
useRowNotes,
|
|
13
|
+
type DataTableCell,
|
|
14
|
+
type DataTableColumn,
|
|
15
|
+
type PaneFooterSegment,
|
|
16
|
+
type PaneHint,
|
|
17
|
+
type SectionedRow,
|
|
18
|
+
} from "../../../components";
|
|
19
|
+
import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
|
|
20
|
+
import { DetailReader, type DetailFact } from "../../../components/reader/detail-reader";
|
|
21
|
+
import { useAsyncResource, usePluginPaneState } from "../../../public/react";
|
|
22
|
+
import { useThemeColors } from "../../../theme/theme-context";
|
|
23
|
+
import type { PaneProps } from "../../../types/plugin";
|
|
24
|
+
import { Box, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
|
|
25
|
+
import { formatShortDate } from "../../../utils/datetime-format";
|
|
26
|
+
import {
|
|
27
|
+
isVotable,
|
|
28
|
+
optimisticVote,
|
|
29
|
+
ROADMAP_STATUS_LABELS,
|
|
30
|
+
roadmapSections,
|
|
31
|
+
voteFailure,
|
|
32
|
+
votesText,
|
|
33
|
+
withLocalVotes,
|
|
34
|
+
type VoteFailure,
|
|
35
|
+
} from "./model";
|
|
36
|
+
|
|
37
|
+
/** The roadmap calls the pane makes; tests hand it their own. */
|
|
38
|
+
export type RoadmapClient = Pick<typeof apiClient, "getRoadmap" | "setRoadmapVote">;
|
|
39
|
+
export const RoadmapClientContext = createContext<RoadmapClient | null>(null);
|
|
40
|
+
|
|
41
|
+
type Row = SectionedRow<RoadmapItem>;
|
|
42
|
+
type ColumnId = "title" | "blurb" | "votes" | "shipped";
|
|
43
|
+
type Column = DataTableColumn & { id: ColumnId };
|
|
44
|
+
|
|
45
|
+
const rowKey = (row: Row) => row.key;
|
|
46
|
+
const itemKey = (item: RoadmapItem) => item.id;
|
|
47
|
+
const voteSignature = (item: RoadmapItem) => `${item.votes}:${item.voted}`;
|
|
48
|
+
|
|
49
|
+
/** Said once, quietly, to everyone who cannot vote: no upgrade pitch. */
|
|
50
|
+
const PRO_VOTES_SEGMENT: PaneFooterSegment = { id: "pro-votes", parts: [{ text: "Pro members vote", tone: "muted" }] };
|
|
51
|
+
const NO_SEGMENTS: PaneFooterSegment[] = [];
|
|
52
|
+
|
|
53
|
+
function shippedDate(item: RoadmapItem): string {
|
|
54
|
+
return item.shippedAt ? formatShortDate(item.shippedAt, { fallback: "" }) : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `votesCell` is what the votes cell says: the count, or a vote's failure on that row. */
|
|
58
|
+
function buildColumns(items: readonly RoadmapItem[], width: number, votesCell: (item: RoadmapItem) => string): Column[] {
|
|
59
|
+
const longest = Math.max(8, ...items.map((item) => item.title.length));
|
|
60
|
+
const narrow = width < 64;
|
|
61
|
+
const columns: Column[] = narrow
|
|
62
|
+
? [{ id: "title", label: "Feature", width: Math.min(longest, 24), flexGrow: 1, align: "left" }]
|
|
63
|
+
: [
|
|
64
|
+
{ id: "title", label: "Feature", width: Math.min(longest, width >= 120 ? 36 : 26), align: "left" },
|
|
65
|
+
{ id: "blurb", label: "Summary", width: 16, flexGrow: 1, align: "left" },
|
|
66
|
+
];
|
|
67
|
+
columns.push({ id: "votes", label: "Votes", width: Math.max(5, ...items.map((item) => votesCell(item).length)), align: "right" });
|
|
68
|
+
if (!narrow && items.some((item) => item.shippedAt)) {
|
|
69
|
+
columns.push({ id: "shipped", label: "Shipped", width: 12, align: "left" });
|
|
70
|
+
}
|
|
71
|
+
return columns;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function RoadmapDetail({ item, failure, width, scrollRef }: {
|
|
75
|
+
item: RoadmapItem;
|
|
76
|
+
failure: VoteFailure | null;
|
|
77
|
+
width: number;
|
|
78
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>;
|
|
79
|
+
}) {
|
|
80
|
+
const colors = useThemeColors();
|
|
81
|
+
// The stack title names the item; the facts and the blurb follow.
|
|
82
|
+
const facts: DetailFact[] = [
|
|
83
|
+
{ label: "Status", value: ROADMAP_STATUS_LABELS[item.status] },
|
|
84
|
+
{ label: "Votes", value: item.voted ? `${item.votes}, yours included` : String(item.votes) },
|
|
85
|
+
...(item.shippedAt ? [{ label: "Shipped", value: shippedDate(item) }] : []),
|
|
86
|
+
...(failure ? [{ label: "Vote", value: failure.reason, color: colors.negative }] : []),
|
|
87
|
+
];
|
|
88
|
+
return (
|
|
89
|
+
<DetailReader
|
|
90
|
+
ref={scrollRef}
|
|
91
|
+
resetScrollKey={item.id}
|
|
92
|
+
width={width}
|
|
93
|
+
facts={facts}
|
|
94
|
+
body={item.blurb || null}
|
|
95
|
+
bodyFormat="text"
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function RoadmapPane({ focused, width, height }: PaneProps) {
|
|
101
|
+
const colors = useThemeColors();
|
|
102
|
+
const client = useContext(RoadmapClientContext) ?? apiClient;
|
|
103
|
+
const access = usePlanAccess();
|
|
104
|
+
// A sign-in, a sign-out or a new plan asks again: the votes and the right to
|
|
105
|
+
// cast one are the account's. The new loader starts from no list, so the
|
|
106
|
+
// last account's marks and vote hint go at once, not when (or if) the new
|
|
107
|
+
// read lands. A refresh of the same session keeps its list.
|
|
108
|
+
const sessionKey = JSON.stringify([apiClient.getCurrentUser()?.id ?? null, access.emailVerified, access.hasProAccess]);
|
|
109
|
+
const loader = useCallback((_force: boolean) => client.getRoadmap(), [client, sessionKey]);
|
|
110
|
+
const resource = useAsyncResource(loader);
|
|
111
|
+
const sessionRef = useRef(sessionKey);
|
|
112
|
+
sessionRef.current = sessionKey;
|
|
113
|
+
const payload = resource.data;
|
|
114
|
+
const canVote = payload?.canVote === true;
|
|
115
|
+
|
|
116
|
+
// This session's votes, over the list until the next read replaces it.
|
|
117
|
+
const [localVotes, setLocalVotes] = useState<Record<string, RoadmapVote>>({});
|
|
118
|
+
useEffect(() => setLocalVotes({}), [resource.updatedAt]);
|
|
119
|
+
const items = useMemo(() => withLocalVotes(payload, localVotes), [payload, localVotes]);
|
|
120
|
+
const rows = useMemo(() => buildSectionedRows(roadmapSections(items), itemKey), [items]);
|
|
121
|
+
const { noteFor, setNote, clearNotes } = useRowNotes<RoadmapItem, VoteFailure>(items, { key: itemKey, signature: voteSignature });
|
|
122
|
+
// Another account's votes and refusals are not this one's.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
setLocalVotes({});
|
|
125
|
+
clearNotes();
|
|
126
|
+
}, [sessionKey, clearNotes]);
|
|
127
|
+
|
|
128
|
+
const [selectedId, setSelectedId] = usePluginPaneState<string | null>("selectedItemId", null);
|
|
129
|
+
const [openId, setOpenId] = usePluginPaneState<string | null>("openItemId", null);
|
|
130
|
+
const selected = items.find((item) => item.id === selectedId) ?? rows.find(isSectionedItemRow)?.item ?? null;
|
|
131
|
+
const open = openId ? items.find((item) => item.id === openId) ?? null : null;
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
// An item that left the roadmap closes once the list that dropped it is in.
|
|
134
|
+
if (openId && payload && !open) setOpenId(null);
|
|
135
|
+
}, [open, openId, payload]);
|
|
136
|
+
const detailScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
137
|
+
|
|
138
|
+
const inFlight = useRef(new Set<string>());
|
|
139
|
+
const reload = resource.reload;
|
|
140
|
+
const toggleVote = useCallback(async (item: RoadmapItem) => {
|
|
141
|
+
if (!canVote || !isVotable(item) || inFlight.current.has(item.id)) return;
|
|
142
|
+
const voted = !item.voted;
|
|
143
|
+
const session = sessionRef.current;
|
|
144
|
+
inFlight.current.add(item.id);
|
|
145
|
+
setNote(item.id, null);
|
|
146
|
+
setLocalVotes((current) => ({ ...current, [item.id]: optimisticVote(item, voted) }));
|
|
147
|
+
try {
|
|
148
|
+
const answer = await client.setRoadmapVote(item.id, voted);
|
|
149
|
+
// An answer for the account that just left changes nothing on screen.
|
|
150
|
+
if (sessionRef.current !== session) return;
|
|
151
|
+
setLocalVotes((current) => ({ ...current, [item.id]: answer }));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (sessionRef.current !== session) return;
|
|
154
|
+
setLocalVotes((current) => ({ ...current, [item.id]: { id: item.id, votes: item.votes, voted: item.voted } }));
|
|
155
|
+
setNote(item.id, voteFailure(error, voted));
|
|
156
|
+
} finally {
|
|
157
|
+
inFlight.current.delete(item.id);
|
|
158
|
+
}
|
|
159
|
+
}, [canVote, client, setNote]);
|
|
160
|
+
|
|
161
|
+
usePaneRefreshKey(() => void reload(), { focused });
|
|
162
|
+
|
|
163
|
+
const target = open ?? selected;
|
|
164
|
+
const hints = useMemo<PaneHint[]>(() => (
|
|
165
|
+
canVote && target && isVotable(target)
|
|
166
|
+
? [{ id: "vote", key: "v", label: target.voted ? " unvote" : "ote", title: target.voted ? "Take vote back" : "Vote", onPress: () => void toggleVote(target) }]
|
|
167
|
+
: []
|
|
168
|
+
), [canVote, target, toggleVote]);
|
|
169
|
+
usePaneStatusFooter({
|
|
170
|
+
registrationId: "roadmap",
|
|
171
|
+
// A failed refresh keeps the list; the failure goes in the footer.
|
|
172
|
+
loading: resource.loading && !!payload,
|
|
173
|
+
error: payload ? resource.error : null,
|
|
174
|
+
info: !canVote && items.length > 0 ? [PRO_VOTES_SEGMENT] : NO_SEGMENTS,
|
|
175
|
+
hints,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const columns = useMemo(
|
|
179
|
+
() => buildColumns(items, width, (item) => noteFor(item)?.status ?? votesText(item)),
|
|
180
|
+
[items, noteFor, width],
|
|
181
|
+
);
|
|
182
|
+
const renderCell = useCallback((row: Row, column: Column): DataTableCell => {
|
|
183
|
+
if (!isSectionedItemRow(row)) return EMPTY_TABLE_CELL;
|
|
184
|
+
const item = row.item;
|
|
185
|
+
switch (column.id) {
|
|
186
|
+
case "title":
|
|
187
|
+
return { text: item.title, color: colors.textBright };
|
|
188
|
+
case "blurb":
|
|
189
|
+
return { text: item.blurb, color: colors.textDim };
|
|
190
|
+
case "votes": {
|
|
191
|
+
const failure = noteFor(item);
|
|
192
|
+
if (failure) return { text: failure.status, color: colors.negative };
|
|
193
|
+
return {
|
|
194
|
+
text: votesText(item),
|
|
195
|
+
value: item.votes,
|
|
196
|
+
color: item.voted ? colors.positive : isVotable(item) ? colors.text : colors.textDim,
|
|
197
|
+
...(item.voted ? { attributes: TextAttributes.BOLD } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
case "shipped":
|
|
201
|
+
return { text: shippedDate(item), color: colors.textDim };
|
|
202
|
+
}
|
|
203
|
+
}, [colors, noteFor]);
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<DataTableStackView<Row, Column>
|
|
207
|
+
focused={focused}
|
|
208
|
+
detailOpen={!!open}
|
|
209
|
+
onBack={() => setOpenId(null)}
|
|
210
|
+
detailTitle={open?.title}
|
|
211
|
+
detailContent={open ? (
|
|
212
|
+
<RoadmapDetail item={open} failure={noteFor(open)} width={width} scrollRef={detailScrollRef} />
|
|
213
|
+
) : <Box flexGrow={1} />}
|
|
214
|
+
detailScrollRef={detailScrollRef}
|
|
215
|
+
rootWidth={width}
|
|
216
|
+
rootHeight={height}
|
|
217
|
+
// A refresh keeps the list on screen; only a first read waits or fails in the body.
|
|
218
|
+
status={payload ? undefined : {
|
|
219
|
+
loading: resource.loading,
|
|
220
|
+
error: resource.error,
|
|
221
|
+
subject: "Roadmap",
|
|
222
|
+
retry: { onRetry: reload, pending: resource.loading },
|
|
223
|
+
}}
|
|
224
|
+
emptyStateTitle="Nothing on the roadmap yet."
|
|
225
|
+
columns={columns}
|
|
226
|
+
items={rows}
|
|
227
|
+
getItemKey={rowKey}
|
|
228
|
+
isNavigable={isSectionedItemRow}
|
|
229
|
+
renderSectionHeader={renderSectionedRowHeader}
|
|
230
|
+
selection={{
|
|
231
|
+
kind: "id",
|
|
232
|
+
selectedId: selected?.id ?? null,
|
|
233
|
+
getId: rowKey,
|
|
234
|
+
onChange: (id) => setSelectedId(String(id)),
|
|
235
|
+
}}
|
|
236
|
+
onActivate={(row) => { if (isSectionedItemRow(row)) setOpenId(row.item.id); }}
|
|
237
|
+
sortColumnId={null}
|
|
238
|
+
sortDirection="asc"
|
|
239
|
+
renderCell={renderCell}
|
|
240
|
+
selectedTextOverridesCellColor
|
|
241
|
+
showHorizontalScrollbar={false}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type LiveScan,
|
|
18
18
|
} from "./live-model";
|
|
19
19
|
import { LIVE_SCAN_INDEXES } from "../../../api-client/live-scan";
|
|
20
|
+
import { formatHiloPrice } from "./hilo-model";
|
|
20
21
|
|
|
21
22
|
/** `all`, an index (`sp500`) or a sector (`Energy`), as `--universe` takes it. */
|
|
22
23
|
function parseHeadlessUniverse(value: string): LiveScan["universe"] {
|
|
@@ -61,7 +62,24 @@ function emptyText(payload: LiveScanPayload): string {
|
|
|
61
62
|
|
|
62
63
|
const number = (value: unknown) => (typeof value === "number" ? value : undefined);
|
|
63
64
|
|
|
65
|
+
/** `2026-10-27 19:59 UTC`: when the scan last ran, to the minute. */
|
|
66
|
+
const utcMinute = (at: number) => `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the report says above the matches: outside the session that the
|
|
70
|
+
* market is closed and when the scan last ran, since a delay means nothing
|
|
71
|
+
* then; in the session a delayed feed's delay.
|
|
72
|
+
*/
|
|
73
|
+
function headerNotice(payload: LiveScanPayload): string | null {
|
|
74
|
+
if (payload.status === "closed") {
|
|
75
|
+
const last = payload.asOf ?? payload.takenAt;
|
|
76
|
+
return last != null && Number.isFinite(last) ? `Market closed; last scan ${utcMinute(last)}` : "Market closed";
|
|
77
|
+
}
|
|
78
|
+
return payload.access === "delayed" ? `Delayed ${payload.delayMinutes} minutes` : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
function projectLiveScan(payload: LiveScanPayload, scan: LiveScan): HeadlessSnapshotResult {
|
|
82
|
+
const notice = headerNotice(payload);
|
|
65
83
|
return {
|
|
66
84
|
asOf: new Date(payload.asOf ?? payload.takenAt ?? Date.now()).toISOString(),
|
|
67
85
|
empty: emptyText(payload),
|
|
@@ -80,7 +98,7 @@ function projectLiveScan(payload: LiveScanPayload, scan: LiveScan): HeadlessSnap
|
|
|
80
98
|
...(payload.status === "degraded" ? { errors: ["Live scanner degraded"] } : {}),
|
|
81
99
|
freshness: freshness(payload),
|
|
82
100
|
metadata: {
|
|
83
|
-
...(
|
|
101
|
+
...(notice ? { notices: [notice] } : {}),
|
|
84
102
|
...(payload.warnings.length ? { warnings: payload.warnings } : {}),
|
|
85
103
|
status: payload.status,
|
|
86
104
|
access: payload.access,
|
|
@@ -117,7 +135,8 @@ export const liveScanHeadless: HeadlessPaneDefinition<"snapshot"> = {
|
|
|
117
135
|
columns: [
|
|
118
136
|
{ key: "symbol", header: "Symbol" },
|
|
119
137
|
{ key: "name", header: "Name" },
|
|
120
|
-
|
|
138
|
+
// At the pane's precision: cents, or four decimals under a dollar; the JSON keeps the feed's digits.
|
|
139
|
+
{ key: "price", header: "Price", align: "right", format: (value) => typeof value === "number" ? formatHiloPrice(value) : "--" },
|
|
121
140
|
{ key: "changePercent", header: "Chg %", align: "right", format: (value) => formatPercentRaw(number(value)) },
|
|
122
141
|
{ key: "relativeVolume", header: "RVol", align: "right", format: (value) => formatMultiple(number(value)) },
|
|
123
142
|
{ key: "volume", header: "Volume", align: "right" },
|
|
@@ -20,6 +20,7 @@ import { fetchFundFacts, fundFactEntries, fundFactPublishers, fundFactsEmpty, fu
|
|
|
20
20
|
import { REPORT_ROWS } from "../shared/headless-options";
|
|
21
21
|
import { fundAbroadProfile, venueAbroad, type FundAbroadProfile } from "./fund-abroad";
|
|
22
22
|
import { listingAnnouncementsPage } from "../../../utils/exchange-announcements";
|
|
23
|
+
import { companyDataNotes } from "../../../types/company-from";
|
|
23
24
|
|
|
24
25
|
const SEC_COLUMNS: HeadlessPaneColumn[] = [
|
|
25
26
|
{
|
|
@@ -140,7 +141,7 @@ export function createSecHeadless(dependencies: SecHeadlessDependencies = defaul
|
|
|
140
141
|
return {
|
|
141
142
|
rows,
|
|
142
143
|
textColumns: secTextColumns(rows),
|
|
143
|
-
...(note ? { notes: [note] } : {}),
|
|
144
|
+
...(note || filings[0]?.companyFrom ? { notes: [...(note ? [note] : []), ...companyDataNotes(filings[0]?.companyFrom)] } : {}),
|
|
144
145
|
...(form ? { empty: `No ${form} filings found for ${symbol}.` }
|
|
145
146
|
: page ? { empty: `${symbol} files no SEC reports. ${page.name}: ${page.url}` } : {}),
|
|
146
147
|
metadata: {
|
|
@@ -12,6 +12,7 @@ import { Box, Text, useUiCapabilities, type ScrollBoxRenderable } from "../../..
|
|
|
12
12
|
import { FILL_REST } from "../../../components/ui/fill";
|
|
13
13
|
import { t, tf } from "../../../i18n";
|
|
14
14
|
import { EmptyState, ExternalLinkText, FeedDataTableStackView, PaneLinkMenu, QueryBar, Spinner, TickerBadgeList, usePaneNoticeFooter, useTableLoadMore, type FeedDataTableItem, type PaneHint, type QueryBarFilter } from "../../../components";
|
|
15
|
+
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
15
16
|
import { displayWidth } from "../../../utils/format";
|
|
16
17
|
import type { DetailFact } from "../../../components/reader/detail-reader";
|
|
17
18
|
import type { ThemeColors } from "../../../theme/colors";
|
|
@@ -466,6 +467,8 @@ function SecView({ width, height, focused, paneType }: { width: number; height:
|
|
|
466
467
|
showOpenHint: !!(openFiling ? openFiling.filingUrl : factsUrl ?? abroadPage ?? announcements),
|
|
467
468
|
});
|
|
468
469
|
|
|
470
|
+
// A foreign line's filings are its company's, read from the home line.
|
|
471
|
+
useCompanyDataFooter(issuerFilings[0]?.companyFrom, !openFiling);
|
|
469
472
|
usePaneNoticeFooter({
|
|
470
473
|
registrationId: "sec:form-filter",
|
|
471
474
|
notices: filterHistoryCapped
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logFailure } from "../../../utils/log-failure";
|
|
2
|
-
import { ApiRequestError,
|
|
2
|
+
import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
|
|
3
3
|
import type { PluginCacheResult } from "../../../data/plugin-cache";
|
|
4
4
|
|
|
5
5
|
interface CloudCache<T> {
|
|
@@ -12,6 +12,8 @@ export interface CloudResource<T> {
|
|
|
12
12
|
payload: T;
|
|
13
13
|
stale: boolean;
|
|
14
14
|
refreshError: string | null;
|
|
15
|
+
/** When the copy shown was saved, when it is one because its refresh failed. */
|
|
16
|
+
savedAt?: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -36,8 +38,9 @@ const isAbort = (error: unknown) => !!error && typeof error === "object" && (err
|
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Loads through the cache, falling back to the cached copy when a refresh
|
|
39
|
-
* fails. A
|
|
40
|
-
* instead of data the account can
|
|
41
|
+
* fails. A refusal asking again will not change still throws: a refused
|
|
42
|
+
* session, so the pane shows its sign-in wall instead of data the account can
|
|
43
|
+
* no longer see, and any other permanent client error (an unknown series).
|
|
41
44
|
*
|
|
42
45
|
* Callers of one key share the request in flight, which runs on the first
|
|
43
46
|
* caller's `signal`. When that caller goes away (a tab left while it loads,
|
|
@@ -57,8 +60,13 @@ export async function loadCloudResource<T>(
|
|
|
57
60
|
throw error;
|
|
58
61
|
});
|
|
59
62
|
if (!result || cancelledByOther(result.error)) result = await cache.load(key, fetch, { force });
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
const saved = result.source === "stale-fallback";
|
|
64
|
+
if (saved && isPermanentClientError(result.error)) throw result.error;
|
|
65
|
+
// A copy served because its refresh failed is one by where it came from, whatever the failure said (or did not).
|
|
66
|
+
return {
|
|
67
|
+
payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null,
|
|
68
|
+
...(saved ? { savedAt: result.fetchedAt } : {}),
|
|
69
|
+
};
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -6,7 +6,7 @@ import { clipPriceHistoryToRange } from "../../../time-series/history-window";
|
|
|
6
6
|
import { canonicalExchange, parsePublicTickerKey } from "../../../utils/exchanges";
|
|
7
7
|
import { nonUsSecListingVenue } from "../../../utils/sec";
|
|
8
8
|
|
|
9
|
-
export async function resolveHeadlessInstrument(ctx: HeadlessPaneContext, symbol: string) {
|
|
9
|
+
export async function resolveHeadlessInstrument(ctx: Pick<HeadlessPaneContext, "resolveInstrument">, symbol: string) {
|
|
10
10
|
return ctx.resolveInstrument ? ctx.resolveInstrument(symbol) : parsePublicTickerKey(symbol);
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -39,7 +39,7 @@ export async function resolveHeadlessIssuerListing(ctx: HeadlessPaneContext, key
|
|
|
39
39
|
return resolveIssuerListing(key, exchange, ctx.marketData);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export async function loadHeadlessFinancials(ctx: HeadlessPaneContext, key: string) {
|
|
42
|
+
export async function loadHeadlessFinancials(ctx: Pick<HeadlessPaneContext, "marketData" | "resolveInstrument">, key: string) {
|
|
43
43
|
const { symbol, exchange } = await resolveHeadlessInstrument(ctx, key);
|
|
44
44
|
return ctx.marketData.getTickerFinancials(symbol, exchange ?? "");
|
|
45
45
|
}
|
|
@@ -47,6 +47,14 @@ export function barResolutionFromDates(dates: readonly (string | number | Date)[
|
|
|
47
47
|
/** Company filings read from EDGAR. Old filings are not stale; there is no schedule to miss. */
|
|
48
48
|
export const SEC_FILINGS: HeadlessPaneFreshness = { source: "SEC EDGAR", status: "not-a-feed", basis: "filed data", oldest: null };
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A list of filings, dated by its newest one's filing date and by when it was
|
|
52
|
+
* read: `newest filing Tue 27 Oct 2026 · retrieved Wed 28 Oct 2026 19:07 UTC`.
|
|
53
|
+
*/
|
|
54
|
+
export const SEC_FILING_ROWS: HeadlessPaneFreshness = {
|
|
55
|
+
...SEC_FILINGS, observedKey: "filingDate", asOfLabel: "newest filing", retrievedToo: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
50
58
|
/**
|
|
51
59
|
* What a filings view opened inside a pane that hosts others (Issuer Debt in
|
|
52
60
|
* Fixed Income) declares as its template's `reportFreshness`, so a capture
|
|
@@ -14,7 +14,7 @@ import { displayWidth } from "../../../utils/format";
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** The one placeholder for a value the source does not give. */
|
|
17
|
-
const NO_VALUE = "--";
|
|
17
|
+
export const NO_VALUE = "--";
|
|
18
18
|
|
|
19
19
|
export const missingCell = (colors: ThemeColors): DataTableCell => ({ text: NO_VALUE, value: null, color: colors.textMuted });
|
|
20
20
|
|
|
@@ -74,9 +74,9 @@ export function createSeriesCache(kind: string, staleMs: number): SeriesCache {
|
|
|
74
74
|
let generation = 0;
|
|
75
75
|
let persistence: PluginPersistence | null = null;
|
|
76
76
|
const withFailure = <T extends SeriesCacheLoadResult>(key: string, result: T): T => {
|
|
77
|
-
|
|
78
|
-
return
|
|
79
|
-
? { ...result, stale: true, source: "stale-fallback", refreshError:
|
|
77
|
+
// A failure is one whatever it said: an Error without a message still leaves the copy a saved one.
|
|
78
|
+
return failures.has(key)
|
|
79
|
+
? { ...result, stale: true, source: "stale-fallback", refreshError: failures.get(key) }
|
|
80
80
|
: result;
|
|
81
81
|
};
|
|
82
82
|
const loadEntry: SeriesCache["loadEntry"] = async (key, loader, options) => {
|
|
@@ -103,7 +103,7 @@ export function createSeriesCache(kind: string, staleMs: number): SeriesCache {
|
|
|
103
103
|
if (owner === generation && requests.get(key) === request) requests.delete(key);
|
|
104
104
|
}
|
|
105
105
|
}, options);
|
|
106
|
-
return withFailure(key, { ...data, fetchedAt, stale, source, ...(
|
|
106
|
+
return withFailure(key, { ...data, fetchedAt, stale, source, ...(source === "stale-fallback" ? { refreshError } : {}) });
|
|
107
107
|
};
|
|
108
108
|
const reset = () => {
|
|
109
109
|
generation += 1;
|
|
@@ -31,6 +31,12 @@ const FORMS_13F_CACHE_POLICY = {
|
|
|
31
31
|
|
|
32
32
|
export interface Forms13FReadOptions {
|
|
33
33
|
onWarning?: (warning: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* A read answered from the copy saved at `savedAt` (ms) because its refresh
|
|
36
|
+
* failed, said by when it was saved (a command's closing line) instead of
|
|
37
|
+
* as an `onWarning` line.
|
|
38
|
+
*/
|
|
39
|
+
onSavedCopy?: (savedAt: number) => void;
|
|
34
40
|
forceRefresh?: boolean;
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -157,7 +163,12 @@ async function fetchForms13F<T>(
|
|
|
157
163
|
const retained = options.cache !== false
|
|
158
164
|
? store?.getResource<T>(CACHE_KIND, key, { ...cacheOptions, allowExpired: true }) : null;
|
|
159
165
|
// A caller may retain old research only when it can surface its provenance.
|
|
160
|
-
if (!retained
|
|
166
|
+
if (!retained) throw error;
|
|
167
|
+
if (options.onSavedCopy && Number.isFinite(retained.fetchedAt)) {
|
|
168
|
+
options.onSavedCopy(retained.fetchedAt);
|
|
169
|
+
return retained.value;
|
|
170
|
+
}
|
|
171
|
+
if (!options.onWarning) throw error;
|
|
161
172
|
const retrieved = new Date(retained.fetchedAt);
|
|
162
173
|
const age = Number.isFinite(retrieved.getTime()) ? `retrieved ${retrieved.toISOString()}` : "with unavailable retrieval time";
|
|
163
174
|
const reason = (error instanceof Error ? error.message : String(error)).trim() || "Request failed";
|
|
@@ -115,7 +115,7 @@ export async function loadBrowserRows(
|
|
|
115
115
|
tab: ThirteenFBrowserTab,
|
|
116
116
|
query: string,
|
|
117
117
|
signal?: AbortSignal,
|
|
118
|
-
options:
|
|
118
|
+
options: Pick<Forms13FReadOptions, "forceRefresh" | "onSavedCopy"> & { offset?: number; limit?: number } = {},
|
|
119
119
|
): Promise<BrowserLoadResult> {
|
|
120
120
|
const warnings: string[] = [];
|
|
121
121
|
const result = await loadBrowserPage(tab, query, signal, { ...options, onWarning: warning => warnings.push(warning) });
|
|
@@ -136,13 +136,13 @@ async function loadBrowserPage(
|
|
|
136
136
|
const offset = Math.max(0, options.offset ?? 0);
|
|
137
137
|
const browserLimit = Math.max(1, options.limit ?? BROWSER_PAGE_LIMIT);
|
|
138
138
|
const latestLimit = Math.max(1, options.limit ?? LATEST_FILINGS_PAGE_LIMIT);
|
|
139
|
-
const apiOptions = { forceRefresh: options.forceRefresh, onWarning: options.onWarning };
|
|
139
|
+
const apiOptions = { forceRefresh: options.forceRefresh, onWarning: options.onWarning, onSavedCopy: options.onSavedCopy };
|
|
140
140
|
const pageApiOptions = { ...apiOptions, offset };
|
|
141
141
|
|
|
142
142
|
if (tab === "performance") {
|
|
143
143
|
const topFunds = await listTopThirteenFFunds(quarter, browserLimit, signal, pageApiOptions);
|
|
144
144
|
const quarters = priorQuarters(quarter);
|
|
145
|
-
const history = await loadRankingHistory(quarters, signal,
|
|
145
|
+
const history = await loadRankingHistory(quarters, signal, apiOptions);
|
|
146
146
|
const funds: ThirteenFFund[] = topFunds.map((fund) => ({ cik: fund.cik, name: fund.name }));
|
|
147
147
|
const rows = buildBrowserRows({ topFunds, source: "performance" }).map(row => ({ ...row, priorReturns: quarters.map((previous, index) => ({ quarter: previous, value: history[index]?.get(row.cik) ?? null })) }));
|
|
148
148
|
const result: BrowserLoadResult = {
|
|
@@ -437,10 +437,10 @@ export async function loadFundPerformance(
|
|
|
437
437
|
cik: string,
|
|
438
438
|
fallbackName: string,
|
|
439
439
|
signal?: AbortSignal,
|
|
440
|
-
options:
|
|
440
|
+
options: Pick<Forms13FReadOptions, "forceRefresh" | "onSavedCopy"> & { splitFactor?: SplitFactorLookup } = {},
|
|
441
441
|
): Promise<FundPerformanceResult> {
|
|
442
442
|
const warnings: string[] = [];
|
|
443
|
-
const apiOptions = { forceRefresh: options.forceRefresh, onWarning: (warning: string) => warnings.push(warning) };
|
|
443
|
+
const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: options.onSavedCopy, onWarning: (warning: string) => warnings.push(warning) };
|
|
444
444
|
const detail = await loadFundDetail(cik, fallbackName, signal, options);
|
|
445
445
|
const period = detail.latestForm?.periodOfReport;
|
|
446
446
|
const quarter = period ? periodToQuarter(period) : null;
|
|
@@ -496,11 +496,11 @@ export async function loadFundDetail(
|
|
|
496
496
|
fallbackName: string,
|
|
497
497
|
signal?: AbortSignal,
|
|
498
498
|
/** `filingGap: false` skips EDGAR's filing list, for a load that never shows it (an overlap). */
|
|
499
|
-
options:
|
|
499
|
+
options: Pick<Forms13FReadOptions, "forceRefresh" | "onSavedCopy"> & { filingGap?: boolean } = {},
|
|
500
500
|
): Promise<FundDetailData> {
|
|
501
501
|
const now = new Date();
|
|
502
502
|
const warnings: string[] = [];
|
|
503
|
-
const apiOptions = { forceRefresh: options.forceRefresh, onWarning: (warning: string) => warnings.push(warning) };
|
|
503
|
+
const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: options.onSavedCopy, onWarning: (warning: string) => warnings.push(warning) };
|
|
504
504
|
|
|
505
505
|
const forms = await listThirteenFForms(
|
|
506
506
|
cik,
|