gloomberb 0.18.0 → 0.18.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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/market-movers.ts +7 -0
  3. package/src/api-client/types.ts +9 -0
  4. package/src/cli/commands/fred-series.ts +15 -5
  5. package/src/cli/commands/market.ts +9 -5
  6. package/src/cli/commands/overview.ts +31 -6
  7. package/src/cli/commands/ticker.ts +5 -2
  8. package/src/cli/pane-functions/function-help.ts +4 -4
  9. package/src/cli/symbol-list.ts +51 -16
  10. package/src/components/chart/composite/composite-chart.tsx +11 -5
  11. package/src/components/chart/composite/panel-surface.tsx +6 -8
  12. package/src/components/chart/composite/price-axis-labels.tsx +2 -1
  13. package/src/components/chart/composite/types.ts +4 -0
  14. package/src/components/command-bar/workflow/collection-actions.ts +4 -0
  15. package/src/components/onboarding/wizard-positions.ts +8 -0
  16. package/src/docs/snapshot.json +52 -17
  17. package/src/plugins/builtin/analytics/index.tsx +1 -1
  18. package/src/plugins/builtin/analytics/risk-client.ts +44 -11
  19. package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
  20. package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
  21. package/src/plugins/builtin/analytics/risk-model.ts +79 -16
  22. package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
  23. package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
  24. package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
  25. package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
  26. package/src/plugins/builtin/custom-view/loader.ts +3 -2
  27. package/src/plugins/builtin/custom-view/pane.tsx +5 -4
  28. package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
  29. package/src/plugins/builtin/econ/calendar-model.ts +3 -2
  30. package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
  31. package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
  32. package/src/plugins/builtin/executives/data.ts +24 -8
  33. package/src/plugins/builtin/executives/index.tsx +2 -18
  34. package/src/plugins/builtin/executives/pane.tsx +34 -10
  35. package/src/plugins/builtin/market-movers/client.ts +2 -2
  36. package/src/plugins/builtin/market-movers/index.tsx +0 -3
  37. package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
  38. package/src/plugins/builtin/market-movers/session.ts +25 -10
  39. package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
  40. package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
  41. package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
  42. package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
  43. package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
  44. package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
  45. package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
  46. package/src/plugins/builtin/notes/cli.ts +33 -8
  47. package/src/plugins/builtin/notes/export.ts +49 -3
  48. package/src/plugins/builtin/notes/files.ts +14 -3
  49. package/src/plugins/builtin/notes/index.tsx +3 -3
  50. package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
  51. package/src/plugins/builtin/notes/store.ts +13 -3
  52. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
  53. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
  54. package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
  55. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
  56. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  57. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
  58. package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
  59. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
  60. package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
  61. package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
  62. package/src/plugins/builtin/thirteenf/api.ts +4 -1
  63. package/src/plugins/builtin/thirteenf/data.ts +5 -1
  64. package/src/plugins/builtin/thirteenf/headless.ts +3 -1
  65. package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
  66. package/src/portfolio/cost-fx.ts +84 -0
  67. package/src/portfolio/mutations.ts +16 -2
  68. package/src/portfolio/position-metrics.ts +45 -4
  69. package/src/portfolio/position-quantity.ts +43 -0
  70. package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
  71. package/src/sync/core-contributors.ts +1 -0
  72. package/src/team/collections.ts +2 -0
  73. package/src/tickers/metadata.ts +10 -0
  74. package/src/types/ticker.ts +9 -0
  75. package/src/utils/currency-units.ts +9 -0
@@ -27,11 +27,14 @@ import {
27
27
  } from "../../../ui";
28
28
  import { isPermanentClientError } from "../../../api-client/errors";
29
29
  import type { IssuerListingParams } from "../../../api-client/paths";
30
+ import { usePlanAccess } from "../../../api-client/plan-access";
31
+ import { CLOUD_PLAN_KEY, useCloudUpgradeAction } from "../shared/cloud-upgrade";
30
32
  import { useFilingYearReader } from "../shared/filing-year-reader";
33
+ import { UpgradeLabel } from "../shared/locked-rows";
31
34
  import { useProFeatureWall, type ProFeatureWallCopy, type ReadGuard } from "../shared/pro-feature-wall";
32
35
  import { useBoundTicker, useIssuerListing } from "../shared/ticker-request";
33
36
  import { SplitBar } from "../../../components/ui/split-bar";
34
- import { loadProxyStatement, loadProxyStatements } from "./data";
37
+ import { loadProxyStatement, loadProxyStatements, type ProxyTier } from "./data";
35
38
  import {
36
39
  equityShare,
37
40
  formatChange,
@@ -127,9 +130,12 @@ function renderOfficerCell(row: CloudExecutiveRowPayload, column: OfficerColumn)
127
130
  function ExecutiveRows({
128
131
  rows,
129
132
  width,
133
+ breakdown,
130
134
  }: {
131
135
  rows: CloudExecutiveRowPayload[];
132
136
  width: number;
137
+ /** False on the free preview, whose rows carry the total alone: no EQ% column. */
138
+ breakdown: boolean;
133
139
  }) {
134
140
  const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
135
141
  if (width < 52) {
@@ -146,7 +152,9 @@ function ExecutiveRows({
146
152
  }
147
153
  // The name column fits the longest name so the title starts after a kit gutter.
148
154
  const nameWidth = Math.min(24, Math.max(10, ...rows.map((row) => row.name.length)));
149
- const columns = OFFICER_COLUMNS.map((column) => column.id === "name" ? { ...column, width: nameWidth } : column);
155
+ const columns = OFFICER_COLUMNS
156
+ .filter((column) => breakdown || column.id !== "equity")
157
+ .map((column) => column.id === "name" ? { ...column, width: nameWidth } : column);
150
158
  const height = rows.length + 1;
151
159
  return (
152
160
  <Box flexDirection="column" height={isDesktopWeb ? undefined : height} flexShrink={0}>
@@ -251,22 +259,26 @@ export function ExecutivesPane({
251
259
  /** Inside Ticker Research, whose own tab strip keeps h/l and the arrows. */
252
260
  nested?: boolean;
253
261
  }) {
254
- const { wall, guard } = useProFeatureWall(EXEC_WALL, { width, height });
262
+ // Every plan reads EXEC: without Pro the server serves each executive's
263
+ // total pay, so only its refusal (an older server, an expired session) walls it.
264
+ const { wall, guard } = useProFeatureWall(EXEC_WALL, { width, height }, { preview: true });
265
+ const tier: ProxyTier = usePlanAccess().hasProAccess ? "full" : "preview";
255
266
  const { symbol, ticker: boundTicker } = useBoundTicker();
256
267
  const ticker = symbol ? symbol.toUpperCase() : null;
257
268
  const listing = useIssuerListing(boundTicker);
258
269
  if (wall) return wall;
259
270
  if (!ticker) return <EmptyState title="Select a ticker." />;
260
- return <ExecutiveResearch key={ticker} ticker={ticker} listing={listing} focused={focused} width={width} nested={nested} guard={guard} />;
271
+ return <ExecutiveResearch key={ticker} ticker={ticker} listing={listing} tier={tier} focused={focused} width={width} nested={nested} guard={guard} />;
261
272
  }
262
273
 
263
274
  export function ExecutivesResearchTab(props: { focused: boolean; width: number; height: number }) {
264
275
  return <ExecutivesPane {...props} nested />;
265
276
  }
266
277
 
267
- function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
278
+ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guard }: {
268
279
  ticker: string;
269
280
  listing: IssuerListingParams;
281
+ tier: ProxyTier;
270
282
  focused: boolean;
271
283
  width: number;
272
284
  nested: boolean;
@@ -274,15 +286,20 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
274
286
  }) {
275
287
  const nativePaneChrome = useUiCapabilities().nativePaneChrome === true;
276
288
  const rendererHost = useRendererHost();
289
+ const openUpgrade = useCloudUpgradeAction("exec");
277
290
  const [selectedYear, setYear] = usePaneStateValue<number | null>("proxyYear", null);
278
- const loadYears = useCallback((force: boolean) => guard(loadProxyStatements(ticker, { force, listing })), [guard, listing, ticker]);
291
+ const loadYears = useCallback((force: boolean) => guard(loadProxyStatements(ticker, { force, listing, tier })), [guard, listing, ticker, tier]);
279
292
  const list = useAsyncResource(loadYears, { clearOnError: isPermanentClientError });
280
293
  const years = useMemo(() => (list.data?.data?.proxies ?? []).map((entry) => entry.proxyYear), [list.data]);
281
- const year = selectedYear !== null && years.includes(selectedYear)
294
+ // Without Pro only the latest proxy is served: an earlier pick from a Pro
295
+ // session waits for Pro again rather than asking for a year that is refused.
296
+ const year = tier === "full" && selectedYear !== null && years.includes(selectedYear)
282
297
  ? selectedYear : years[0] ?? null;
283
- const loadStatement = useCallback((force: boolean) => guard(loadProxyStatement(ticker, year!, { force, listing })), [guard, listing, ticker, year]);
298
+ const loadStatement = useCallback((force: boolean) => guard(loadProxyStatement(ticker, year!, { force, listing, tier })), [guard, listing, ticker, year, tier]);
284
299
  const detail = useAsyncResource(year === null ? null : loadStatement, { clearOnError: isPermanentClientError });
285
300
  const statement = detail.data?.data ?? null;
301
+ // The free preview: each executive's total pay; the breakdown and earlier years are Pro.
302
+ const preview = statement?.access === "preview" || list.data?.data?.access === "preview";
286
303
  const loading = list.loading || detail.loading;
287
304
  const listError = list.error ?? list.data?.refreshError;
288
305
  const statementError = detail.error ?? detail.data?.refreshError;
@@ -332,8 +349,9 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
332
349
  // `r` refreshes every pane, so it gets no hint here.
333
350
  const hints: PaneHint[] = statement ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }] : [];
334
351
  if (yearHint) hints.push(yearHint);
352
+ if (preview) hints.push({ id: "upgrade", key: CLOUD_PLAN_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: openUpgrade });
335
353
  return { info, hints };
336
- }, [loading, statement, openFiling, yearHint]);
354
+ }, [loading, statement, openFiling, yearHint, preview, openUpgrade]);
337
355
 
338
356
  const figures = useMemo(
339
357
  () => (statement ? figuresOf(statement) : []),
@@ -390,7 +408,7 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
390
408
  <FigureList figures={figures} width={proseWidth} minValueWidth={4} maxValueWidth={14} />
391
409
  </Box>
392
410
  )}
393
- {statement.ceo && (
411
+ {statement.ceo && !preview && (
394
412
  <Box flexDirection="column">
395
413
  <SectionHeading marginTop={figures.length > 0 ? 1 : 0}
396
414
  title={`How ${statement.ceo.name.trim().split(/\s+/).pop() || "the CEO"} Was Paid`}
@@ -404,9 +422,15 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
404
422
  <ExecutiveRows
405
423
  rows={statement.namedExecutives}
406
424
  width={proseWidth}
425
+ breakdown={!preview}
407
426
  />
408
427
  </Box>
409
428
  )}
429
+ {preview && (
430
+ <Box marginTop={1}>
431
+ <UpgradeLabel text="Upgrade for the pay breakdown and earlier years" onPress={openUpgrade} role="exec-upgrade" />
432
+ </Box>
433
+ )}
410
434
  {statement.highlights && (
411
435
  <Box flexDirection="column">
412
436
  <SectionHeading marginTop={1} title="What Changed" />
@@ -110,10 +110,10 @@ export async function loadMarketMoverTab(
110
110
  return { ...result, quotes: quotes.slice(0, LIST_ROWS), rankedBy, tab };
111
111
  }
112
112
 
113
- /** The server refused the list for the account's plan. */
113
+ /** The server refused the list for the account's plan (gaps, without Pro). */
114
114
  export class SessionMoversAccessError extends Error {
115
115
  constructor() {
116
- super("Pre-market, after-hours and gap lists need Pro");
116
+ super("Gap lists need Pro");
117
117
  this.name = "SessionMoversAccessError";
118
118
  }
119
119
  }
@@ -8,7 +8,6 @@ import type { PluginModule } from "../plugin-module";
8
8
  import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
9
9
  import { publicTickerKey } from "../../../utils/exchanges";
10
10
  import { usePaneSettingValue } from "../../../state/app/context";
11
- import { usePlanAccess } from "../../../api-client/plan-access";
12
11
  import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime";
13
12
  import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
14
13
  import { useAutoRefresh } from "../../../react/auto-refresh";
@@ -87,7 +86,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
87
86
  const [savedTab, setSavedTab] = usePluginPaneState<TabId>("activeTab", tabs[0]!.id);
88
87
  const [pickedIn, setPickedIn] = usePluginPaneState<string | null>("activeTabSession", null);
89
88
  const [requestedTab, setRequestedTab] = usePluginPaneState<TabId | null>("requestedTab", null);
90
- const access = usePlanAccess();
91
89
  const session = useUsSession();
92
90
  const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
93
91
  const activeTab = resolveActiveTab({
@@ -96,7 +94,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
96
94
  pickedIn,
97
95
  session,
98
96
  requested: requestedTab,
99
- sessionListsOpen: access.signedIn && access.emailVerified && access.hasProAccess,
100
97
  });
101
98
 
102
99
  // The index summary is a quote board like any other, so it runs on the shared
@@ -24,6 +24,7 @@ import { summaryFooterSegments } from "./footer";
24
24
  import type { MarketSummaryQuote } from "./screener";
25
25
  import {
26
26
  earlierSessionLabel,
27
+ listAsOfLabel,
27
28
  overlaySessionMovers,
28
29
  resolveSide,
29
30
  sessionListIsLive,
@@ -46,7 +47,13 @@ interface LoadedList {
46
47
  payload: CloudSessionMoversPayload;
47
48
  }
48
49
 
49
- /** Pre-market, after-hours and gap lists: Pro only, like the ranked lists they sit beside. */
50
+ /**
51
+ * Pre-market, after-hours and gap lists. Pre-market and after hours open on
52
+ * every plan: without Pro the server answers with the list as it was built a
53
+ * delay before (`access: "delayed"`), as old as the account's quotes, and the
54
+ * footer gives that time. Gaps are Pro only, like the ranked lists they sit
55
+ * beside.
56
+ */
50
57
  export function SessionMoversBody(props: {
51
58
  view: CloudSessionMoversCategory;
52
59
  session: UsSession;
@@ -57,29 +64,31 @@ export function SessionMoversBody(props: {
57
64
  liveStreaming: boolean;
58
65
  }) {
59
66
  const access = usePlanAccess();
67
+ const proOnly = props.view === "gaps";
60
68
  const [denied, setDenied] = useState(false);
61
69
  const deny = useCallback(() => setDenied(true), []);
62
70
  // A refusal is about the plan the account had then; a new plan asks again.
63
71
  useEffect(() => setDenied(false), [access.signedIn, access.emailVerified, access.hasProAccess]);
64
- const entitled = access.emailVerified && access.hasProAccess && !denied;
72
+ const entitled = !proOnly || (access.emailVerified && access.hasProAccess && !denied);
65
73
  // Behind a wall the footer still carries the index summary; the list registers its own.
66
74
  usePaneFooter("market-movers-wall", () => (
67
75
  entitled ? null : { info: summaryFooterSegments(props.summaryQuotes) }
68
76
  ), [entitled, props.summaryQuotes]);
69
77
 
70
- if (!access.signedIn) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see pre-market, after-hours and gap movers" />;
71
- if (!access.emailVerified) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see pre-market, after-hours and gap movers" needsVerification />;
78
+ if (proOnly && !access.signedIn) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see gap movers" />;
79
+ if (proOnly && !access.emailVerified) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see gap movers" needsVerification />;
72
80
  if (!entitled) {
73
81
  return (
74
82
  <ProWall
75
83
  placement="most-wall"
76
84
  width={props.width} height={props.height}
77
- title="Pre-market, after-hours and gap movers are part of Gloom Cloud Pro."
78
- message="The whole listed US market from 04:00 to 20:00, with relative volume, VWAP, float and catalysts."
85
+ title="Gap movers are part of Gloom Cloud Pro."
86
+ message="Every listed US stock that opened away from its prior close, with relative volume, VWAP, float and catalysts."
79
87
  />
80
88
  );
81
89
  }
82
- return <SessionMoversTable {...props} onDenied={deny} />;
90
+ // A new plan reads the list again: live with Pro, a delay before without.
91
+ return <SessionMoversTable key={access.hasProAccess ? "pro" : "free"} {...props} onDenied={proOnly ? deny : undefined} />;
83
92
  }
84
93
 
85
94
  function SessionMoversTable({ view, session, focused, width, summaryQuotes, liveStreaming, onDenied }: {
@@ -89,7 +98,8 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
89
98
  width: number;
90
99
  summaryQuotes: MarketSummaryQuote[];
91
100
  liveStreaming: boolean;
92
- onDenied: () => void;
101
+ /** Gaps: the server refused the list for the account's plan. */
102
+ onDenied?: () => void;
93
103
  }) {
94
104
  const { pinTicker } = usePluginTickerActions();
95
105
  const [savedSide, setSavedSide] = usePluginPaneState<CloudSessionMoversSide>("sessionSide", "up");
@@ -106,7 +116,10 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
106
116
 
107
117
  const payload = loaded?.key === listKey ? loaded.payload : null;
108
118
  const listRows = payload?.items ?? NO_ROWS;
109
- const live = !!payload && sessionListIsLive(view, payload.session, session);
119
+ // A free account's list is the one built a delay before: no live prices on its rows.
120
+ const delayed = payload?.access === "delayed";
121
+ const sessionTrading = !!payload && sessionListIsLive(view, payload.session, session);
122
+ const live = sessionTrading && !delayed;
110
123
  // While the list's session trades it is rebuilt every minute, and a failed or
111
124
  // stale answer is asked for again as often; a finished list does not change,
112
125
  // so it follows the refresh interval the user set.
@@ -126,7 +139,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
126
139
  setLastLoadedAt(Date.now());
127
140
  } catch (error) {
128
141
  if (fetchGenRef.current !== gen) return;
129
- if (error instanceof SessionMoversAccessError) {
142
+ if (error instanceof SessionMoversAccessError && onDenied) {
130
143
  onDenied();
131
144
  return;
132
145
  }
@@ -172,11 +185,14 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
172
185
  }, [rows, selectedId]);
173
186
 
174
187
  const sessionLabel = payload ? earlierSessionLabel(payload.session, session) : null;
188
+ // While its session trades, a free list says when it was built; a finished one is final and its date says enough.
189
+ const asOfLabel = payload && delayed && sessionTrading ? listAsOfLabel(payload.asOf, payload.session, session) : null;
175
190
  usePaneFooter("market-movers", () => ({
176
191
  info: [
177
192
  ...(loadError ? [{ id: "load-error", parts: [{ text: loadError, tone: "warning" as const }] }] : []),
178
193
  ...summaryFooterSegments(summaryQuotes),
179
194
  ...(sessionLabel ? [{ id: "session", parts: [{ text: sessionLabel, tone: "muted" as const }] }] : []),
195
+ ...(asOfLabel ? [{ id: "as-of", asOf: true, parts: [{ text: asOfLabel, tone: "muted" as const }] }] : []),
180
196
  ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
181
197
  ...(feedStatus ? [{
182
198
  id: "feed",
@@ -184,7 +200,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
184
200
  }] : []),
185
201
  ...(payload?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "muted" as const }] }] : []),
186
202
  ],
187
- }), [feedStatus, loadError, loading, payload?.stale, sessionLabel, summaryQuotes]);
203
+ }), [asOfLabel, feedStatus, loadError, loading, payload?.stale, sessionLabel, summaryQuotes]);
188
204
 
189
205
  const openRow = useCallback((row: SessionMoverRow) => {
190
206
  pinTicker(rowKey(row), { floating: true, paneType: TICKER_RESEARCH_PANE_ID, instrument: null });
@@ -11,7 +11,7 @@ import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors"
11
11
  import type { QueryEntry } from "../../../market-data/result-types";
12
12
  import type { Quote } from "../../../types/financials";
13
13
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
14
- import { zonedDateKey, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
14
+ import { zonedDateKey, zonedDateTimeParts, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
15
15
  import type { SessionTabId } from "./model";
16
16
 
17
17
  const NEW_YORK = "America/New_York";
@@ -67,9 +67,8 @@ export function usSessionAt(now: number): UsSession {
67
67
  * stays while the session it was picked in lasts; otherwise the pane follows
68
68
  * the session that is trading: Pre-market before 09:30, the day's lists during
69
69
  * the regular session, After hours after the close. Overnight and on closed
70
- * days the last pick stands. An account that cannot open the session lists
71
- * (signed out, unverified, free) keeps the day's lists instead of being moved
72
- * onto a wall.
70
+ * days the last pick stands. Every plan opens the two lists it follows, a
71
+ * free one as of a delay before (session-body.tsx).
73
72
  */
74
73
  export function resolveActiveTab<T extends string>(args: {
75
74
  tabs: readonly T[];
@@ -77,14 +76,13 @@ export function resolveActiveTab<T extends string>(args: {
77
76
  pickedIn: string | null | undefined;
78
77
  session: UsSession;
79
78
  requested?: string | null;
80
- sessionListsOpen: boolean;
81
79
  }): T {
82
80
  const { tabs, session } = args;
83
81
  const requested = tabs.find((tab) => tab === args.requested);
84
82
  if (requested) return requested;
85
83
  const saved = tabs.find((tab) => tab === args.saved) ?? tabs[0]!;
86
84
  if (session.phase === "closed" || args.pickedIn === session.key) return saved;
87
- if (session.phase === "regular" || !args.sessionListsOpen) {
85
+ if (session.phase === "regular") {
88
86
  return REGULAR_TABS.has(saved) ? saved : tabs.find((tab) => REGULAR_TABS.has(tab)) ?? saved;
89
87
  }
90
88
  const sessionTab = session.phase === "pre" ? "premarket" : "afterhours";
@@ -223,11 +221,28 @@ export function sortSessionRows(rows: SessionMoverRow[], preference: SessionMove
223
221
  return [...rows].sort((left, right) => compareSortValues(sortValue(columnId, left), sortValue(columnId, right), preference.direction));
224
222
  }
225
223
 
224
+ function shortDay(date: string): string | null {
225
+ const day = new Date(`${date}T12:00:00Z`);
226
+ return Number.isFinite(day.getTime())
227
+ ? day.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
228
+ : null;
229
+ }
230
+
226
231
  /** The session date when it is not today's, e.g. last evening's after-hours list. */
227
232
  export function earlierSessionLabel(listSession: string | null, session: UsSession): string | null {
228
233
  if (!listSession || listSession === session.date) return null;
229
- const date = new Date(`${listSession}T12:00:00Z`);
230
- return Number.isFinite(date.getTime())
231
- ? date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
232
- : null;
234
+ return shortDay(listSession);
235
+ }
236
+
237
+ /**
238
+ * When a free account's list was built, New York time: "as of 07:42 ET", and
239
+ * "as of Oct 19 19:58 ET" when that is not on the list's own session day.
240
+ */
241
+ export function listAsOfLabel(asOf: string, listSession: string | null, session: UsSession): string | null {
242
+ const at = Date.parse(asOf);
243
+ if (!Number.isFinite(at)) return null;
244
+ const { hour, minute } = zonedDateTimeParts(at, NEW_YORK);
245
+ const clock = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ET`;
246
+ const day = zonedDateKey(at, NEW_YORK);
247
+ return day === (listSession ?? session.date) ? `as of ${clock}` : `as of ${shortDay(day) ?? day} ${clock}`;
233
248
  }
@@ -1,4 +1,5 @@
1
1
  import type { PluginConfigState } from "../../../../types/plugin";
2
+ import { logFailure } from "../../../../utils/log-failure";
2
3
  import { DEFAULT_FEEDS } from "./default-feeds";
3
4
  import { hashString } from "./hash";
4
5
  import type { RssFeedConfig } from "./rss/parser";
@@ -30,7 +31,8 @@ function parseJsonArray(value: unknown): unknown[] {
30
31
  try {
31
32
  const parsed = JSON.parse(value);
32
33
  return Array.isArray(parsed) ? parsed : [];
33
- } catch {
34
+ } catch (error) {
35
+ logFailure("news.feed-config", "Read stored news feed settings failed")(error);
34
36
  return [];
35
37
  }
36
38
  }
@@ -93,6 +95,7 @@ function normalizeUserFeed(value: unknown): RssFeedConfig | null {
93
95
  enabled: record.enabled !== false,
94
96
  });
95
97
  } catch {
98
+ // Intentional ignore: invalid stored feed entries are omitted during normalization.
96
99
  return null;
97
100
  }
98
101
  }
@@ -31,6 +31,7 @@ const isWebUrl = (value: unknown): value is string => {
31
31
  const url = new URL(value);
32
32
  return url.protocol === "https:" || url.protocol === "http:";
33
33
  } catch {
34
+ // Intentional ignore: malformed links fail letter validation like other invalid fields.
34
35
  return false;
35
36
  }
36
37
  };
@@ -8,6 +8,7 @@ import type {
8
8
  GloomPluginContext,
9
9
  } from "../../../../../types/plugin";
10
10
  import { formatFeedTime } from "../../../../../utils/datetime-format";
11
+ import { logFailure } from "../../../../../utils/log-failure";
11
12
  import { openNewsStoryPane } from "./pop-out";
12
13
  import { NEWS_QUERY_PRESETS } from "./query-presets";
13
14
 
@@ -75,9 +76,11 @@ let warming: Promise<unknown> | null = null;
75
76
  */
76
77
  async function warmLatestStories(service: NewsService): Promise<void> {
77
78
  if (service.getQueryState(NEWS_QUERY_PRESETS.feed).articles.length > 0) return;
78
- warming ??= service.load(NEWS_QUERY_PRESETS.feed).catch(() => null).finally(() => {
79
- warming = null;
80
- });
79
+ warming ??= service.load(NEWS_QUERY_PRESETS.feed)
80
+ .catch(logFailure("news.command-bar-search", "Load news for command bar search failed", null))
81
+ .finally(() => {
82
+ warming = null;
83
+ });
81
84
  await warming;
82
85
  }
83
86
 
@@ -14,6 +14,7 @@ import { useInlineTickers } from "../../../../../state/hooks/inline-tickers";
14
14
  import { truncateToDisplayWidth } from "../../../../../utils/format";
15
15
  import { wrapTextLines } from "../../../../../utils/text-wrap";
16
16
  import { formatDetailDate } from "../../../../../utils/datetime-format";
17
+ import { logFailure } from "../../../../../utils/log-failure";
17
18
  import { mergeNewsArticle } from "../../../../../news/news-model";
18
19
  import { usePluginPaneState } from "../../../../runtime";
19
20
  import { formatNewsCategory } from "../categories";
@@ -277,7 +278,7 @@ export function NewsDetailView({ item, focused, width, showTitle = true }: {
277
278
  }).then((choice) => {
278
279
  const url = choice ? coverage[Number(choice)]?.url : undefined;
279
280
  if (url) void rendererHost.openExternal(url);
280
- }).catch(() => {});
281
+ }).catch(logFailure("news.detail-view", "Choose news coverage failed"));
281
282
  }, [coverage, dialog, rendererHost]);
282
283
  usePaneFooter("news-detail:story", () => ({
283
284
  order: -1,
@@ -15,6 +15,7 @@ import { colors } from "../../../../../theme/colors";
15
15
  import { collectNewsDisplayTickers } from "../../../../../news/ticker-symbols";
16
16
  import { useLoadNewsStory } from "../../../../../news/hooks";
17
17
  import { formatFeedTime } from "../../../../../utils/datetime-format";
18
+ import { logFailure } from "../../../../../utils/log-failure";
18
19
  import { nextHeaderSort } from "../../../../../utils/sort-values";
19
20
  import { formatNewsCategory } from "../categories";
20
21
  import { useOpenTickerChoice } from "../../../shared/ticker-choice";
@@ -270,7 +271,7 @@ export function NewsArticleStackView({
270
271
  const loadNewsStory = useLoadNewsStory();
271
272
  const prefetchStory = useCallback((article: MarketNewsItem) => {
272
273
  if ((article.items?.length ?? 0) > 0) return;
273
- void loadNewsStory(article.id).catch(() => {});
274
+ void loadNewsStory(article.id).catch(logFailure("news.table", "Prefetch news story failed"));
274
275
  }, [loadNewsStory]);
275
276
 
276
277
  // A crosshair pinned in the tab picks the newest story out by then, and
@@ -14,7 +14,12 @@ export interface FeedElement {
14
14
 
15
15
  export function resolveFeedUrl(value: string, base: string): string {
16
16
  if (!value.trim()) return "";
17
- try { return new URL(value.trim(), base).href; } catch { return ""; }
17
+ try {
18
+ return new URL(value.trim(), base).href;
19
+ } catch {
20
+ // Intentional ignore: malformed optional feed links are treated as absent.
21
+ return "";
22
+ }
18
23
  }
19
24
 
20
25
  // Read the structure of a syndication document without executing declarations or
@@ -14,6 +14,7 @@ export function isManagedNewsFeedUrl(value: string): boolean {
14
14
  const url = new URL(value);
15
15
  return url.origin === "https://api.gloom.sh" && url.pathname.replace(/\/$/, "") === "/news/rss";
16
16
  } catch {
17
+ // Intentional ignore: malformed URLs cannot identify the managed news feed.
17
18
  return false;
18
19
  }
19
20
  }
@@ -1,5 +1,6 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import { withCliServices, withConfigData } from "../../../cli/scoped-context";
3
+ import type { CliServicesContext } from "../../../cli/types";
3
4
  import { CLI_COMMAND_GROUPS } from "../../../cli/help";
4
5
  import { dryRunNote } from "../../../cli/helpers";
5
6
  import { requireArg, takeOption } from "../../../cli/commands/command-utils";
@@ -7,9 +8,10 @@ import { EXCHANGE_OPTION, listingReadNotes, requireListingArg } from "../../../c
7
8
  import { createPluginPersistence } from "../../plugin-persistence";
8
9
  import type { CliCommandDef } from "../../../types/plugin";
9
10
  import { cliStyles } from "../../../utils/cli-output";
11
+ import { errorMessage } from "../../../utils/errors";
10
12
  import { logFailure } from "../../../utils/log-failure";
11
13
  import { readChatSessionState } from "../chat/controller/persistence";
12
- import { exportNotesToDirectory, type NotesExportSource } from "./export";
14
+ import { describeExportSkip, exportNotesToDirectory, type NotesExportSource } from "./export";
13
15
  import { NotesFiles } from "./files";
14
16
  import { CloudNotesStore } from "./store";
15
17
 
@@ -42,29 +44,52 @@ export const notesCliCommand: CliCommandDef = {
42
44
  // (personal and every team), one folder each.
43
45
  const dir = args[1] ?? `${config.dataDir}/notes-export-${new Date().toISOString().slice(0, 10)}`;
44
46
  const sources: NotesExportSource[] = [{ label: "local", store: notes }];
45
- await withCliServices(ctx, async (services) => {
47
+ // A Cloud source left out or unreadable is a warning, as `quote` reports
48
+ // a symbol it found nothing for: the local files are always written.
49
+ const warnings: string[] = [];
50
+ const leftOut = (what: string, failure: string) => (error: unknown) => {
51
+ logFailure("notes.cli", failure)(error);
52
+ warnings.push(`${what} (${errorMessage(error)}).`);
53
+ return null;
54
+ };
55
+ const addCloudSources = async (services: CliServicesContext) => {
46
56
  const cloudPersistence = createPluginPersistence(services.persistence.pluginState, services.persistence.resources, "plugin:gloomberb-cloud", "gloomberb-cloud");
47
57
  const session = readChatSessionState(cloudPersistence, null);
48
58
  if (!session?.sessionToken) return;
49
59
  apiClient.setSessionToken(session.sessionToken);
50
60
  const user = await apiClient.ensureVerifiedSession()
51
- .catch(logFailure("notes.cli", "Verify the saved session for the notes export failed", null));
61
+ .catch(leftOut("Skipped Gloom Cloud notes: the saved session could not be checked", "Verify the saved session for the notes export failed"));
52
62
  if (!user) return;
53
63
  const notesPersistence = createPluginPersistence(services.persistence.pluginState, services.persistence.resources, "plugin:notes", "notes");
54
64
  sources.push({ label: "mine", store: new CloudNotesStore({ kind: "user" }, notesPersistence) });
55
- const teams = await apiClient.listTeams().catch(logFailure("notes.cli", "List teams for the notes export failed", []));
65
+ const teams = await apiClient.listTeams()
66
+ .catch(leftOut("Skipped team notes: the team list could not be read", "List teams for the notes export failed")) ?? [];
56
67
  for (const team of teams) {
57
68
  sources.push({ label: team.name, store: new CloudNotesStore({ kind: "team", teamId: team.id }, notesPersistence) });
58
69
  }
70
+ };
71
+ // The Cloud stores read and cache through these services, so the
72
+ // export runs before they close.
73
+ const result = await withCliServices(ctx, async (services) => {
74
+ await addCloudSources(services);
75
+ return ctx.cliOptions.dryRun ? null : await exportNotesToDirectory(dir, sources);
59
76
  });
60
- if (ctx.cliOptions.dryRun) {
61
- ctx.printResult({ data: { dryRun: true, dir, sources: sources.map((source) => source.label) } }, {
77
+ if (!result) {
78
+ ctx.printResult({ data: { dryRun: true, dir, sources: sources.map((source) => source.label) }, ...(warnings.length > 0 ? { warnings } : {}) }, {
62
79
  text: (data) => `Would export ${data.sources.join(", ")} notes to ${data.dir}.${dryRunNote(true)}`,
63
80
  });
64
81
  return;
65
82
  }
66
- const result = await exportNotesToDirectory(dir, sources);
67
- ctx.printResult({ data: { dir: result.dir, files: result.files, sources: sources.map((source) => source.label) } }, {
83
+ warnings.push(...result.skipped.map(describeExportSkip));
84
+ ctx.printResult({
85
+ data: {
86
+ dir: result.dir,
87
+ files: result.files,
88
+ sources: result.exported,
89
+ ...(result.skipped.length > 0 ? { skipped: result.skipped } : {}),
90
+ },
91
+ ...(warnings.length > 0 ? { warnings } : {}),
92
+ }, {
68
93
  text: (data) => `Exported ${data.files} notes from ${data.sources.join(", ")} to ${data.dir}.`,
69
94
  });
70
95
  return;
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "../../../utils/errors";
1
2
  import type { NoteFileEntry, QuickNoteEntry } from "./files";
2
3
  import type { NotesStore } from "./store";
3
4
  import { noteOwnerKey, splitNoteKey } from "./store";
@@ -7,9 +8,23 @@ export interface NotesExportSource {
7
8
  store: NotesStore;
8
9
  }
9
10
 
11
+ /**
12
+ * What an export left out, and why: a whole source whose notes could not be
13
+ * listed, or with `note`, one note of that source that could not be read.
14
+ */
15
+ export interface NotesExportSkip {
16
+ label: string;
17
+ /** The note's symbol, or a quick note's title. */
18
+ note?: string;
19
+ reason: string;
20
+ }
21
+
10
22
  export interface NotesExportResult {
11
23
  dir: string;
12
24
  files: number;
25
+ /** The sources whose notes were written, in the order given. */
26
+ exported: string[];
27
+ skipped: NotesExportSkip[];
13
28
  }
14
29
 
15
30
  function safeFileName(value: string): string {
@@ -29,16 +44,27 @@ export async function exportNotesToDirectory(
29
44
  const fsModulePath = "fs/promises";
30
45
  const { mkdir, writeFile } = await import(fsModulePath) as typeof import("fs/promises");
31
46
  let files = 0;
47
+ const exported: string[] = [];
48
+ const skipped: NotesExportResult["skipped"] = [];
32
49
  for (const source of sources) {
33
50
  const folder = sources.length > 1 ? `${dir}/${safeFileName(source.label)}` : dir;
34
51
  let entries: NoteFileEntry[];
35
52
  let quick: QuickNoteEntry[];
53
+ const unreadable: Array<{ key: string; error: unknown }> = [];
36
54
  try {
37
- [entries, quick] = await Promise.all([source.store.list(), source.store.loadQuickNotesIndex()]);
38
- } catch {
55
+ [entries, quick] = await Promise.all([
56
+ source.store.list({ strict: true, skipped: (key, error) => unreadable.push({ key, error }) }),
57
+ source.store.loadQuickNotesIndex(),
58
+ ]);
59
+ } catch (error) {
60
+ skipped.push({ label: source.label, reason: errorMessage(error) });
39
61
  continue;
40
62
  }
41
63
  const titles = new Map(quick.map((entry) => [entry.id, entry.title]));
64
+ for (const { key: noteKey, error } of unreadable) {
65
+ const { kind, key } = splitNoteKey(noteKey);
66
+ skipped.push({ label: source.label, note: kind === "quick" ? (titles.get(key) ?? key) : key, reason: errorMessage(error) });
67
+ }
42
68
  for (const entry of entries) {
43
69
  const { kind, key } = splitNoteKey(entry.key);
44
70
  const path = kind === "quick"
@@ -48,8 +74,28 @@ export async function exportNotesToDirectory(
48
74
  await writeFile(path, entry.text, "utf-8");
49
75
  files += 1;
50
76
  }
77
+ exported.push(source.label);
51
78
  }
52
- return { dir, files };
79
+ return { dir, files, exported, skipped };
80
+ }
81
+
82
+ /** One line per skip, as `notes export` warns and the Export Notes command reports. */
83
+ export function describeExportSkip(skip: NotesExportSkip): string {
84
+ return skip.note === undefined
85
+ ? `Skipped ${skip.label}: its notes could not be read (${skip.reason}).`
86
+ : `Skipped the ${skip.label} note ${skip.note}: it could not be read (${skip.reason}).`;
87
+ }
88
+
89
+ /** The Export Notes command's notice: the count, then what was skipped. */
90
+ export function exportNotice(result: NotesExportResult): { body: string; type: "success" | "error" } {
91
+ const exported = `Exported ${result.files} ${result.files === 1 ? "note" : "notes"} to ${result.dir}`;
92
+ if (result.skipped.length === 0) return { body: exported, type: "success" };
93
+ const shown = result.skipped.slice(0, 3).map(describeExportSkip);
94
+ const more = result.skipped.length - shown.length;
95
+ return {
96
+ body: [`${exported}.`, ...shown, ...(more > 0 ? [`${more} more could not be read.`] : [])].join(" "),
97
+ type: "error",
98
+ };
53
99
  }
54
100
 
55
101
  export function exportSourceLabel(store: NotesStore, teamName?: string): string {