@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.
Files changed (203) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/hiring.ts +3 -0
  10. package/src/api-client/index.ts +4 -0
  11. package/src/api-client/ipo.ts +24 -1
  12. package/src/api-client/mna.ts +3 -0
  13. package/src/api-client/paths.ts +9 -1
  14. package/src/api-client/roadmap.ts +85 -0
  15. package/src/api-client/types.ts +12 -0
  16. package/src/architecture/core-builtin-allowlist.json +2 -2
  17. package/src/cli/commands/market.ts +35 -12
  18. package/src/cli/commands/news-search.ts +22 -0
  19. package/src/cli/commands/overview.ts +86 -20
  20. package/src/cli/commands/ticker.ts +32 -2
  21. package/src/cli/errors.ts +17 -2
  22. package/src/cli/glossary.ts +6 -0
  23. package/src/cli/index-roots.ts +0 -7
  24. package/src/cli/listing-arg.ts +57 -10
  25. package/src/cli/pane-functions/capabilities.ts +1 -0
  26. package/src/cli/pane-functions/discovery.ts +21 -1
  27. package/src/cli/pane-functions/freshness.ts +7 -1
  28. package/src/cli/pane-functions/function-help.ts +7 -0
  29. package/src/cli/pane-functions/index.ts +6 -3
  30. package/src/cli/quote-answer.ts +1 -1
  31. package/src/cli/saved-copy-persistence.ts +88 -0
  32. package/src/components/command-bar/list/model.ts +5 -0
  33. package/src/components/command-bar/routes/root/runtime.ts +6 -7
  34. package/src/components/command-bar/selection-runtime.ts +52 -3
  35. package/src/components/command-bar/surface/index.tsx +2 -0
  36. package/src/components/layout/pane/capture-stamp.ts +2 -2
  37. package/src/components/layout/pane/footer/index.tsx +22 -6
  38. package/src/components/layout/pane/footer/model.ts +83 -15
  39. package/src/components/layout/pane/status-footer.ts +35 -2
  40. package/src/components/layout/status-widget-space.ts +9 -0
  41. package/src/components/ticker/list-table-view.tsx +4 -20
  42. package/src/components/ui/display.tsx +18 -3
  43. package/src/components/ui/icon.tsx +4 -1
  44. package/src/data/plugin-cache.ts +5 -3
  45. package/src/data/saved-copies.ts +8 -0
  46. package/src/docs/snapshot.json +32 -5
  47. package/src/i18n/es.ts +2 -4
  48. package/src/i18n/ja.ts +2 -4
  49. package/src/i18n/ko.ts +2 -4
  50. package/src/i18n/zh-cn.ts +2 -4
  51. package/src/i18n/zh-tw.ts +2 -4
  52. package/src/market-data/depositary-pair.ts +25 -1
  53. package/src/market-data/quotes/metadata.ts +7 -0
  54. package/src/news/aggregator.ts +7 -1
  55. package/src/news/types.ts +6 -0
  56. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  57. package/src/plugins/builtin/alerts/cli.ts +34 -11
  58. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  59. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  60. package/src/plugins/builtin/alerts/headless.ts +10 -6
  61. package/src/plugins/builtin/cds/client.ts +142 -30
  62. package/src/plugins/builtin/cds/headless.ts +35 -8
  63. package/src/plugins/builtin/cds/model.ts +145 -18
  64. package/src/plugins/builtin/cds/pane.tsx +16 -5
  65. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  66. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  67. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  68. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  69. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  70. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  71. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  72. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  73. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  74. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  75. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  76. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  77. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  78. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  79. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  80. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  81. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  82. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  83. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  84. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  85. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  86. package/src/plugins/builtin/composite-plugins.ts +2 -1
  87. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  88. package/src/plugins/builtin/doe/headless.ts +3 -2
  89. package/src/plugins/builtin/doe/pane.tsx +13 -3
  90. package/src/plugins/builtin/earnings-calls/data.ts +18 -6
  91. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
  93. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  94. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  95. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  96. package/src/plugins/builtin/econ/figures.ts +48 -0
  97. package/src/plugins/builtin/econ/index.tsx +14 -5
  98. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  99. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  100. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  101. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  102. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  103. package/src/plugins/builtin/help/index.tsx +61 -59
  104. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  105. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  106. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  107. package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
  108. package/src/plugins/builtin/holders/headless.ts +4 -2
  109. package/src/plugins/builtin/holders/pane.tsx +3 -0
  110. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  111. package/src/plugins/builtin/insider/headless.ts +2 -2
  112. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  113. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  114. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  115. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  116. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  117. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  118. package/src/plugins/builtin/macro-day/model.ts +39 -5
  119. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  120. package/src/plugins/builtin/market-movers/client.ts +2 -0
  121. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  122. package/src/plugins/builtin/market-movers/screener.ts +8 -4
  123. package/src/plugins/builtin/mna/headless.ts +2 -0
  124. package/src/plugins/builtin/mna/pane.tsx +3 -1
  125. package/src/plugins/builtin/news/headless.ts +3 -1
  126. package/src/plugins/builtin/news/index.tsx +3 -0
  127. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  128. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  129. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  130. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  131. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  132. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  133. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  134. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  135. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  136. package/src/plugins/builtin/research/event-model.ts +30 -4
  137. package/src/plugins/builtin/research/index.tsx +7 -3
  138. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  139. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  140. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  141. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  142. package/src/plugins/builtin/risk-factors/data.ts +4 -2
  143. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  144. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  145. package/src/plugins/builtin/roadmap/model.ts +67 -0
  146. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  147. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  148. package/src/plugins/builtin/sec/headless.ts +2 -1
  149. package/src/plugins/builtin/sec/index.tsx +3 -0
  150. package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
  151. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  152. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  153. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  154. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  155. package/src/plugins/builtin/thirteenf/api.ts +12 -1
  156. package/src/plugins/builtin/thirteenf/data.ts +7 -7
  157. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  158. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  159. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  160. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  161. package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
  162. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  163. package/src/plugins/builtin/volatility/pane.tsx +5 -2
  164. package/src/plugins/builtin/yield-curve/client.ts +61 -1
  165. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  166. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  167. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  168. package/src/plugins/catalog-browser.ts +2 -1
  169. package/src/plugins/host-link.ts +1 -1
  170. package/src/portfolio/column-values.ts +11 -7
  171. package/src/renderers/dom/desktop/icons.tsx +7 -0
  172. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  173. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  174. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  175. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  176. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  177. package/src/renderers/electrobun/bun/index.ts +26 -2
  178. package/src/renderers/electrobun/bun/window/create.ts +43 -4
  179. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  180. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  181. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  182. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  183. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  184. package/src/renderers/electrobun/view/news-source.ts +2 -0
  185. package/src/renderers/electrobun/view/ui-host.tsx +8 -0
  186. package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
  187. package/src/sources/gloomberb-cloud/index.ts +42 -8
  188. package/src/tickers/company-name-listing.ts +53 -13
  189. package/src/tickers/company-name.ts +6 -1
  190. package/src/tickers/index-roots.ts +7 -0
  191. package/src/tickers/listing-fate.ts +2 -2
  192. package/src/tickers/search/index.ts +6 -2
  193. package/src/tickers/search/ranking.ts +5 -1
  194. package/src/tickers/search/upsert.ts +13 -0
  195. package/src/tickers/ticker-changes.ts +8 -4
  196. package/src/types/company-from.ts +40 -0
  197. package/src/types/data-provider.ts +7 -0
  198. package/src/types/financials.ts +27 -0
  199. package/src/types/headless.ts +6 -0
  200. package/src/utils/crypto-pair.ts +121 -7
  201. package/src/utils/declared-dividends.ts +1 -1
  202. package/src/utils/fundamentals.ts +36 -5
  203. 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
- { title: "Filing", entries: [
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?.refreshError, report.refreshError].filter((error): error is string => !!error);
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
- ...(payload.access === "delayed" ? { notices: [`Delayed ${payload.delayMinutes} minutes`] } : {}),
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
- { key: "price", header: "Price", align: "right" },
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, isAccessDenied } from "../../../api-client/errors";
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 refused session still throws, so the pane shows its sign-in wall
40
- * instead of data the account can no longer see.
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
- if (isAccessDenied(result.error)) throw result.error;
61
- return { payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null };
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
- const failure = failures.get(key);
78
- return failure
79
- ? { ...result, stale: true, source: "stale-fallback", refreshError: failure }
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, ...(refreshError ? { refreshError } : {}) });
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 || !options.onWarning) throw error;
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: { forceRefresh?: boolean; offset?: number; limit?: number } = {},
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, { ...apiOptions, onWarning: options.onWarning });
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: { forceRefresh?: boolean; splitFactor?: SplitFactorLookup } = {},
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: { forceRefresh?: boolean; filingGap?: boolean } = {},
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,