gloomberb 0.9.6 → 0.9.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -10,6 +10,7 @@ describe("pane settings helpers", () => {
10
10
 
11
11
  expect(portfolioPane?.settings).toMatchObject({
12
12
  collectionScope: "all",
13
+ viewMode: "table",
13
14
  });
14
15
  expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
15
16
  expect(detailPane?.settings).toEqual({
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDefaultConfig } from "../../../types/config";
3
+ import type { Quote, TickerFinancials } from "../../../types/financials";
3
4
  import type { BrokerAccount } from "../../../types/trading";
4
5
  import type { TickerRecord } from "../../../types/ticker";
5
6
  import type { PortfolioSummaryTotals } from "./metrics";
6
7
  import { buildPortfolioSummarySegments } from "./summary";
7
8
  import { portfolioListPlugin, shouldToggleCashMarginDrawer } from ".";
8
- import { selectStreamTickers } from "./pane/data";
9
+ import { needsVisibleQuoteWatchdogRefresh, selectStreamTickers } from "./pane/data";
10
+ import { buildPortfolioPaneSettingsDef, getPortfolioPaneSettings } from "./settings";
9
11
 
10
12
  function ticker(symbol: string): TickerRecord {
11
13
  return {
@@ -132,6 +134,43 @@ describe("selectStreamTickers", () => {
132
134
  });
133
135
  });
134
136
 
137
+ describe("visible quote refresh predicates", () => {
138
+ function quote(overrides: Partial<Quote> = {}): Quote {
139
+ return {
140
+ symbol: "AAPL",
141
+ price: 125,
142
+ currency: "USD",
143
+ change: 5,
144
+ changePercent: 4.17,
145
+ lastUpdated: 1_700_000_000_000,
146
+ ...overrides,
147
+ };
148
+ }
149
+
150
+ function financials(quoteValue: Quote | undefined): TickerFinancials {
151
+ return {
152
+ annualStatements: [],
153
+ quarterlyStatements: [],
154
+ priceHistory: [],
155
+ quote: quoteValue,
156
+ };
157
+ }
158
+
159
+ test("refreshes visible quotes when the local stream timestamp is too old", () => {
160
+ const now = 1_700_000_120_000;
161
+ expect(needsVisibleQuoteWatchdogRefresh(
162
+ financials(quote({ lastUpdated: now, receivedAt: now - 61_000 })),
163
+ now,
164
+ 60_000,
165
+ )).toBe(true);
166
+ expect(needsVisibleQuoteWatchdogRefresh(
167
+ financials(quote({ lastUpdated: now, receivedAt: now - 10_000 })),
168
+ now,
169
+ 60_000,
170
+ )).toBe(false);
171
+ });
172
+ });
173
+
135
174
  describe("portfolio list pane templates", () => {
136
175
  test("create panes with default all-collection settings", async () => {
137
176
  const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-template");
@@ -154,3 +193,15 @@ describe("portfolio list pane templates", () => {
154
193
  expect(portfolioInstance).toEqual({ params: { collectionId: "main" } });
155
194
  });
156
195
  });
196
+
197
+ describe("portfolio list pane settings", () => {
198
+ test("exposes view mode only for portfolio collections", () => {
199
+ const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-settings");
200
+ const settings = getPortfolioPaneSettings({ viewMode: "grid" });
201
+ const portfolioFields = buildPortfolioPaneSettingsDef(config, settings, "main").fields.map((field) => field.key);
202
+ const watchlistFields = buildPortfolioPaneSettingsDef(config, settings, "watchlist").fields.map((field) => field.key);
203
+
204
+ expect(portfolioFields).toContain("viewMode");
205
+ expect(watchlistFields).not.toContain("viewMode");
206
+ });
207
+ });
@@ -48,6 +48,7 @@ export const portfolioListPlugin: GloomPlugin = {
48
48
  settings: (context) => buildPortfolioPaneSettingsDef(
49
49
  context.config,
50
50
  getPortfolioPaneSettings(context.settings),
51
+ context.activeCollectionId,
51
52
  ),
52
53
  },
53
54
  ],
@@ -5,11 +5,15 @@ import type { TickerFinancials } from "../../../../types/financials";
5
5
  import type { TickerRecord } from "../../../../types/ticker";
6
6
  import type { CollectionSortPreference } from "../../../../state/app/context";
7
7
  import { isQuoteStaleForCurrentSession } from "../../../../market-data/quotes/freshness";
8
+ import { resolveQuoteAgeTimestamp } from "../../../../market-data/quotes/time";
8
9
  import { compareSortValues } from "../../../../utils/sort-values";
9
10
  import { getSortValue, type ColumnContext } from "../metrics";
10
11
  import type { ResolvedPortfolioAccountState } from "../summary";
11
12
 
12
- export const VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS = 5 * 60_000;
13
+ export const VISIBLE_QUOTE_REFRESH_COOLDOWN_MS = 60_000;
14
+ export const VISIBLE_QUOTE_STREAM_MAX_AGE_MS = 60_000;
15
+ export const VISIBLE_QUOTE_STREAM_WATCHDOG_MS = 30_000;
16
+ export const VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS = 5 * 60_000;
13
17
  export const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
14
18
  export const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
15
19
 
@@ -40,6 +44,16 @@ export function needsVisibleQuoteWarmup(financials: TickerFinancials | undefined
40
44
  return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
41
45
  }
42
46
 
47
+ export function needsVisibleQuoteWatchdogRefresh(
48
+ financials: TickerFinancials | undefined,
49
+ now = Date.now(),
50
+ maxAgeMs = VISIBLE_QUOTE_STREAM_MAX_AGE_MS,
51
+ ): boolean {
52
+ if (needsVisibleQuoteWarmup(financials, now)) return true;
53
+ const quoteTimestamp = resolveQuoteAgeTimestamp(financials?.quote, now);
54
+ return quoteTimestamp == null || now - quoteTimestamp >= maxAgeMs;
55
+ }
56
+
43
57
  export function needsVisibleSnapshotWarmup(
44
58
  ticker: TickerRecord,
45
59
  financials: TickerFinancials | undefined,
@@ -690,6 +690,13 @@ describe("PortfolioListPane cash and margin UI", () => {
690
690
  test("renders portfolio grid from portfolio table values and opens the selected ticker", async () => {
691
691
  const portfolioId = "broker:ibkr-flex:DU12345";
692
692
  const config = createPortfolioConfig(portfolioId, [createBrokerInstance("flex")]);
693
+ const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
694
+ if (instance) {
695
+ instance.settings = {
696
+ ...(instance.settings ?? {}),
697
+ viewMode: "grid",
698
+ };
699
+ }
693
700
  const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
694
701
  const runtime = createTestPluginRuntime({
695
702
  pinTicker: (symbol, options) => {
@@ -702,12 +709,6 @@ describe("PortfolioListPane cash and margin UI", () => {
702
709
  config={config}
703
710
  collectionId={portfolioId}
704
711
  runtime={runtime}
705
- stateMutator={(state) => {
706
- state.paneState[TEST_PANE_ID] = {
707
- ...state.paneState[TEST_PANE_ID],
708
- viewMode: "grid",
709
- };
710
- }}
711
712
  />,
712
713
  { width: 100, height: 12 },
713
714
  );
@@ -715,7 +716,6 @@ describe("PortfolioListPane cash and margin UI", () => {
715
716
  await flushFrame();
716
717
 
717
718
  const frame = testSetup.captureCharFrame();
718
- expect(frame).toContain("Grid");
719
719
  expect(frame).toContain("AAPL");
720
720
  expect(frame).toContain("1.3k");
721
721
  expect(frame).toContain("+4.17%");
@@ -728,6 +728,32 @@ describe("PortfolioListPane cash and margin UI", () => {
728
728
  expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: TICKER_RESEARCH_PANE_ID } }]);
729
729
  });
730
730
 
731
+ test("keeps watchlists in table view when grid is saved on the pane", async () => {
732
+ const config = createManualCollectionConfig("watchlist");
733
+ const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
734
+ if (instance) {
735
+ instance.settings = {
736
+ ...(instance.settings ?? {}),
737
+ viewMode: "grid",
738
+ };
739
+ }
740
+
741
+ testSetup = await testRender(
742
+ <PortfolioHarness
743
+ config={config}
744
+ collectionId="watchlist"
745
+ ticker={makeTicker({ portfolios: [], watchlists: ["watchlist"], positions: [] })}
746
+ />,
747
+ { width: 100, height: 12 },
748
+ );
749
+
750
+ await flushFrame();
751
+
752
+ const frame = testSetup.captureCharFrame();
753
+ expect(frame).toContain("TICKER");
754
+ expect(frame).toContain("AAPL");
755
+ });
756
+
731
757
  test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
732
758
  const portfolioId = "broker:ibkr-flex:DU12345";
733
759
  const optionTicker = "SPY 260619C00500000";
@@ -965,6 +991,88 @@ describe("PortfolioListPane cash and margin UI", () => {
965
991
  expect(frame).toContain("22.0");
966
992
  });
967
993
 
994
+ test("force-refreshes stale quote data for visible rows", async () => {
995
+ const config = createPortfolioConfigWithColumns(
996
+ "broker:ibkr-flex:DU12345",
997
+ ["ticker", "price", "day_pnl"],
998
+ [createBrokerInstance("flex")],
999
+ );
1000
+ const staleQuote = makeQuote({
1001
+ price: 120,
1002
+ change: 0,
1003
+ changePercent: 0,
1004
+ marketState: "PRE",
1005
+ lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
1006
+ });
1007
+ const batchOptions: Array<{ forceRefresh?: boolean } | undefined> = [];
1008
+ const provider: DataProvider = {
1009
+ id: "test-provider",
1010
+ name: "Test Provider",
1011
+ async getTickerFinancials() {
1012
+ return {
1013
+ annualStatements: [],
1014
+ quarterlyStatements: [],
1015
+ priceHistory: [],
1016
+ quote: staleQuote,
1017
+ };
1018
+ },
1019
+ async getQuotesBatch(targets, options) {
1020
+ batchOptions.push(options);
1021
+ return targets.map((target) => ({
1022
+ target,
1023
+ quote: makeQuote({
1024
+ symbol: target.symbol,
1025
+ price: 126,
1026
+ change: 6,
1027
+ changePercent: 5,
1028
+ marketState: "PRE",
1029
+ preMarketPrice: 126,
1030
+ preMarketChange: 6,
1031
+ preMarketChangePercent: 5,
1032
+ }),
1033
+ }));
1034
+ },
1035
+ async getQuote(symbol) {
1036
+ return makeQuote({ symbol, price: 126 });
1037
+ },
1038
+ async getExchangeRate() {
1039
+ return 1;
1040
+ },
1041
+ async search() {
1042
+ return [];
1043
+ },
1044
+ async getArticleSummary() {
1045
+ return null;
1046
+ },
1047
+ async getPriceHistory() {
1048
+ return [];
1049
+ },
1050
+ subscribeQuotes() {
1051
+ return () => {};
1052
+ },
1053
+ };
1054
+ sharedCoordinator = new MarketDataCoordinator(provider);
1055
+ setSharedMarketDataCoordinator(sharedCoordinator);
1056
+
1057
+ testSetup = await testRender(
1058
+ <PortfolioHarness
1059
+ config={config}
1060
+ collectionId="broker:ibkr-flex:DU12345"
1061
+ quote={staleQuote}
1062
+ />,
1063
+ { width: 100, height: 12 },
1064
+ );
1065
+
1066
+ await flushFrame();
1067
+ await act(async () => {
1068
+ await new Promise((resolve) => setTimeout(resolve, 380));
1069
+ });
1070
+ await flushFrame();
1071
+
1072
+ expect(batchOptions.some((options) => options?.forceRefresh === true)).toBe(true);
1073
+ expect(testSetup.captureCharFrame()).toContain("126");
1074
+ });
1075
+
968
1076
  test("shows cached market cap on reopen for broker-linked rows", async () => {
969
1077
  const dbPath = createTempDbPath("cached-reopen-market-cap");
970
1078
  const persistence = new AppPersistence(dbPath);
@@ -13,6 +13,7 @@ import {
13
13
  useAppSelector,
14
14
  usePaneCollection,
15
15
  usePaneInstance,
16
+ usePaneSettingValue,
16
17
  usePaneStateValue,
17
18
  type CollectionSortPreference,
18
19
  } from "../../../../state/app/context";
@@ -33,6 +34,7 @@ import {
33
34
  resolveActiveCollectionId,
34
35
  resolveScopedCollectionEntries,
35
36
  resolveVisibleColumns,
37
+ type PortfolioViewMode,
36
38
  } from "../settings";
37
39
  import { useQuoteFlashMap } from "../../../../components/quote-flash";
38
40
  import { PortfolioTickerTable } from "../table";
@@ -50,8 +52,6 @@ import {
50
52
  import { usePortfolioPaneStreaming } from "./streaming";
51
53
  import { usePortfolioSupplementalData } from "./supplemental";
52
54
 
53
- type PortfolioViewMode = "table" | "grid";
54
-
55
55
  export function PortfolioListPane({ focused, width, height }: PaneProps) {
56
56
  const { pinTicker } = usePluginTickerActions();
57
57
  const paneInstance = usePaneInstance();
@@ -68,7 +68,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
68
68
  const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
69
69
  const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
70
70
  const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
71
- const [viewMode, setViewMode] = usePaneStateValue<PortfolioViewMode>("viewMode", "table");
71
+ const [, setViewMode] = usePaneSettingValue<PortfolioViewMode>("viewMode", "table");
72
72
 
73
73
  const [now, setNow] = useState(Date.now());
74
74
  const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
@@ -101,6 +101,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
101
101
  ? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
102
102
  : null
103
103
  ), [activeCollectionId, config.portfolios, isPortfolioTab]);
104
+ const viewMode: PortfolioViewMode = isPortfolioTab ? paneSettings.viewMode : "table";
104
105
 
105
106
  const tickers = useMemo(
106
107
  () => getCollectionTickersFromConfig(config, tickersBySymbol, activeCollectionId),
@@ -178,8 +179,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
178
179
  ? (cashDrawerExpanded ? Math.min(6, Math.max(3, 2 + accountState.visibleCashBalances.length)) : 1)
179
180
  : 0;
180
181
  const showCollectionTabs = visibleCollections.length > 1;
181
- const viewTabsHeight = 1;
182
- const headerHeight = viewTabsHeight + (showCollectionTabs ? 1 : 0);
182
+ const headerHeight = showCollectionTabs ? 1 : 0;
183
183
  const drawerHeight = showCashDrawer
184
184
  ? Math.min(requestedDrawerHeight, Math.max(1, height - (headerHeight + 2)))
185
185
  : 0;
@@ -220,8 +220,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
220
220
  }, [pinTicker]);
221
221
 
222
222
  const toggleViewMode = useCallback(() => {
223
+ if (!isPortfolioTab) return;
223
224
  setViewMode((current) => current === "table" ? "grid" : "table");
224
- }, [setViewMode]);
225
+ }, [isPortfolioTab, setViewMode]);
225
226
 
226
227
  const handleRowActivate = useCallback((ticker: TickerRecord) => {
227
228
  flushCursorSymbol(ticker.metadata.ticker);
@@ -254,7 +255,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
254
255
  return true;
255
256
  }
256
257
 
257
- if (key === "s") {
258
+ if (key === "s" && isPortfolioTab) {
258
259
  event.preventDefault?.();
259
260
  event.stopPropagation?.();
260
261
  toggleViewMode();
@@ -265,6 +266,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
265
266
  }, [
266
267
  cashDrawerExpanded,
267
268
  focused,
269
+ isPortfolioTab,
268
270
  openTickerFloating,
269
271
  safeSelectedIdx,
270
272
  setCashDrawerExpanded,
@@ -379,25 +381,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
379
381
 
380
382
  return (
381
383
  <Box flexDirection="column" width={width} height={height}>
382
- <Box flexDirection="column" height={headerHeight}>
383
- <Box flexDirection="row" height={1}>
384
- <Box flexShrink={1} overflow="hidden">
385
- <Tabs
386
- tabs={[
387
- { label: "Table", value: "table" },
388
- { label: "Grid", value: "grid" },
389
- ]}
390
- activeValue={viewMode}
391
- onSelect={(value) => setViewMode(value as PortfolioViewMode)}
392
- compact
393
- variant="bare"
394
- focused={focused && !quickAddFocused}
395
- keyboardNavigation={false}
396
- />
397
- </Box>
398
- </Box>
399
-
400
- {showCollectionTabs && (
384
+ {showCollectionTabs && (
385
+ <Box flexDirection="column" height={headerHeight}>
401
386
  <Box flexDirection="row" height={1}>
402
387
  <Box flexShrink={1} overflow="hidden">
403
388
  <Tabs
@@ -409,8 +394,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
409
394
  />
410
395
  </Box>
411
396
  </Box>
412
- )}
413
- </Box>
397
+ </Box>
398
+ )}
414
399
 
415
400
  {viewMode === "table" ? (
416
401
  <PortfolioTickerTable
@@ -5,10 +5,13 @@ import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
5
5
  import type { TickerFinancials } from "../../../../types/financials";
6
6
  import type { TickerRecord } from "../../../../types/ticker";
7
7
  import {
8
- VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS,
9
8
  VISIBLE_FINANCIAL_WARMUP_DELAY_MS,
9
+ VISIBLE_QUOTE_REFRESH_COOLDOWN_MS,
10
+ VISIBLE_QUOTE_STREAM_WATCHDOG_MS,
10
11
  VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT,
12
+ VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS,
11
13
  needsVisibleQuoteWarmup,
14
+ needsVisibleQuoteWatchdogRefresh,
12
15
  needsVisibleSnapshotWarmup,
13
16
  selectStreamTickers,
14
17
  visibleWarmupKey,
@@ -90,7 +93,7 @@ export function usePortfolioPaneStreaming({
90
93
  if (
91
94
  needsVisibleQuoteWarmup(financials, nowTimestamp)
92
95
  && !warmupInFlightRef.current.has(quoteKey)
93
- && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
96
+ && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
94
97
  ) {
95
98
  quoteQueue.push(ticker);
96
99
  continue;
@@ -100,7 +103,7 @@ export function usePortfolioPaneStreaming({
100
103
  if (
101
104
  needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
102
105
  && !warmupInFlightRef.current.has(snapshotKey)
103
- && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
106
+ && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS
104
107
  ) {
105
108
  snapshotQueue.push(ticker);
106
109
  }
@@ -130,7 +133,7 @@ export function usePortfolioPaneStreaming({
130
133
  try {
131
134
  await Promise.allSettled([
132
135
  quoteEntries.length > 0
133
- ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
136
+ ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true })
134
137
  : Promise.resolve(),
135
138
  snapshotEntries.length > 0
136
139
  ? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
@@ -155,5 +158,52 @@ export function usePortfolioPaneStreaming({
155
158
  };
156
159
  }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
157
160
 
161
+ useEffect(() => {
162
+ if (!appActive) return;
163
+ if (!focused) return;
164
+ if (!sharedCoordinator) return;
165
+
166
+ let cancelled = false;
167
+ const runWatchdog = async (): Promise<void> => {
168
+ const nowTimestamp = Date.now();
169
+ const quoteEntries = visibleFinancialTickers.flatMap((ticker) => {
170
+ const financials = financialsMap.get(ticker.metadata.ticker);
171
+ const key = visibleWarmupKey("quote", ticker);
172
+ if (
173
+ !needsVisibleQuoteWatchdogRefresh(financials, nowTimestamp)
174
+ || warmupInFlightRef.current.has(key)
175
+ || nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
176
+ ) {
177
+ return [];
178
+ }
179
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
180
+ if (!instrument) return [];
181
+ warmupInFlightRef.current.add(key);
182
+ warmupAttemptRef.current.set(key, nowTimestamp);
183
+ return [{ key, instrument }];
184
+ });
185
+ if (quoteEntries.length === 0) return;
186
+
187
+ try {
188
+ await sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true });
189
+ } catch {
190
+ // Best-effort watchdog for visible rows only.
191
+ } finally {
192
+ for (const entry of quoteEntries) {
193
+ warmupInFlightRef.current.delete(entry.key);
194
+ }
195
+ }
196
+ };
197
+
198
+ const intervalId = setInterval(() => {
199
+ if (!cancelled && mountedRef.current) void runWatchdog();
200
+ }, VISIBLE_QUOTE_STREAM_WATCHDOG_MS);
201
+
202
+ return () => {
203
+ cancelled = true;
204
+ clearInterval(intervalId);
205
+ };
206
+ }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
207
+
158
208
  useQuoteStreaming(streamTargets);
159
209
  }
@@ -3,11 +3,13 @@ import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type Col
3
3
  import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline/view";
4
4
 
5
5
  type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
6
+ export type PortfolioViewMode = "table" | "grid";
6
7
 
7
8
  export interface PortfolioPaneSettings {
8
9
  columnIds: string[];
9
10
  collectionScope: CollectionScope;
10
11
  visibleCollectionIds: string[];
12
+ viewMode: PortfolioViewMode;
11
13
  hideHeader: boolean;
12
14
  hideCash: boolean;
13
15
  }
@@ -95,11 +97,27 @@ const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
95
97
  description: "Choose exactly which collections this pane should show.",
96
98
  },
97
99
  ];
100
+ const VIEW_MODE_OPTIONS: PaneSettingOption[] = [
101
+ {
102
+ value: "table",
103
+ label: "Table",
104
+ description: "Show positions in the portfolio table.",
105
+ },
106
+ {
107
+ value: "grid",
108
+ label: "Grid",
109
+ description: "Show positions in the portfolio grid.",
110
+ },
111
+ ];
98
112
 
99
113
  function isCollectionScope(value: unknown): value is CollectionScope {
100
114
  return value === "all" || value === "portfolios" || value === "watchlists" || value === "custom";
101
115
  }
102
116
 
117
+ function isPortfolioViewMode(value: unknown): value is PortfolioViewMode {
118
+ return value === "table" || value === "grid";
119
+ }
120
+
103
121
  function filterCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
104
122
  switch (settings.collectionScope) {
105
123
  case "portfolios":
@@ -142,6 +160,7 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
142
160
  columnIds: columnIds.length > 0 ? columnIds : DEFAULT_PORTFOLIO_COLUMN_IDS,
143
161
  collectionScope: isCollectionScope(settings?.collectionScope) ? settings.collectionScope : "all",
144
162
  visibleCollectionIds,
163
+ viewMode: isPortfolioViewMode(settings?.viewMode) ? settings.viewMode : "table",
145
164
  hideHeader: settings?.hideHeader === true,
146
165
  hideCash: settings?.hideCash === true,
147
166
  };
@@ -218,9 +237,15 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
218
237
  .filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
219
238
  }
220
239
 
221
- export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: PortfolioPaneSettings): PaneSettingsDef {
240
+ export function buildPortfolioPaneSettingsDef(
241
+ config: AppConfig,
242
+ settings: PortfolioPaneSettings,
243
+ activeCollectionId?: string | null,
244
+ ): PaneSettingsDef {
222
245
  const collectionEntries = getCollectionEntries(config);
223
246
  const allCollectionOptions = resolveCollectionOptions(collectionEntries);
247
+ const activeCollectionIsPortfolio = !!activeCollectionId
248
+ && config.portfolios.some((portfolio) => portfolio.id === activeCollectionId);
224
249
 
225
250
  const fields: PaneSettingsDef["fields"] = [
226
251
  {
@@ -241,6 +266,15 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
241
266
  },
242
267
  ];
243
268
 
269
+ if (activeCollectionIsPortfolio) {
270
+ fields.push({
271
+ key: "viewMode",
272
+ label: "View",
273
+ type: "select",
274
+ options: VIEW_MODE_OPTIONS,
275
+ });
276
+ }
277
+
244
278
  if (settings.collectionScope === "custom") {
245
279
  fields.push({
246
280
  key: "visibleCollectionIds",
@@ -266,6 +300,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
266
300
  ...settings,
267
301
  columnIds: [...settings.columnIds],
268
302
  visibleCollectionIds: [...settings.visibleCollectionIds],
303
+ viewMode: settings.viewMode,
269
304
  },
270
305
  fields,
271
306
  };
@@ -216,6 +216,7 @@ const DEFAULT_HOME_LAYOUT: LayoutConfig = {
216
216
  columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
217
217
  collectionScope: "all",
218
218
  visibleCollectionIds: [],
219
+ viewMode: "table",
219
220
  },
220
221
  binding: { kind: "none" },
221
222
  },
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.9.6";
1
+ export const VERSION = "0.9.7";