1
-"use client";
1
+"use client";
2
3
import {
4
Bell,
18
Sun,
19
TrendingDown,
20
TrendingUp,
21
+ UploadCloud,
22
+ CheckCircle2,
23
WalletCards,
24
X
25
} from "lucide-react";
24
-import { useEffect, useMemo, useState } from "react";
26
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27
import { frontendConfig } from "../config";
28
import {
29
+ type AuthenticatedUser,
30
type ApiFailure,
31
type BrokerConnection,
32
type BrokerProviderInfo,
33
type Portfolio,
34
+ type PortfolioHistory,
35
+ type PortfolioHistoryRange,
36
+ type PortfolioDashboard,
37
type PortfolioListItem,
38
+ type PortfolioImportPreview,
39
type PortfolioPosition,
40
+ type PortfolioResearchCompany,
41
+ type PortfolioResearchSummary,
42
type PortfolioSummary,
34
- type ResearchProfile,
43
+ type ResearchDocument,
44
+ type ResearchEvent,
45
type ResearchSummary,
46
brokerApi,
47
+ authApi,
48
portfolioApi,
49
researchApi
50
} from "../lib/portfolio-api";
53
type View = "dashboard" | "portfolio" | "research" | "brokers" | "settings";
54
type Theme = "system" | "light" | "dark";
55
type SortKey = "company" | "ticker" | "marketValue" | "profitLoss" | "allocation";
56
+type ResearchSectionId = "overview" | "growth" | "orders" | "capex" | "customers" | "guidance" | "news" | "sources";
57
+const portfolioHistoryRanges: PortfolioHistoryRange[] = ["1D", "5D", "1W", "1M", "1Y", "2Y", "3Y", "4Y", "5Y", "MAX"];
58
+const brokerAuthenticationPollMs = 2000;
59
+const brokerAuthenticationTimeoutMs = 5 * 60 * 1000;
60
61
const navItems: Array<{ id: View; label: string; icon: typeof Gauge }> = [
62
{ id: "dashboard", label: "Dashboard", icon: Gauge },
78
}).format(amount);
79
}
80
81
+function formatBackendMoney(money?: { amount: number; currency: string } | null) {
82
+ return formatMoney(money?.amount, money?.currency);
83
+}
84
+
85
function formatPercent(value?: number) {
86
if (value === undefined || Number.isNaN(value)) {
87
return "--";
90
return `${value.toFixed(2)}%`;
91
}
92
93
+function formatChangePercent(start?: number, end?: number) {
94
+ if (!start || end === undefined) {
95
+ return "--";
96
+ }
97
+ return formatPercent(((end - start) / start) * 100);
98
+}
99
+
100
function getAllocationValue(summary: PortfolioSummary | null, position: PortfolioPosition) {
101
if (!summary || summary.totalMarketValue.amount === 0) {
102
return 0;
113
return { message: "The portfolio API is not reachable. Confirm the gateway or portfolio service is running." };
114
}
115
116
+function hasRealBrokerPositions(positions: PortfolioPosition[]) {
117
+ return positions.some((position) => position.dataFreshness === "REAL_BROKER");
118
+}
119
+
120
+function selectedPortfolioStorageKey(userId: string) {
121
+ return `aip.selectedPortfolioId.${userId}`;
122
+}
123
+
124
+function rememberSelectedPortfolioId(userId: string | undefined, portfolioId: string) {
125
+ if (!userId || typeof window === "undefined") {
126
+ return;
127
+ }
128
+ if (portfolioId) {
129
+ window.localStorage.setItem(selectedPortfolioStorageKey(userId), portfolioId);
130
+ } else {
131
+ window.localStorage.removeItem(selectedPortfolioStorageKey(userId));
132
+ }
133
+}
134
+
135
+function storedSelectedPortfolioId(userId: string | undefined) {
136
+ if (!userId || typeof window === "undefined") {
137
+ return "";
138
+ }
139
+ return window.localStorage.getItem(selectedPortfolioStorageKey(userId)) ?? "";
140
+}
141
+
142
+function portfolioSourceLabels(positions: PortfolioPosition[]) {
143
+ if (hasRealBrokerPositions(positions)) {
144
+ return {
145
+ badge: "Real broker data",
146
+ syncMeta: "IBKR broker sync",
147
+ totalProfitLoss: "Total P/L",
148
+ returnLabel: "Return",
149
+ marketValue: "Market value",
150
+ costBasis: "Cost basis",
151
+ unrealizedProfitLoss: "Unrealized P/L",
152
+ lastUpdated: "from latest IBKR sync",
153
+ source: "Interactive Brokers",
154
+ syncButton: "Sync IBKR broker",
155
+ emptyMessage: "Sync a broker account to populate this view.",
156
+ sortMarketValue: "Market value",
157
+ sortProfitLoss: "P/L"
158
+ };
159
+ }
160
+
161
+ return {
162
+ badge: "Demo data",
163
+ syncMeta: "Demo broker sync",
164
+ totalProfitLoss: "Demo total P/L",
165
+ returnLabel: "Demo return",
166
+ marketValue: "Demo market value",
167
+ costBasis: "Demo cost basis",
168
+ unrealizedProfitLoss: "Demo unrealized P/L",
169
+ lastUpdated: "from latest mock sync",
170
+ source: "Broker demo data",
171
+ syncButton: "Sync mock broker",
172
+ emptyMessage: "Sync a mock broker account to populate this view.",
173
+ sortMarketValue: "Demo market value",
174
+ sortProfitLoss: "Demo P/L"
175
+ };
176
+}
177
+
178
export function InvestmentWorkspace() {
179
+ const [accessToken, setAccessToken] = useState<string | null>(() =>
180
+ typeof window === "undefined" ? null : window.localStorage.getItem("aip.accessToken")
181
+ );
182
+ const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(() => {
183
+ if (typeof window === "undefined") {
184
+ return null;
185
+ }
186
+ const storedUser = window.localStorage.getItem("aip.user");
187
+ return storedUser ? (JSON.parse(storedUser) as AuthenticatedUser) : null;
188
+ });
189
+ const [authLoading, setAuthLoading] = useState(false);
190
const [view, setView] = useState<View>("dashboard");
191
const [theme, setTheme] = useState<Theme>("system");
192
const [sidebarOpen, setSidebarOpen] = useState(false);
193
const [portfolios, setPortfolios] = useState<PortfolioListItem[]>([]);
194
+ const [portfolioDashboard, setPortfolioDashboard] = useState<PortfolioDashboard | null>(null);
195
+ const [portfolioScope, setPortfolioScope] = useState<"ALL" | string>("ALL");
196
const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | undefined>();
197
const [selectedPortfolioId, setSelectedPortfolioId] = useState<string>("");
198
const [summary, setSummary] = useState<PortfolioSummary | null>(null);
199
const [positions, setPositions] = useState<PortfolioPosition[]>([]);
200
+ const [portfolioHistory, setPortfolioHistory] = useState<PortfolioHistory | null>(null);
201
+ const [portfolioHistoryRange, setPortfolioHistoryRange] = useState<PortfolioHistoryRange>("1M");
202
+ const [portfolioHistoryLoading, setPortfolioHistoryLoading] = useState(false);
203
const [loading, setLoading] = useState(false);
204
const [syncing, setSyncing] = useState(false);
205
const [creating, setCreating] = useState(false);
211
const [brokerProviders, setBrokerProviders] = useState<BrokerProviderInfo[]>([]);
212
const [brokerConnections, setBrokerConnections] = useState<BrokerConnection[]>([]);
213
const [brokerLoading, setBrokerLoading] = useState(false);
110
- const [researchCompanies, setResearchCompanies] = useState<ResearchProfile[]>([]);
214
+ const [brokerErrors, setBrokerErrors] = useState<Record<string, string>>({});
215
+ const [authenticatingBroker, setAuthenticatingBroker] = useState<string | null>(null);
216
+ const [brokerAuthenticationTimedOut, setBrokerAuthenticationTimedOut] = useState(false);
217
+ const brokerAuthPopupRef = useRef<Window | null>(null);
218
+ const brokerAuthPollRef = useRef<number | null>(null);
219
+ const brokerAuthPollBusyRef = useRef(false);
220
+ const pendingBrokerAuthRef = useRef<{ connectionId: string; provider: string } | null>(null);
221
+ const brokerAuthStartedAtRef = useRef(0);
222
const [selectedResearchInstrumentId, setSelectedResearchInstrumentId] = useState("");
223
const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null);
224
const [researchLoading, setResearchLoading] = useState(false);
225
+ const [portfolioResearchSummary, setPortfolioResearchSummary] = useState<PortfolioResearchSummary | null>(null);
226
+ const [portfolioResearchLoading, setPortfolioResearchLoading] = useState(false);
227
const [researchEventType, setResearchEventType] = useState("");
228
const [researchImpact, setResearchImpact] = useState("");
229
230
+ const clearUserScopedState = useCallback(() => {
231
+ rememberSelectedPortfolioId(authenticatedUser?.userId, "");
232
+ setPortfolios([]);
233
+ setPortfolioDashboard(null);
234
+ setPortfolioScope("ALL");
235
+ setSelectedPortfolio(undefined);
236
+ setSelectedPortfolioId("");
237
+ setSummary(null);
238
+ setPositions([]);
239
+ setPortfolioHistory(null);
240
+ setBrokerConnections([]);
241
+ setBrokerProviders([]);
242
+ setSelectedResearchInstrumentId("");
243
+ setResearchSummary(null);
244
+ setPortfolioResearchSummary(null);
245
+ setSearchText("");
246
+ }, [authenticatedUser?.userId]);
247
+
248
useEffect(() => {
249
document.documentElement.dataset.theme = theme;
250
}, [theme]);
251
252
+ useEffect(() => {
253
+ const params = new URLSearchParams(window.location.search);
254
+ const apiSession = params.get("API_Session") ?? params.get("api_session");
255
+ const requestToken = params.get("request_token");
256
+ const pending = window.localStorage.getItem("aip.pendingBrokerAuthentication");
257
+ if ((!apiSession && !requestToken) || !pending || !accessToken) return;
258
+ const value = JSON.parse(pending) as { connectionId: string; provider: string };
259
+ const completion = apiSession && value.provider === "ICICI_DIRECT"
260
+ ? brokerApi.attachIciciSession(value.connectionId, apiSession)
261
+ : requestToken && value.provider === "HDFC_SECURITIES"
262
+ ? brokerApi.attachHdfcRequestToken(value.connectionId, requestToken)
263
+ : null;
264
+ if (!completion) return;
265
+ void completion.then(() => {
266
+ window.localStorage.removeItem("aip.pendingBrokerAuthentication");
267
+ if (window.opener) window.close();
268
+ else window.history.replaceState({}, "", window.location.pathname);
269
+ }).catch(() => setBrokerErrors({}));
270
+ }, [accessToken]);
271
+
272
+ useEffect(() => {
273
+ function handleUnauthorized() {
274
+ setAccessToken(null);
275
+ setAuthenticatedUser(null);
276
+ clearUserScopedState();
277
+ setError({ message: "Your session expired. Sign in again." });
278
+ }
279
+
280
+ window.addEventListener("aip:unauthorized", handleUnauthorized);
281
+ return () => window.removeEventListener("aip:unauthorized", handleUnauthorized);
282
+ }, [clearUserScopedState]);
283
+
284
+ useEffect(() => {
285
+ function handleBrokerAuthentication(event: MessageEvent) {
286
+ if (event.origin !== window.location.origin || event.data?.type !== "aip:ibkr-authenticated") return;
287
+ const pending = pendingBrokerAuthRef.current;
288
+ if (!pending || pending.provider !== "IBKR") return;
289
+ void finishBrokerAuthentication(pending.connectionId);
290
+ }
291
+ window.addEventListener("message", handleBrokerAuthentication);
292
+ return () => window.removeEventListener("message", handleBrokerAuthentication);
293
+ });
294
+
295
+ useEffect(() => () => stopBrokerAuthenticationMonitoring(false), []);
296
+
297
useEffect(() => {
298
let cancelled = false;
299
301
setLoading(true);
302
setError(null);
303
try {
128
- const loaded = await portfolioApi.listPortfolios();
304
+ const dashboard = await portfolioApi.getDashboard();
305
+ const loaded = dashboard.portfolios;
306
if (cancelled) {
307
return;
308
}
309
setPortfolios(loaded);
133
- setSelectedPortfolioId((current) => current || loaded[0]?.portfolioId || "");
310
+ setPortfolioDashboard(dashboard);
311
+ setSelectedPortfolioId((current) => {
312
+ const stored = storedSelectedPortfolioId(authenticatedUser?.userId);
313
+ const next = current && loaded.some((portfolio) => portfolio.portfolioId === current)
314
+ ? current
315
+ : stored && loaded.some((portfolio) => portfolio.portfolioId === stored)
316
+ ? stored
317
+ : loaded[0]?.portfolioId || "";
318
+ rememberSelectedPortfolioId(authenticatedUser?.userId, next);
319
+ return next;
320
+ });
321
+ if (loaded.length === 0) {
322
+ rememberSelectedPortfolioId(authenticatedUser?.userId, "");
323
+ setSelectedPortfolio(undefined);
324
+ setSummary(null);
325
+ setPositions([]);
326
+ }
327
} catch (err) {
328
if (!cancelled) {
329
setError(getApiFailure(err));
335
}
336
}
337
145
- void loadPortfolios();
338
+ if (accessToken) {
339
+ void loadPortfolios();
340
+ }
341
return () => {
342
cancelled = true;
343
};
149
- }, []);
344
+ }, [accessToken, authenticatedUser?.userId]);
345
346
useEffect(() => {
347
let cancelled = false;
348
154
- async function loadResearchCompanies() {
349
+ async function loadResearchSummary() {
350
+ if (!selectedResearchInstrumentId) {
351
+ setResearchSummary(null);
352
+ return;
353
+ }
354
+ const selectedPortfolioCompany = portfolioResearchSummary?.companies.find(
355
+ (company) => company.instrumentId === selectedResearchInstrumentId
356
+ );
357
+ if (selectedPortfolioCompany && !isResearchSummaryLoadable(selectedPortfolioCompany.status)) {
358
+ setResearchSummary(null);
359
+ setResearchLoading(false);
360
+ return;
361
+ }
362
+ setResearchLoading(true);
363
try {
156
- const companies = await researchApi.listCompanies();
364
+ const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
365
if (!cancelled) {
158
- setResearchCompanies(companies);
159
- setSelectedResearchInstrumentId((current) => current || companies[0]?.instrumentId || "");
366
+ setResearchSummary(loaded);
367
}
368
} catch {
369
if (!cancelled) {
163
- setResearchCompanies([]);
370
+ setResearchSummary(null);
371
+ }
372
+ } finally {
373
+ if (!cancelled) {
374
+ setResearchLoading(false);
375
}
376
}
377
}
378
168
- void loadResearchCompanies();
379
+ void loadResearchSummary();
380
return () => {
381
cancelled = true;
382
};
172
- }, []);
383
+ }, [portfolioResearchSummary, selectedResearchInstrumentId]);
384
385
useEffect(() => {
386
let cancelled = false;
387
177
- async function loadResearchSummary() {
178
- if (!selectedResearchInstrumentId) {
388
+ async function loadPortfolioResearchSummary() {
389
+ if (!selectedPortfolioId) {
390
+ setPortfolioResearchSummary(null);
391
+ setSelectedResearchInstrumentId("");
392
return;
393
}
181
- setResearchLoading(true);
394
+ setPortfolioResearchLoading(true);
395
try {
183
- const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
184
- if (!cancelled) {
185
- setResearchSummary(loaded);
396
+ const loaded = await researchApi.getPortfolioSummary(selectedPortfolioId);
397
+ if (cancelled) {
398
+ return;
399
}
400
+ setPortfolioResearchSummary(loaded);
401
+ setSelectedResearchInstrumentId((current) => {
402
+ if (current && loaded.companies.some((company) => company.instrumentId === current)) {
403
+ return current;
404
+ }
405
+ return loaded.companies.find((company) => company.instrumentId && isResearchSummaryLoadable(company.status))?.instrumentId ?? "";
406
+ });
407
} catch {
408
if (!cancelled) {
189
- setResearchSummary(null);
409
+ setPortfolioResearchSummary(null);
410
+ setSelectedResearchInstrumentId("");
411
}
412
} finally {
413
if (!cancelled) {
193
- setResearchLoading(false);
414
+ setPortfolioResearchLoading(false);
415
}
416
}
417
}
418
198
- void loadResearchSummary();
419
+ void loadPortfolioResearchSummary();
420
return () => {
421
cancelled = true;
422
};
202
- }, [selectedResearchInstrumentId]);
423
+ }, [selectedPortfolioId]);
424
425
useEffect(() => {
426
let cancelled = false;
462
};
463
}, [selectedPortfolioId]);
464
465
+ useEffect(() => {
466
+ let cancelled = false;
467
+
468
+ async function loadPortfolioHistory() {
469
+ if (!selectedPortfolioId) {
470
+ return;
471
+ }
472
+ setPortfolioHistoryLoading(true);
473
+ try {
474
+ const loaded = await portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange);
475
+ if (!cancelled) {
476
+ setPortfolioHistory(loaded);
477
+ }
478
+ } catch {
479
+ if (!cancelled) {
480
+ setPortfolioHistory(null);
481
+ }
482
+ } finally {
483
+ if (!cancelled) {
484
+ setPortfolioHistoryLoading(false);
485
+ }
486
+ }
487
+ }
488
+
489
+ if (accessToken) {
490
+ void loadPortfolioHistory();
491
+ }
492
+ return () => {
493
+ cancelled = true;
494
+ };
495
+ }, [accessToken, selectedPortfolioId, portfolioHistoryRange]);
496
+
497
useEffect(() => {
498
let cancelled = false;
499
517
}
518
}
519
267
- void loadBrokers();
520
+ if (accessToken) {
521
+ void loadBrokers();
522
+ }
523
return () => {
524
cancelled = true;
525
};
271
- }, []);
526
+ }, [accessToken]);
527
528
const sortedPositions = useMemo(() => {
529
const normalizedSearch = searchText.trim().toLowerCase();
533
return true;
534
}
535
return [
536
+ position.displayName,
537
position.instrument.companyName,
538
position.instrument.ticker,
539
position.instrument.isin,
546
.sort((a, b) => {
547
switch (sortKey) {
548
case "company":
293
- return a.instrument.companyName.localeCompare(b.instrument.companyName);
549
+ return a.displayName.localeCompare(b.displayName);
550
case "ticker":
551
return a.instrument.ticker.localeCompare(b.instrument.ticker);
552
case "profitLoss":
560
});
561
}, [positions, searchText, sortKey, summary]);
562
563
+ async function updateHoldingDisplayName(position: PortfolioPosition, customDisplayName: string | null) {
564
+ const updated = await portfolioApi.updateHoldingDisplayName(position.portfolioId, position.positionId, customDisplayName);
565
+ setPositions((current) => current.map((item) => item.positionId === updated.positionId ? updated : item));
566
+ setPortfolioDashboard(await portfolioApi.getDashboard());
567
+ }
568
+
569
async function createPortfolio() {
570
setCreating(true);
571
setError(null);
576
});
577
const loaded = await portfolioApi.listPortfolios();
578
setPortfolios(loaded);
579
+ rememberSelectedPortfolioId(authenticatedUser?.userId, created.portfolioId);
580
setSelectedPortfolioId(created.portfolioId);
581
setView("portfolio");
582
} catch (err) {
607
}
608
}
609
610
+ async function syncSelectedPortfolio() {
611
+ const brokerPortfolio = portfolios.find((portfolio) => portfolio.portfolioId === selectedPortfolioId);
612
+ if (!brokerPortfolio?.brokerConnectionId) {
613
+ return syncPortfolio();
614
+ }
615
+ const connectionId = brokerPortfolio.brokerConnectionId;
616
+ const brokerConnection = brokerConnections.find((connection) => connection.connectionId === connectionId);
617
+ const brokerType = brokerConnection?.brokerType ?? brokerPortfolio.provider ?? "UNKNOWN";
618
+ const provider = brokerProviders.find((candidate) => candidate.brokerType === brokerType);
619
+ setSyncing(true);
620
+ setError(null);
621
+ const authWindow = provider?.consumerAuthMode === "BROKER_REDIRECT"
622
+ ? openBrokerAuthenticationWindow(brokerType)
623
+ : null;
624
+ try {
625
+ const result = await portfolioApi.syncBrokerConnection(connectionId);
626
+ if (result.status === "AUTHENTICATION_REQUIRED") {
627
+ if (provider?.consumerAuthMode === "PARTNER_UNAVAILABLE") {
628
+ setBrokerCardError(brokerType, "Direct customer account connection is not available yet.");
629
+ return;
630
+ }
631
+ await completeBrokerAuthentication(
632
+ authWindow,
633
+ brokerType,
634
+ () => brokerApi.authenticationAction(connectionId)
635
+ );
636
+ return;
637
+ }
638
+ authWindow?.close();
639
+ const dashboard = await portfolioApi.getDashboard();
640
+ const [loadedSummary, loadedPositions] = await Promise.all([
641
+ portfolioApi.getSummary(selectedPortfolioId), portfolioApi.getPositions(selectedPortfolioId)
642
+ ]);
643
+ setPortfolioDashboard(dashboard);
644
+ setPortfolios(dashboard.portfolios);
645
+ setSummary(loadedSummary);
646
+ setPositions(loadedPositions);
647
+ } catch (err) {
648
+ authWindow?.close();
649
+ setError(getApiFailure(err));
650
+ } finally {
651
+ setSyncing(false);
652
+ }
653
+ }
654
+
655
+ function setBrokerCardError(brokerType: string, message: string | null) {
656
+ setBrokerErrors((current) => {
657
+ const next = { ...current };
658
+ if (message) next[brokerType] = message;
659
+ else delete next[brokerType];
660
+ return next;
661
+ });
662
+ }
663
+
664
+ function openBrokerAuthenticationWindow(brokerType: string): Window | null {
665
+ if (brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
666
+ brokerAuthPopupRef.current.focus();
667
+ return null;
668
+ }
669
+ const width = 620;
670
+ const height = 760;
671
+ const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
672
+ const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
673
+ const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
674
+ const authWindow = window.open("about:blank", "aip-ibkr-auth", features);
675
+ if (!authWindow) {
676
+ setBrokerCardError(brokerType, "Your browser blocked the IBKR sign-in window. Allow pop-ups for this site and try again.");
677
+ return null;
678
+ }
679
+ brokerAuthPopupRef.current = authWindow;
680
+ return authWindow;
681
+ }
682
+
683
+ function stopBrokerAuthenticationMonitoring(closePopup: boolean) {
684
+ if (brokerAuthPollRef.current !== null) {
685
+ window.clearInterval(brokerAuthPollRef.current);
686
+ brokerAuthPollRef.current = null;
687
+ }
688
+ if (closePopup && brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
689
+ brokerAuthPopupRef.current.close();
690
+ }
691
+ brokerAuthPopupRef.current = null;
692
+ brokerAuthPollBusyRef.current = false;
693
+ pendingBrokerAuthRef.current = null;
694
+ brokerAuthStartedAtRef.current = 0;
695
+ window.localStorage.removeItem("aip.pendingBrokerAuthentication");
696
+ setAuthenticatingBroker(null);
697
+ setBrokerAuthenticationTimedOut(false);
698
+ }
699
+
700
+ async function refreshAuthenticatedBroker(connectionId: string): Promise<boolean> {
701
+ const authStatus = await brokerApi.getAuthStatus(connectionId);
702
+ if (!authStatus.authenticated || authStatus.state !== "CONNECTED") return false;
703
+ const [connections, dashboard] = await Promise.all([
704
+ brokerApi.listConnections(),
705
+ portfolioApi.getDashboard()
706
+ ]);
707
+ setBrokerConnections(connections);
708
+ setPortfolioDashboard(dashboard);
709
+ setPortfolios(dashboard.portfolios);
710
+ return true;
711
+ }
712
+
713
+ async function finishBrokerAuthentication(connectionId: string) {
714
+ if (brokerAuthPollBusyRef.current) return;
715
+ brokerAuthPollBusyRef.current = true;
716
+ try {
717
+ if (await refreshAuthenticatedBroker(connectionId)) {
718
+ stopBrokerAuthenticationMonitoring(true);
719
+ }
720
+ } catch {
721
+ // Authentication may still be propagating from IBKR; the bounded parent poll remains authoritative.
722
+ } finally {
723
+ brokerAuthPollBusyRef.current = false;
724
+ }
725
+ }
726
+
727
+ function startBrokerAuthenticationMonitoring(authWindow: Window, connectionId: string, provider: string) {
728
+ pendingBrokerAuthRef.current = { connectionId, provider };
729
+ setAuthenticatingBroker(provider);
730
+ setBrokerAuthenticationTimedOut(false);
731
+ brokerAuthStartedAtRef.current = Date.now();
732
+ startBrokerAuthenticationPolling(authWindow, connectionId);
733
+ }
734
+
735
+ function startBrokerAuthenticationPolling(authWindow: Window, connectionId: string) {
736
+ if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
737
+ const poll = async () => {
738
+ if (brokerAuthPollBusyRef.current) return;
739
+ if (Date.now() - brokerAuthStartedAtRef.current >= brokerAuthenticationTimeoutMs) {
740
+ if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
741
+ brokerAuthPollRef.current = null;
742
+ setBrokerAuthenticationTimedOut(true);
743
+ return;
744
+ }
745
+ if (authWindow.closed) {
746
+ brokerAuthPollBusyRef.current = true;
747
+ try {
748
+ await refreshAuthenticatedBroker(connectionId);
749
+ } catch {
750
+ // A manually closed pending login remains unauthenticated.
751
+ } finally {
752
+ brokerAuthPollBusyRef.current = false;
753
+ stopBrokerAuthenticationMonitoring(false);
754
+ }
755
+ return;
756
+ }
757
+ await finishBrokerAuthentication(connectionId);
758
+ };
759
+ brokerAuthPollRef.current = window.setInterval(() => void poll(), brokerAuthenticationPollMs);
760
+ }
761
+
762
+ function continueBrokerAuthenticationChecking() {
763
+ const pending = pendingBrokerAuthRef.current;
764
+ const authWindow = brokerAuthPopupRef.current;
765
+ if (!pending || !authWindow || authWindow.closed) return;
766
+ setBrokerAuthenticationTimedOut(false);
767
+ brokerAuthStartedAtRef.current = Date.now();
768
+ startBrokerAuthenticationPolling(authWindow, pending.connectionId);
769
+ }
770
+
771
+ function cancelBrokerAuthentication() {
772
+ stopBrokerAuthenticationMonitoring(true);
773
+ }
774
+
775
+ async function completeBrokerAuthentication(
776
+ authWindow: Window | null,
777
+ brokerType: string,
778
+ requestAction: () => ReturnType<typeof brokerApi.authenticationAction>
779
+ ): Promise<boolean> {
780
+ if (!authWindow) return false;
781
+ try {
782
+ const action = await requestAction();
783
+ if (action.action === "REDIRECT_REQUIRED" || action.action === "POPUP_REQUIRED") {
784
+ if (!action.authenticationUrl) {
785
+ stopBrokerAuthenticationMonitoring(true);
786
+ setBrokerCardError(brokerType, "Broker authentication is temporarily unavailable. Your saved portfolio remains available.");
787
+ return false;
788
+ }
789
+ window.localStorage.setItem("aip.pendingBrokerAuthentication", JSON.stringify({
790
+ connectionId: action.connectionId, provider: action.provider
791
+ }));
792
+ authWindow.location.assign(action.authenticationUrl);
793
+ setBrokerCardError(brokerType, null);
794
+ startBrokerAuthenticationMonitoring(authWindow, action.connectionId, action.provider);
795
+ return true;
796
+ }
797
+ authWindow.close();
798
+ if (action.action === "NONE") {
799
+ setBrokerConnections(await brokerApi.listConnections());
800
+ } else {
801
+ setBrokerCardError(brokerType, action.message);
802
+ }
803
+ stopBrokerAuthenticationMonitoring(false);
804
+ return false;
805
+ } catch (err) {
806
+ stopBrokerAuthenticationMonitoring(true);
807
+ setBrokerCardError(brokerType, getApiFailure(err).message);
808
+ return false;
809
+ }
810
+ }
811
+
812
+ async function login(userKey: "user-a" | "user-b") {
813
+ setAuthLoading(true);
814
+ setError(null);
815
+ try {
816
+ const session = await authApi.loginDev(userKey);
817
+ window.localStorage.setItem("aip.accessToken", session.accessToken);
818
+ window.localStorage.setItem("aip.user", JSON.stringify(session.user));
819
+ clearUserScopedState();
820
+ setAccessToken(session.accessToken);
821
+ setAuthenticatedUser(session.user);
822
+ } catch (err) {
823
+ setError(getApiFailure(err));
824
+ } finally {
825
+ setAuthLoading(false);
826
+ }
827
+ }
828
+
829
+ function logout() {
830
+ window.localStorage.removeItem("aip.accessToken");
831
+ window.localStorage.removeItem("aip.user");
832
+ setAccessToken(null);
833
+ setAuthenticatedUser(null);
834
+ clearUserScopedState();
835
+ }
836
+
837
+ if (authLoading) {
838
+ return <LoadingView />;
839
+ }
840
+
841
+ if (!accessToken || !authenticatedUser) {
842
+ return <SignInView error={error} onLogin={login} />;
843
+ }
844
+
845
return (
846
<main className="app-shell">
847
<aside className={`sidebar ${sidebarOpen ? "sidebar-open" : ""}`}>
874
</nav>
875
876
<div className="sidebar-panel">
379
- <Badge tone="info">Demo data</Badge>
380
- <p>Phase 2B validates backend portfolio and broker APIs. Real providers remain not configured.</p>
877
+ <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "info"}>
878
+ {portfolioSourceLabels(positions).badge}
879
+ </Badge>
880
+ <p>{hasRealBrokerPositions(positions) ? "Read-only broker data is sourced from Interactive Brokers." : "Demo portfolios use generated broker data."}</p>
881
</div>
882
</aside>
883
896
<kbd>/</kbd>
897
</div>
898
<div className="topbar-actions">
399
- <Badge tone="warning">Demo data</Badge>
899
+ <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "warning"}>
900
+ {portfolioSourceLabels(positions).badge}
901
+ </Badge>
902
<button className="icon-button" type="button" aria-label="Notifications">
903
<Bell size={18} />
904
</button>
913
<Moon size={16} aria-hidden="true" />
914
</label>
915
<button className="account-button" type="button">
414
- <span>DEV</span>
916
+ <span>{authenticatedUser.displayName ?? authenticatedUser.email ?? "Account"}</span>
917
<ChevronDown size={16} aria-hidden="true" />
918
</button>
919
+ <Button variant="secondary" onClick={logout}>
920
+ Logout
921
+ </Button>
922
</div>
923
</header>
924
931
<div className="content">
932
<section className="page-header">
933
<div>
429
- <p className="eyebrow">Phase 2B broker readiness</p>
934
+ <p className="eyebrow">{view === "research" ? "Research intelligence" : "Brokers & Portfolios"}</p>
935
<h1>{view === "dashboard" ? "Portfolio command center" : navItems.find((item) => item.id === view)?.label}</h1>
936
<p>
937
Backend: <code>{frontendConfig.apiBaseUrl}</code>
940
<PortfolioSelector
941
portfolios={portfolios}
942
selectedPortfolioId={selectedPortfolioId}
438
- onChange={setSelectedPortfolioId}
943
+ onChange={(portfolioId) => {
944
+ setPortfolioResearchSummary(null);
945
+ setSelectedResearchInstrumentId("");
946
+ setResearchSummary(null);
947
+ setPortfolioHistory(null);
948
+ rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
949
+ setSelectedPortfolioId(portfolioId);
950
+ }}
951
/>
952
</section>
953
968
{!loading && !error ? (
969
<>
970
{view === "dashboard" ? (
459
- <DashboardView
460
- portfolio={selectedPortfolio}
461
- summary={summary}
462
- positions={positions}
463
- onCreate={createPortfolio}
464
- onSync={syncPortfolio}
465
- creating={creating}
466
- syncing={syncing}
467
- newPortfolioName={newPortfolioName}
468
- newPortfolioCurrency={newPortfolioCurrency}
469
- setNewPortfolioName={setNewPortfolioName}
470
- setNewPortfolioCurrency={setNewPortfolioCurrency}
471
- />
971
+ <>
972
+ <PortfolioTabs
973
+ portfolios={portfolios}
974
+ selected={portfolioScope}
975
+ onSelect={(value) => {
976
+ setPortfolioScope(value);
977
+ if (value !== "ALL") setSelectedPortfolioId(value);
978
+ }}
979
+ />
980
+ {portfolioScope === "ALL" ? (
981
+ <MultiPortfolioDashboard dashboard={portfolioDashboard} onCreate={createPortfolio} creating={creating} />
982
+ ) : (
983
+ <DashboardView
984
+ portfolio={selectedPortfolio}
985
+ summary={summary}
986
+ positions={positions}
987
+ onCreate={createPortfolio}
988
+ onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
989
+ creating={creating}
990
+ syncing={syncing}
991
+ newPortfolioName={newPortfolioName}
992
+ newPortfolioCurrency={newPortfolioCurrency}
993
+ setNewPortfolioName={setNewPortfolioName}
994
+ setNewPortfolioCurrency={setNewPortfolioCurrency}
995
+ />
996
+ )}
997
+ </>
998
) : null}
999
{view === "portfolio" ? (
1000
<PortfolioView
1002
summary={summary}
1003
positions={sortedPositions}
1004
rawPositions={positions}
1005
+ history={portfolioHistory}
1006
+ historyRange={portfolioHistoryRange}
1007
+ historyLoading={portfolioHistoryLoading}
1008
+ onHistoryRange={setPortfolioHistoryRange}
1009
searchText={searchText}
1010
sortKey={sortKey}
1011
onSearch={setSearchText}
1012
onSort={setSortKey}
1013
onCreate={createPortfolio}
484
- onSync={syncPortfolio}
1014
+ onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
1015
creating={creating}
1016
syncing={syncing}
1017
newPortfolioName={newPortfolioName}
1018
newPortfolioCurrency={newPortfolioCurrency}
1019
setNewPortfolioName={setNewPortfolioName}
1020
setNewPortfolioCurrency={setNewPortfolioCurrency}
1021
+ onUpdateDisplayName={updateHoldingDisplayName}
1022
/>
1023
) : null}
1024
{view === "brokers" ? (
1025
<BrokerView
1026
providers={brokerProviders}
1027
connections={brokerConnections}
1028
+ portfolios={portfolios}
1029
+ errors={brokerErrors}
1030
loading={brokerLoading}
498
- onConnectDemo={async () => {
1031
+ authenticatingBroker={authenticatingBroker}
1032
+ authenticationTimedOut={brokerAuthenticationTimedOut}
1033
+ onContinueAuthentication={continueBrokerAuthenticationChecking}
1034
+ onCancelAuthentication={cancelBrokerAuthentication}
1035
+ onConnectBroker={async (provider) => {
1036
+ if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1037
+ setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1038
+ return;
1039
+ }
1040
+ const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1041
+ if (!authWindow) return;
1042
+ setBrokerLoading(true);
1043
+ try {
1044
+ const connection = await brokerApi.connectBroker(provider.brokerType);
1045
+ await completeBrokerAuthentication(
1046
+ authWindow,
1047
+ provider.brokerType,
1048
+ () => brokerApi.authenticationAction(connection.connectionId)
1049
+ );
1050
+ setBrokerConnections(await brokerApi.listConnections());
1051
+ setBrokerProviders(await brokerApi.listBrokers());
1052
+ } catch (err) {
1053
+ stopBrokerAuthenticationMonitoring(true);
1054
+ setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1055
+ } finally {
1056
+ setBrokerLoading(false);
1057
+ }
1058
+ }}
1059
+ onAuthenticate={async (connectionId, provider) => {
1060
+ if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1061
+ setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1062
+ return;
1063
+ }
1064
+ const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1065
+ if (!authWindow) return;
1066
+ setBrokerLoading(true);
1067
+ try {
1068
+ await completeBrokerAuthentication(
1069
+ authWindow,
1070
+ provider.brokerType,
1071
+ () => brokerApi.authenticationAction(connectionId)
1072
+ );
1073
+ setBrokerConnections(await brokerApi.listConnections());
1074
+ } catch (err) {
1075
+ stopBrokerAuthenticationMonitoring(true);
1076
+ setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1077
+ } finally {
1078
+ setBrokerLoading(false);
1079
+ }
1080
+ }}
1081
+ onConfigureIndividual={async (provider, clientKey, clientSecret) => {
1082
setBrokerLoading(true);
1083
try {
501
- await brokerApi.connectMock();
1084
+ const existing = brokerConnections.find((connection) => connection.brokerType === provider.brokerType);
1085
+ const connection = existing ?? await brokerApi.connectBroker(provider.brokerType);
1086
+ await brokerApi.configureCredentials(connection.connectionId, clientKey, clientSecret);
1087
+ setBrokerCardError(provider.brokerType, null);
1088
setBrokerConnections(await brokerApi.listConnections());
1089
+ } catch (err) {
1090
+ setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1091
+ throw err;
1092
} finally {
1093
setBrokerLoading(false);
1094
}
1095
}}
507
- onSyncConnection={async (connectionId) => {
1096
+ onImportComplete={async (portfolioId) => {
1097
+ const dashboard = await portfolioApi.getDashboard();
1098
+ setPortfolioDashboard(dashboard);
1099
+ setPortfolios(dashboard.portfolios);
1100
+ setSelectedPortfolioId(portfolioId);
1101
+ setPortfolioScope(portfolioId);
1102
+ rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
1103
+ setView("portfolio");
1104
+ }}
1105
+ onDisconnect={async (connectionId) => {
1106
setBrokerLoading(true);
1107
try {
510
- await brokerApi.syncConnection(connectionId);
1108
+ await brokerApi.disconnectConnection(connectionId);
1109
setBrokerConnections(await brokerApi.listConnections());
1110
+ setBrokerProviders(await brokerApi.listBrokers());
1111
} finally {
1112
setBrokerLoading(false);
1113
}
1116
) : null}
1117
{view === "research" ? (
1118
<ResearchView
520
- companies={researchCompanies}
1119
+ positions={positions}
1120
+ selectedPortfolioId={selectedPortfolioId}
1121
+ portfolioResearchSummary={portfolioResearchSummary}
1122
+ portfolioResearchLoading={portfolioResearchLoading}
1123
selectedInstrumentId={selectedResearchInstrumentId}
1124
onSelectInstrument={setSelectedResearchInstrumentId}
1125
summary={researchSummary}
1139
setResearchLoading(false);
1140
}
1141
}}
1142
+ onPortfolioRefresh={async () => {
1143
+ if (!selectedPortfolioId) {
1144
+ return;
1145
+ }
1146
+ setPortfolioResearchLoading(true);
1147
+ try {
1148
+ const refreshed = await researchApi.refreshPortfolio(selectedPortfolioId);
1149
+ setPortfolioResearchSummary(refreshed);
1150
+ const firstLoadable = refreshed.companies.find((company) =>
1151
+ company.instrumentId && isResearchSummaryLoadable(company.status)
1152
+ );
1153
+ if (firstLoadable?.instrumentId) {
1154
+ setSelectedResearchInstrumentId(firstLoadable.instrumentId);
1155
+ }
1156
+ } finally {
1157
+ setPortfolioResearchLoading(false);
1158
+ }
1159
+ }}
1160
/>
1161
) : null}
1162
{view === "settings" ? <SettingsView /> : null}
1168
);
1169
}
1170
1171
+function SignInView({
1172
+ error,
1173
+ onLogin
1174
+}: {
1175
+ error: ApiFailure | null;
1176
+ onLogin: (userKey: "user-a" | "user-b") => void;
1177
+}) {
1178
+ return (
1179
+ <main className="signin-shell">
1180
+ <section className="signin-panel">
1181
+ <div className="brand-lockup">
1182
+ <div className="brand-mark">AI</div>
1183
+ <div>
1184
+ <strong>AI Investment</strong>
1185
+ <span>Secure workspace</span>
1186
+ </div>
1187
+ </div>
1188
+ <h1>Sign in</h1>
1189
+ <p>Choose a DEV identity to validate authentication and data isolation.</p>
1190
+ {error ? <ErrorState message={error.message} correlationId={error.correlationId} /> : null}
1191
+ <div className="signin-actions">
1192
+ <Button onClick={() => onLogin("user-a")}>Sign in as User A</Button>
1193
+ <Button variant="secondary" onClick={() => onLogin("user-b")}>Sign in as User B</Button>
1194
+ </div>
1195
+ </section>
1196
+ </main>
1197
+ );
1198
+}
1199
+
1200
function PortfolioSelector({
1201
portfolios,
1202
selectedPortfolioId,
1234
);
1235
}
1236
1237
+function PortfolioTabs({
1238
+ portfolios,
1239
+ selected,
1240
+ onSelect
1241
+}: {
1242
+ portfolios: PortfolioListItem[];
1243
+ selected: "ALL" | string;
1244
+ onSelect: (value: "ALL" | string) => void;
1245
+}) {
1246
+ return (
1247
+ <nav className="portfolio-tabs" aria-label="Portfolio views">
1248
+ <button className={selected === "ALL" ? "active" : ""} onClick={() => onSelect("ALL")} type="button">All</button>
1249
+ {portfolios.map((portfolio) => (
1250
+ <button
1251
+ className={selected === portfolio.portfolioId ? "active" : ""}
1252
+ onClick={() => onSelect(portfolio.portfolioId)}
1253
+ type="button"
1254
+ key={portfolio.portfolioId}
1255
+ >
1256
+ {portfolio.name}
1257
+ </button>
1258
+ ))}
1259
+ </nav>
1260
+ );
1261
+}
1262
+
1263
+function MultiPortfolioDashboard({
1264
+ dashboard,
1265
+ onCreate,
1266
+ creating
1267
+}: {
1268
+ dashboard: PortfolioDashboard | null;
1269
+ onCreate: () => void;
1270
+ creating: boolean;
1271
+}) {
1272
+ if (!dashboard || dashboard.portfolios.length === 0) {
1273
+ return (
1274
+ <Card className="wide-panel">
1275
+ <EmptyState title="No portfolios" message="Connect a broker or create a portfolio to begin." />
1276
+ <Button onClick={onCreate} disabled={creating}>{creating ? "Creating..." : "Create portfolio"}</Button>
1277
+ </Card>
1278
+ );
1279
+ }
1280
+ return (
1281
+ <div className="dashboard-grid">
1282
+ <Card className="wide-panel">
1283
+ <div className="panel-header"><div><p className="eyebrow">My portfolios</p><h2>Total portfolio value</h2></div></div>
1284
+ <div className="currency-total-grid">
1285
+ {Object.entries(dashboard.currencyTotals).map(([currency, amount]) => (
1286
+ <MetricCard label={currency} value={formatMoney(amount, currency)} key={currency} />
1287
+ ))}
1288
+ </div>
1289
+ {dashboard.incompleteValuationPortfolioIds.length > 0 ? (
1290
+ <p className="broker-note">Some portfolios have incomplete valuation data and are not combined with another currency.</p>
1291
+ ) : null}
1292
+ </Card>
1293
+ <section className="portfolio-card-grid" aria-label="Persisted portfolios">
1294
+ {dashboard.portfolios.map((portfolio) => (
1295
+ <Card as="article" key={portfolio.portfolioId}>
1296
+ <Badge tone={portfolio.provider ? "positive" : "neutral"}>{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual"}</Badge>
1297
+ <h2>{portfolio.name}</h2>
1298
+ <strong>{formatBackendMoney(portfolio.totalMarketValue)}</strong>
1299
+ <p>{portfolio.acquisitionSource === "MANUAL_CSV_IMPORT"
1300
+ ? `Imported snapshot: ${portfolio.lastImportedAt ? new Date(portfolio.lastImportedAt).toLocaleString() : "Not available"}`
1301
+ : `Broker holdings synced: ${portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}`}</p>
1302
+ <p>Market price updated independently in the holdings view.</p>
1303
+ </Card>
1304
+ ))}
1305
+ </section>
1306
+ <Card className="wide-panel">
1307
+ <div className="panel-header"><div><h2>All holdings</h2><p>Matching ISINs are combined with quantity-weighted average cost. Imported prices remain snapshots.</p></div></div>
1308
+ <div className="table-wrap">
1309
+ <table>
1310
+ <thead><tr><th>Instrument</th><th>ISIN</th><th>Quantity</th><th>Weighted average cost</th><th>Value</th><th>Provenance</th></tr></thead>
1311
+ <tbody>
1312
+ {dashboard.combinedHoldings.map((holding) => (
1313
+ <tr key={holding.securityKey + holding.averageCost.currency}>
1314
+ <td>{holding.companyName}<small>{holding.symbol}</small></td>
1315
+ <td>{holding.isin}</td>
1316
+ <td>{holding.quantity}</td>
1317
+ <td>{formatBackendMoney(holding.averageCost)}</td>
1318
+ <td>{formatBackendMoney(holding.marketValue)}</td>
1319
+ <td>{holding.dataFreshness === "IMPORTED_SNAPSHOT" ? "Imported snapshot" : "Mixed sources"}</td>
1320
+ </tr>
1321
+ ))}
1322
+ </tbody>
1323
+ </table>
1324
+ </div>
1325
+ </Card>
1326
+ </div>
1327
+ );
1328
+}
1329
+
1330
function PortfolioCreatePanel({
1331
onCreate,
1332
creating,
1384
summary: PortfolioSummary | null;
1385
positions: PortfolioPosition[];
1386
onCreate: () => void;
645
- onSync: () => void;
1387
+ onSync?: () => void;
1388
creating: boolean;
1389
syncing: boolean;
1390
newPortfolioName: string;
1407
1408
const topGainers = [...positions].sort((a, b) => b.unrealizedProfitLossPercent - a.unrealizedProfitLossPercent).slice(0, 3);
1409
const topLosers = [...positions].sort((a, b) => a.unrealizedProfitLossPercent - b.unrealizedProfitLossPercent).slice(0, 3);
1410
+ const sourceLabels = portfolioSourceLabels(positions);
1411
+ const syncAction = onSync ? (
1412
+ <Button onClick={onSync} disabled={syncing} variant="secondary">
1413
+ <RefreshCw size={16} />
1414
+ {syncing ? "Syncing..." : sourceLabels.syncButton}
1415
+ </Button>
1416
+ ) : null;
1417
1418
return (
1419
<div className="dashboard-grid">
1420
<section className="metrics-grid" aria-label="Portfolio summary">
1421
<MetricCard
1422
label="Portfolio value"
674
- value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)}
675
- meta="Demo broker sync"
1423
+ value={formatBackendMoney(summary?.totalMarketValue)}
1424
+ meta={sourceLabels.syncMeta}
1425
/>
677
- <MetricCard label="Total P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
678
- <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} tone={(summary?.unrealizedProfitLossPercent ?? 0) >= 0 ? "positive" : "negative"} />
679
- <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
1426
+ <MetricCard label={sourceLabels.totalProfitLoss} value={formatBackendMoney(summary?.unrealizedProfitLoss)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
1427
+ <MetricCard label={sourceLabels.returnLabel} value={formatPercent(summary?.unrealizedProfitLossPercent)} tone={(summary?.unrealizedProfitLossPercent ?? 0) >= 0 ? "positive" : "negative"} />
1428
+ <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
1429
<MetricCard label="Holdings" value={String(summary?.positions ?? 0)} />
1430
<MetricCard label="Portfolio risk" value="Pending" meta="Risk service not implemented" tone="warning" />
1431
</section>
1434
<div className="panel-header">
1435
<div>
1436
<h2>{portfolio.name}</h2>
688
- <p>Last updated: {summary ? "from latest mock sync" : "not synced"} - Source: Broker demo data</p>
1437
+ <p>Last updated: {summary ? sourceLabels.lastUpdated : "not synced"} - Source: {sourceLabels.source}</p>
1438
</div>
690
- <Button onClick={onSync} disabled={syncing} variant="secondary">
691
- <RefreshCw size={16} />
692
- {syncing ? "Syncing..." : "Sync mock broker"}
693
- </Button>
1439
+ {syncAction}
1440
</div>
1441
{summary ? <AllocationCharts summary={summary} /> : <EmptyState title="No positions" message="This portfolio currently has no positions." />}
1442
</Card>
1443
698
- <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} />
699
- <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} />
1444
+ <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} sourceLabels={sourceLabels} />
1445
+ <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} sourceLabels={sourceLabels} />
1446
1447
<Card className="wide-panel">
1448
<div className="panel-header">
1449
<div>
1450
<h2>AI opportunities</h2>
705
- <p>Recommendation logic is intentionally out of scope for Phase 2B.</p>
1451
+ <p>Recommendation logic is not yet available.</p>
1452
</div>
1453
<Badge tone="neutral">Future ready</Badge>
1454
</div>
1461
function MovementPanel({
1462
title,
1463
icon: Icon,
718
- positions
1464
+ positions,
1465
+ sourceLabels
1466
}: {
1467
title: string;
1468
icon: typeof TrendingUp;
1469
positions: PortfolioPosition[];
1470
+ sourceLabels: ReturnType<typeof portfolioSourceLabels>;
1471
}) {
1472
return (
1473
<Card>
1476
<Icon size={18} aria-hidden="true" />
1477
</div>
1478
{positions.length === 0 ? (
731
- <EmptyState title="No synced positions" message="Sync a mock broker account to populate this view." />
1479
+ <EmptyState title="No synced positions" message={sourceLabels.emptyMessage} />
1480
) : (
1481
<div className="movement-list">
1482
{positions.map((position) => (
1498
summary,
1499
positions,
1500
rawPositions,
1501
+ history,
1502
+ historyRange,
1503
+ historyLoading,
1504
+ onHistoryRange,
1505
searchText,
1506
sortKey,
1507
onSearch,
1513
newPortfolioName,
1514
newPortfolioCurrency,
1515
setNewPortfolioName,
764
- setNewPortfolioCurrency
1516
+ setNewPortfolioCurrency,
1517
+ onUpdateDisplayName
1518
}: {
1519
portfolio?: Portfolio;
1520
summary: PortfolioSummary | null;
1521
positions: PortfolioPosition[];
1522
rawPositions: PortfolioPosition[];
1523
+ history: PortfolioHistory | null;
1524
+ historyRange: PortfolioHistoryRange;
1525
+ historyLoading: boolean;
1526
+ onHistoryRange: (value: PortfolioHistoryRange) => void;
1527
searchText: string;
1528
sortKey: SortKey;
1529
onSearch: (value: string) => void;
1530
onSort: (value: SortKey) => void;
1531
onCreate: () => void;
775
- onSync: () => void;
1532
+ onSync?: () => void;
1533
creating: boolean;
1534
syncing: boolean;
1535
newPortfolioName: string;
1536
newPortfolioCurrency: string;
1537
setNewPortfolioName: (value: string) => void;
1538
setNewPortfolioCurrency: (value: string) => void;
1539
+ onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
1540
}) {
1541
if (!portfolio) {
1542
return (
1551
);
1552
}
1553
1554
+ const sourceLabels = portfolioSourceLabels(rawPositions);
1555
+
1556
return (
1557
<div className="portfolio-layout">
1558
+ <Card className="wide-panel">
1559
+ <div className="panel-header">
1560
+ <div>
1561
+ <p className="eyebrow">{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual portfolio"}</p>
1562
+ <h2>{portfolio.name}</h2>
1563
+ <p>Broker holdings synced: {portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}</p>
1564
+ <p>Market price updated: {rawPositions.map((position) => position.quote?.sourceTimestamp).filter(Boolean).sort().at(-1) ? new Date(rawPositions.map((position) => position.quote?.sourceTimestamp).filter(Boolean).sort().at(-1)!).toLocaleString() : "Last-known valuation"}</p>
1565
+ </div>
1566
+ {onSync ? <Button onClick={onSync} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Syncing..." : "Sync"}</Button> : null}
1567
+ </div>
1568
+ <p>Base currency: {portfolio.baseCurrency} · Connection: {portfolio.lastBrokerSyncErrorCode ? "Re-authentication or retry required" : portfolio.brokerConnectionId ? "Connected or previously connected" : "Not broker-backed"}</p>
1569
+ </Card>
1570
<section className="metrics-grid" aria-label="Portfolio totals">
799
- <MetricCard label="Total market value" value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)} />
800
- <MetricCard label="Total cost" value={formatMoney(summary?.totalCostBasis.amount, summary?.baseCurrency)} />
801
- <MetricCard label="Unrealized P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
802
- <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} />
803
- <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
1571
+ <MetricCard label={sourceLabels.marketValue} value={formatBackendMoney(summary?.totalMarketValue)} />
1572
+ <MetricCard label={sourceLabels.costBasis} value={formatBackendMoney(summary?.totalCostBasis)} />
1573
+ <MetricCard label={sourceLabels.unrealizedProfitLoss} value={formatBackendMoney(summary?.unrealizedProfitLoss)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
1574
+ <MetricCard label={sourceLabels.returnLabel} value={formatPercent(summary?.unrealizedProfitLossPercent)} />
1575
+ <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
1576
<MetricCard label="Position count" value={String(summary?.positions ?? 0)} />
1577
</section>
1578
1579
+ <PortfolioHistoryPanel
1580
+ history={history}
1581
+ range={historyRange}
1582
+ loading={historyLoading}
1583
+ onRange={onHistoryRange}
1584
+ />
1585
+
1586
<Card className="wide-panel">
1587
<div className="panel-header">
1588
<div>
1589
<h2>Holdings</h2>
1590
<p>Search respects ticker, company, ISIN, exchange, and country.</p>
1591
</div>
813
- <Button onClick={onSync} disabled={syncing} variant="secondary">
814
- <RefreshCw size={16} />
815
- {syncing ? "Syncing..." : "Sync mock broker"}
816
- </Button>
1592
+ {onSync ? (
1593
+ <Button onClick={onSync} disabled={syncing} variant="secondary">
1594
+ <RefreshCw size={16} />
1595
+ {syncing ? "Syncing..." : sourceLabels.syncButton}
1596
+ </Button>
1597
+ ) : null}
1598
</div>
1599
<div className="table-toolbar">
1600
<div className="table-search">
1610
<SlidersHorizontal size={16} aria-hidden="true" />
1611
<span>Sort</span>
1612
<select value={sortKey} onChange={(event) => onSort(event.target.value as SortKey)}>
832
- <option value="marketValue">Market value</option>
833
- <option value="profitLoss">P/L</option>
1613
+ <option value="marketValue">{sourceLabels.sortMarketValue}</option>
1614
+ <option value="profitLoss">{sourceLabels.sortProfitLoss}</option>
1615
<option value="allocation">Allocation</option>
1616
<option value="company">Company</option>
1617
<option value="ticker">Ticker</option>
1619
</label>
1620
</div>
1621
{rawPositions.length === 0 ? (
841
- <EmptyState title="No positions" message="This portfolio currently has no positions." action={<Button onClick={onSync}>Sync mock broker</Button>} />
1622
+ <EmptyState
1623
+ title="No positions"
1624
+ message="This portfolio currently has no positions."
1625
+ action={onSync ? <Button onClick={onSync}>{sourceLabels.syncButton}</Button> : undefined}
1626
+ />
1627
) : (
843
- <HoldingsTable positions={positions} summary={summary} />
1628
+ <HoldingsTable positions={positions} summary={summary} onUpdateDisplayName={onUpdateDisplayName} />
1629
)}
1630
</Card>
1631
1645
);
1646
}
1647
1648
+function PortfolioHistoryPanel({
1649
+ history,
1650
+ range,
1651
+ loading,
1652
+ onRange
1653
+}: {
1654
+ history: PortfolioHistory | null;
1655
+ range: PortfolioHistoryRange;
1656
+ loading: boolean;
1657
+ onRange: (value: PortfolioHistoryRange) => void;
1658
+}) {
1659
+ const points = history?.points ?? [];
1660
+ const first = points[0];
1661
+ const last = points[points.length - 1];
1662
+ const absoluteChange = first && last ? last.marketValue.amount - first.marketValue.amount : undefined;
1663
+ const hasInvestedCapital = points.some((point) => point.investedCapital);
1664
+
1665
+ return (
1666
+ <Card className="wide-panel">
1667
+ <div className="panel-header">
1668
+ <div>
1669
+ <h2>Portfolio value history</h2>
1670
+ <p>{history?.investedCapitalStatus === "AVAILABLE" ? "Market value and invested capital." : "Portfolio value change; investment return needs cash-flow history."}</p>
1671
+ </div>
1672
+ <div className="range-control" aria-label="Portfolio history range">
1673
+ {portfolioHistoryRanges.map((value) => (
1674
+ <button
1675
+ className={range === value ? "range-active" : ""}
1676
+ key={value}
1677
+ onClick={() => onRange(value)}
1678
+ type="button"
1679
+ >
1680
+ {value}
1681
+ </button>
1682
+ ))}
1683
+ </div>
1684
+ </div>
1685
+ {loading ? (
1686
+ <Skeleton rows={4} />
1687
+ ) : points.length === 0 ? (
1688
+ <EmptyState title="No portfolio history" message="No portfolio history available yet." />
1689
+ ) : (
1690
+ <>
1691
+ <section className="history-metrics" aria-label="Portfolio value change">
1692
+ <MetricCard label="Starting value" value={formatBackendMoney(first?.marketValue)} />
1693
+ <MetricCard label="Ending value" value={formatBackendMoney(last?.marketValue)} />
1694
+ <MetricCard
1695
+ label="Portfolio value change"
1696
+ value={absoluteChange === undefined ? "--" : formatMoney(absoluteChange, last?.marketValue.currency)}
1697
+ tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
1698
+ />
1699
+ <MetricCard
1700
+ label="Value change %"
1701
+ value={formatChangePercent(first?.marketValue.amount, last?.marketValue.amount)}
1702
+ tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
1703
+ />
1704
+ </section>
1705
+ <PortfolioHistoryChart points={points} showInvestedCapital={hasInvestedCapital} />
1706
+ {points.length === 1 ? (
1707
+ <p className="history-note">Portfolio history will build as real broker snapshots are collected.</p>
1708
+ ) : null}
1709
+ {history?.investedCapitalStatus !== "AVAILABLE" ? (
1710
+ <p className="history-note">INVESTED_CAPITAL_HISTORY_UNAVAILABLE</p>
1711
+ ) : null}
1712
+ </>
1713
+ )}
1714
+ </Card>
1715
+ );
1716
+}
1717
+
1718
+function PortfolioHistoryChart({
1719
+ points,
1720
+ showInvestedCapital
1721
+}: {
1722
+ points: PortfolioHistory["points"];
1723
+ showInvestedCapital: boolean;
1724
+}) {
1725
+ const width = 900;
1726
+ const height = 260;
1727
+ const padding = { top: 18, right: 28, bottom: 34, left: 64 };
1728
+ const marketValues = points.map((point) => point.marketValue.amount);
1729
+ const investedValues = showInvestedCapital
1730
+ ? points.map((point) => point.investedCapital?.amount).filter((value): value is number => value !== undefined)
1731
+ : [];
1732
+ const values = [...marketValues, ...investedValues];
1733
+ const min = Math.min(...values);
1734
+ const max = Math.max(...values);
1735
+ const span = max - min || 1;
1736
+ const times = points.map((point) => new Date(point.timestamp).getTime());
1737
+ const minTime = Math.min(...times);
1738
+ const maxTime = Math.max(...times);
1739
+ const timeSpan = maxTime - minTime || 1;
1740
+ const singlePoint = points.length === 1;
1741
+ const x = (timestamp: string) =>
1742
+ singlePoint
1743
+ ? padding.left + (width - padding.left - padding.right) / 2
1744
+ : padding.left + ((new Date(timestamp).getTime() - minTime) / timeSpan) * (width - padding.left - padding.right);
1745
+ const y = (value: number) =>
1746
+ singlePoint
1747
+ ? padding.top + (height - padding.top - padding.bottom) / 2
1748
+ : padding.top + (1 - (value - min) / span) * (height - padding.top - padding.bottom);
1749
+ const marketPath = points.length > 1 ? linePath(points.map((point) => [x(point.timestamp), y(point.marketValue.amount)])) : "";
1750
+ const investedPath = showInvestedCapital && points.length > 1
1751
+ ? linePath(points.filter((point) => point.investedCapital).map((point) => [x(point.timestamp), y(point.investedCapital?.amount ?? 0)]))
1752
+ : "";
1753
+ const latest = points[points.length - 1];
1754
+
1755
+ return (
1756
+ <div className="history-chart">
1757
+ <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Portfolio value history chart">
1758
+ <line x1={padding.left} y1={padding.top} x2={padding.left} y2={height - padding.bottom} />
1759
+ <line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
1760
+ <text x={padding.left} y={14}>{formatMoney(max, latest?.marketValue.currency)}</text>
1761
+ <text x={padding.left} y={height - 8}>{formatMoney(min, latest?.marketValue.currency)}</text>
1762
+ {marketPath ? <path className="market-line" d={marketPath} /> : null}
1763
+ {investedPath ? <path className="invested-line" d={investedPath} /> : null}
1764
+ {points.map((point) => (
1765
+ <circle className="market-point" cx={x(point.timestamp)} cy={y(point.marketValue.amount)} r={points.length === 1 ? 5 : 3} key={point.timestamp}>
1766
+ <title>
1767
+ {new Date(point.timestamp).toLocaleString()} | Portfolio {formatBackendMoney(point.marketValue)}
1768
+ {point.investedCapital ? ` | Invested ${formatBackendMoney(point.investedCapital)}` : ""}
1769
+ {` | P/L ${formatBackendMoney(point.unrealizedPnl)}`}
1770
+ </title>
1771
+ </circle>
1772
+ ))}
1773
+ </svg>
1774
+ </div>
1775
+ );
1776
+}
1777
+
1778
+function linePath(points: number[][]) {
1779
+ return points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`).join(" ");
1780
+}
1781
+
1782
function FreshnessBadge({ freshness }: { freshness: string }) {
1783
const label = freshness === "END_OF_DAY" ? "EOD" : freshness === "MOCK" ? "DEMO" : freshness.replaceAll("_", "-");
865
- const tone = freshness === "REAL_TIME" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
1784
+ const tone = freshness === "REAL_TIME" || freshness === "REAL_BROKER" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
1785
return <Badge tone={tone}>{label}</Badge>;
1786
}
1787
869
-function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[]; summary: PortfolioSummary | null }) {
1788
+function HoldingsTable({ positions, summary, onUpdateDisplayName }: {
1789
+ positions: PortfolioPosition[];
1790
+ summary: PortfolioSummary | null;
1791
+ onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
1792
+}) {
1793
+ const [editingPositionId, setEditingPositionId] = useState<string | null>(null);
1794
+ const [editingName, setEditingName] = useState("");
1795
+ const [savingPositionId, setSavingPositionId] = useState<string | null>(null);
1796
+ const [renameError, setRenameError] = useState<string | null>(null);
1797
+
1798
+ async function saveName(position: PortfolioPosition, reset = false) {
1799
+ const nextName = reset ? null : editingName.trim();
1800
+ if (!reset && !nextName) {
1801
+ setRenameError("Enter a name, or use Reset to remove the custom name.");
1802
+ return;
1803
+ }
1804
+ setSavingPositionId(position.positionId);
1805
+ setRenameError(null);
1806
+ try {
1807
+ await onUpdateDisplayName(position, nextName);
1808
+ setEditingPositionId(null);
1809
+ } catch (error) {
1810
+ setRenameError(getApiFailure(error).message);
1811
+ } finally {
1812
+ setSavingPositionId(null);
1813
+ }
1814
+ }
1815
+
1816
return (
1817
<div className="table-frame">
1818
<table>
1823
<th>Exchange</th>
1824
<th>Quantity</th>
1825
<th>Average cost</th>
880
- <th>Current price</th>
881
- <th>Last price</th>
1826
+ <th>Broker current price</th>
1827
+ <th>Quote last price</th>
1828
<th>Bid</th>
1829
<th>Ask</th>
1830
<th>Market value</th>
885
- <th>P/L</th>
886
- <th>P/L %</th>
1831
+ <th>Unrealized P/L</th>
1832
+ <th>Unrealized P/L %</th>
1833
<th>Allocation</th>
1834
<th>Currency</th>
1835
<th>Data status</th>
1846
<tr key={position.positionId}>
1847
<td>
1848
<button className="security-button" type="button">
903
- <strong>{position.instrument.companyName}</strong>
1849
+ <strong>{position.displayName}</strong>
1850
<span>{position.instrument.isin ?? "No ISIN"}</span>
1851
</button>
1852
+ {position.sourceType === "MANUAL_CSV_IMPORT" ? editingPositionId === position.positionId ? (
1853
+ <div className="holding-name-editor">
1854
+ <label>
1855
+ Display name
1856
+ <input maxLength={160} value={editingName} onChange={(event) => setEditingName(event.target.value)} />
1857
+ </label>
1858
+ <Button disabled={savingPositionId === position.positionId} onClick={() => void saveName(position)}>Save</Button>
1859
+ <Button variant="secondary" disabled={savingPositionId === position.positionId} onClick={() => { setEditingPositionId(null); setRenameError(null); }}>Cancel</Button>
1860
+ {position.customDisplayName ? <Button variant="secondary" disabled={savingPositionId === position.positionId} onClick={() => void saveName(position, true)}>Reset</Button> : null}
1861
+ {renameError ? <small role="alert">{renameError}</small> : null}
1862
+ </div>
1863
+ ) : (
1864
+ <button className="text-action" type="button" onClick={() => { setEditingPositionId(position.positionId); setEditingName(position.customDisplayName ?? position.displayName); setRenameError(null); }}>
1865
+ Edit name
1866
+ </button>
1867
+ ) : null}
1868
</td>
1869
<td>{position.instrument.ticker}</td>
1870
<td>{position.instrument.exchange}</td>
1883
</td>
1884
<td>{formatPercent(allocation)}</td>
1885
<td>{position.instrument.tradingCurrency}</td>
924
- <td>{position.quote?.freshness ? <FreshnessBadge freshness={position.quote.freshness} /> : <FreshnessBadge freshness="UNAVAILABLE" />}</td>
925
- <td>{position.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : position.quote?.timestamp ? new Date(position.quote.timestamp).toLocaleString() : "Unavailable"}</td>
926
- <td>{position.quote?.source ?? "--"}</td>
927
- <td>{position.brokerAccountId}</td>
1886
+ <td>
1887
+ {position.dataFreshness === "REAL_BROKER" ? (
1888
+ <FreshnessBadge freshness="REAL_BROKER" />
1889
+ ) : position.quote?.freshness ? (
1890
+ <FreshnessBadge freshness={position.quote.freshness} />
1891
+ ) : (
1892
+ <FreshnessBadge freshness="UNAVAILABLE" />
1893
+ )}
1894
+ </td>
1895
+ <td>{position.lastUpdated ? new Date(position.lastUpdated).toLocaleString() : position.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : position.quote?.timestamp ? new Date(position.quote.timestamp).toLocaleString() : "Unavailable"}</td>
1896
+ <td>{position.dataFreshness === "REAL_BROKER" ? `${brokerDisplayName(position.brokerType)} / REAL_BROKER` : position.quote?.source ?? "--"}</td>
1897
+ <td>{brokerDisplayName(position.brokerType)}</td>
1898
<td>
1899
<Badge tone="neutral">Not rated</Badge>
1900
</td>
1947
function BrokerView({
1948
providers,
1949
connections,
1950
+ portfolios,
1951
+ errors,
1952
loading,
981
- onConnectDemo,
982
- onSyncConnection
1953
+ authenticatingBroker,
1954
+ authenticationTimedOut,
1955
+ onContinueAuthentication,
1956
+ onCancelAuthentication,
1957
+ onConnectBroker,
1958
+ onAuthenticate,
1959
+ onConfigureIndividual,
1960
+ onImportComplete,
1961
+ onDisconnect
1962
}: {
1963
providers: BrokerProviderInfo[];
1964
connections: BrokerConnection[];
1965
+ portfolios: PortfolioListItem[];
1966
+ errors: Record<string, string>;
1967
loading: boolean;
987
- onConnectDemo: () => Promise<void>;
988
- onSyncConnection: (connectionId: string) => Promise<void>;
1968
+ authenticatingBroker: string | null;
1969
+ authenticationTimedOut: boolean;
1970
+ onContinueAuthentication: () => void;
1971
+ onCancelAuthentication: () => void;
1972
+ onConnectBroker: (provider: BrokerProviderInfo) => Promise<void>;
1973
+ onAuthenticate: (connectionId: string, provider: BrokerProviderInfo) => Promise<void>;
1974
+ onConfigureIndividual: (provider: BrokerProviderInfo, clientKey: string, clientSecret: string) => Promise<void>;
1975
+ onImportComplete: (portfolioId: string) => Promise<void>;
1976
+ onDisconnect: (connectionId: string) => Promise<void>;
1977
}) {
1978
+ const [developerProvider, setDeveloperProvider] = useState<string | null>(null);
1979
+ const [developerKey, setDeveloperKey] = useState("");
1980
+ const [developerSecret, setDeveloperSecret] = useState("");
1981
+ const [importProvider, setImportProvider] = useState<string | null>(null);
1982
+ const [importFile, setImportFile] = useState<File | null>(null);
1983
+ const [importName, setImportName] = useState("");
1984
+ const [importPreview, setImportPreview] = useState<PortfolioImportPreview | null>(null);
1985
+ const [importBusy, setImportBusy] = useState(false);
1986
+ const [importError, setImportError] = useState<string | null>(null);
1987
+ const [importSuccess, setImportSuccess] = useState(false);
1988
+ const visibleProviders = providers.filter((provider) => provider.brokerType !== "MOCK");
1989
+ if (visibleProviders.length === 0) return <EmptyState title="No brokers available" message="Broker discovery is unavailable." />;
1990
return (
1991
+ <div>
1992
<div className="broker-grid">
992
- {providers.map((provider) => {
993
- const providerConnections = connections.filter((connection) => connection.brokerType === provider.brokerType);
994
- const isMock = provider.brokerType === "MOCK";
995
- const activeConnection = providerConnections[0];
996
- const providerTone = isMock ? "info" : provider.officialProviderSetupRequired ? "warning" : provider.providerStatus === "CONNECTED" ? "positive" : "neutral";
1993
+ {visibleProviders.map((provider) => {
1994
+ const activeConnection = connections.find((connection) => connection.brokerType === provider.brokerType);
1995
+ const linkedPortfolio = activeConnection
1996
+ ? portfolios.find((portfolio) => portfolio.brokerConnectionId === activeConnection.connectionId)
1997
+ : undefined;
1998
+ const authenticationRequired = activeConnection?.status === "AUTHENTICATION_REQUIRED"
1999
+ || activeConnection?.providerStatus === "AUTHENTICATION_REQUIRED";
2000
+ const connected = activeConnection?.status === "CONNECTED"
2001
+ && (!activeConnection.providerStatus || activeConnection.providerStatus === "CONNECTED");
2002
+ const recoverable = Boolean(activeConnection) && !connected;
2003
+ const connectionError = activeConnection?.status === "ERROR"
2004
+ || activeConnection?.providerStatus === "ERROR"
2005
+ || activeConnection?.lastErrorCode === "BROKER_UNAVAILABLE";
2006
+ const partnerUnavailable = provider.consumerAuthMode === "PARTNER_UNAVAILABLE";
2007
+ const developerIndividualMode = provider.individualApiSupported && provider.advancedIndividualMode;
2008
+ const meaningfulPersistedLinkage = Boolean(linkedPortfolio || activeConnection?.lastSuccessfulSyncAt || connected);
2009
+ const authenticationPending = authenticatingBroker === provider.brokerType;
2010
+ const statusLabel = partnerUnavailable ? "Direct connection unavailable"
2011
+ : !provider.connectable ? "Unavailable"
2012
+ : authenticationPending && authenticationTimedOut ? "Authentication pending"
2013
+ : authenticationPending ? "Waiting for IBKR authentication…"
2014
+ : authenticationRequired ? "Authentication required"
2015
+ : connected ? "Connected"
2016
+ : connectionError ? "Connection error"
2017
+ : activeConnection ? "Not connected" : "Not connected";
2018
+ const message = partnerUnavailable
2019
+ ? "Direct customer account connection is not available yet."
2020
+ : !provider.connectable
2021
+ ? provider.unavailableReason ?? "Connection not available yet."
2022
+ : authenticationPending && authenticationTimedOut
2023
+ ? "IBKR authentication is still pending."
2024
+ : authenticationPending
2025
+ ? "Waiting for IBKR authentication…"
2026
+ : authenticationRequired
2027
+ ? "Authentication is required to refresh holdings. Your saved portfolio remains available."
2028
+ : connected ? "Your broker connection is ready. Sync holdings from the linked portfolio."
2029
+ : connectionError ? "Broker authentication is temporarily unavailable. Your saved portfolio remains available."
2030
+ : "Connect this broker to import read-only holdings.";
2031
return (
998
- <Card className="broker-card" as="article" key={provider.brokerType}>
2032
+ <Card className="broker-card" as="article" key={provider.brokerType}>
2033
<div className="broker-icon">
2034
<Building2 size={22} aria-hidden="true" />
2035
</div>
1002
- <div>
2036
+ <div className="broker-card-content">
2037
<div className="panel-header compact">
1004
- <h2>{brokerDisplayName(provider.brokerType)}</h2>
1005
- <Badge tone={providerTone}>{isMock ? "DEMO" : provider.providerStatus}</Badge>
2038
+ <h2>{provider.displayName}</h2>
2039
+ <Badge tone={connected ? "positive" : "warning"}>{statusLabel}</Badge>
2040
<Badge tone="positive">Read-only</Badge>
2041
</div>
1008
- <dl className="broker-facts">
1009
- <div>
1010
- <dt>Connection status</dt>
1011
- <dd>{activeConnection?.status ?? provider.code}</dd>
1012
- </div>
1013
- <div>
1014
- <dt>Connection method</dt>
1015
- <dd>{provider.connectionMethod}</dd>
1016
- </div>
1017
- <div>
1018
- <dt>Last sync</dt>
1019
- <dd>{activeConnection?.lastSuccessfulSyncAt ? new Date(activeConnection.lastSuccessfulSyncAt).toLocaleString() : "Not synced"}</dd>
1020
- </div>
1021
- <div>
1022
- <dt>Account reference</dt>
1023
- <dd>{activeConnection?.externalAccountReference ?? "Unavailable"}</dd>
1024
- </div>
1025
- <div>
1026
- <dt>Data freshness</dt>
1027
- <dd>{activeConnection?.dataFreshness ?? provider.dataFreshness}</dd>
1028
- </div>
1029
- <div>
1030
- <dt>Capabilities</dt>
1031
- <dd>{provider.capabilities.length > 0 ? provider.capabilities.join(", ") : "None"}</dd>
1032
- </div>
1033
- </dl>
1034
- {provider.officialProviderSetupRequired ? (
1035
- <p className="broker-note">{provider.providerStatus === "DOCUMENTATION_REQUIRED" ? "Documentation required" : "Not configured"}</p>
1036
- ) : null}
1037
- {isMock && providerConnections.length === 0 ? (
1038
- <Button variant="secondary" onClick={onConnectDemo} disabled={loading}>
1039
- {loading ? "Connecting..." : "Connect Demo Broker"}
2042
+ <p className="broker-message">{message}</p>
2043
+ {!partnerUnavailable && errors[provider.brokerType] ? <div className="broker-inline-error" role="alert">{errors[provider.brokerType]}</div> : null}
2044
+ {linkedPortfolio ? <p className="broker-linked-portfolio">Portfolio: <strong>{linkedPortfolio.name}</strong></p> : null}
2045
+ <div className="broker-actions">
2046
+ {!authenticationPending && !partnerUnavailable && !developerIndividualMode && !activeConnection && provider.connectable ? (
2047
+ <Button onClick={() => onConnectBroker(provider)} disabled={loading}>
2048
+ {loading ? "Connecting..." : provider.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
2049
</Button>
2050
) : null}
1042
- {isMock && providerConnections[0] ? (
1043
- <Button variant="secondary" onClick={() => onSyncConnection(providerConnections[0].connectionId)} disabled={loading}>
1044
- {loading ? "Syncing..." : "Sync connection"}
2051
+ {!authenticationPending && !partnerUnavailable && !developerIndividualMode && activeConnection && recoverable ? (
2052
+ <Button onClick={() => onAuthenticate(activeConnection.connectionId, provider)} disabled={loading}>
2053
+ {loading ? "Opening sign-in..." : activeConnection.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
2054
</Button>
2055
) : null}
2056
+ {authenticationPending && !authenticationTimedOut ? (
2057
+ <Button variant="ghost" onClick={onCancelAuthentication}>Cancel authentication</Button>
2058
+ ) : null}
2059
+ {authenticationPending && authenticationTimedOut ? (
2060
+ <>
2061
+ <Button variant="secondary" onClick={onContinueAuthentication}>Continue checking</Button>
2062
+ {activeConnection ? <Button onClick={() => {
2063
+ onCancelAuthentication();
2064
+ void onAuthenticate(activeConnection.connectionId, provider);
2065
+ }}>Try again</Button> : null}
2066
+ <Button variant="ghost" onClick={onCancelAuthentication}>Cancel</Button>
2067
+ </>
2068
+ ) : null}
2069
+ {activeConnection && connected ? <Button variant="secondary" disabled>Manage</Button> : null}
2070
+ {activeConnection && (!partnerUnavailable || meaningfulPersistedLinkage) ? (
2071
+ <Button variant="ghost" onClick={() => onDisconnect(activeConnection.connectionId)} disabled={loading}>Disconnect</Button>
2072
+ ) : null}
2073
+ {developerIndividualMode ? (
2074
+ <Button variant="secondary" onClick={() => setDeveloperProvider(
2075
+ developerProvider === provider.brokerType ? null : provider.brokerType
2076
+ )} disabled={loading}>Configure DEV API</Button>
2077
+ ) : null}
2078
+ {provider.manualImportSupported ? (
2079
+ <Button variant="secondary" onClick={() => {
2080
+ setImportProvider(importProvider === provider.brokerType ? null : provider.brokerType);
2081
+ setImportFile(null); setImportPreview(null); setImportError(null); setImportName("");
2082
+ }} disabled={loading}>Import portfolio</Button>
2083
+ ) : null}
2084
+ </div>
2085
+ {developerIndividualMode && developerProvider === provider.brokerType ? (
2086
+ <form className="broker-developer-form" onSubmit={async (event) => {
2087
+ event.preventDefault();
2088
+ await onConfigureIndividual(provider, developerKey, developerSecret);
2089
+ setDeveloperKey("");
2090
+ setDeveloperSecret("");
2091
+ setDeveloperProvider(null);
2092
+ }}>
2093
+ <p>Developer/test individual API setup. Credentials are stored write-only.</p>
2094
+ <label>API key<input type="password" autoComplete="off" value={developerKey} onChange={(event) => setDeveloperKey(event.target.value)} required /></label>
2095
+ <label>API secret<input type="password" autoComplete="new-password" value={developerSecret} onChange={(event) => setDeveloperSecret(event.target.value)} required /></label>
2096
+ <Button type="submit" disabled={loading}>Save DEV credentials</Button>
2097
+ </form>
2098
+ ) : null}
2099
+ {provider.manualImportSupported && importProvider === provider.brokerType ? (
2100
+ <div className="import-modal-backdrop" role="presentation">
2101
+ <section className="import-modal" role="dialog" aria-modal="true" aria-label={`Import ${provider.displayName} Portfolio`}>
2102
+ <header className="import-modal-header"><div><span>Step {importPreview ? "2" : "1"} of 2</span><h2>Import {provider.displayName} Portfolio</h2></div><button className="icon-button" aria-label="Close import" onClick={() => setImportProvider(null)}><X size={20} /></button></header>
2103
+ {provider.manualImportParserStatus !== "SUPPORTED" ? (
2104
+ <p role="status">This broker portfolio file format is not yet supported. No columns have been guessed.</p>
2105
+ ) : (
2106
+ <>
2107
+ <p className="import-section-label">Select statement</p>
2108
+ <label className="import-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
2109
+ event.preventDefault(); const selected = event.dataTransfer.files[0] ?? null;
2110
+ if (!selected || !selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024) {
2111
+ setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
2112
+ }
2113
+ setImportFile(selected); setImportPreview(null); setImportError(null);
2114
+ }}><UploadCloud size={32} /><strong>Drag & drop your CSV here</strong><span>or choose an ICICI/HDFC equity portfolio CSV</span><span className="button button-secondary">Choose CSV file</span><input type="file" accept=".csv,text/csv" onChange={(event) => {
2115
+ const selected = event.target.files?.[0] ?? null;
2116
+ if (selected && (!selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024)) {
2117
+ setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
2118
+ }
2119
+ setImportFile(selected); setImportPreview(null); setImportError(null);
2120
+ }} /></label>
2121
+ {importFile ? <p className="import-file"><CheckCircle2 size={18} /> <strong>{importFile.name.replace(/[\\/]/g, "")}</strong></p> : null}
2122
+ {importFile && !detectedImportAccount(importFile.name, provider.brokerType) ? <label>Portfolio name<input value={importName} onChange={(event) => setImportName(event.target.value)} /></label> : importFile ? <p>Account detected: <strong>{detectedImportAccount(importFile.name, provider.brokerType)}</strong></p> : null}
2123
+ <Button variant="secondary" disabled={!importFile || importBusy} onClick={async () => {
2124
+ if (!importFile) return;
2125
+ setImportBusy(true); setImportError(null);
2126
+ try { setImportPreview(await portfolioApi.previewImport(provider.brokerType, importFile, importName)); }
2127
+ catch (error) { setImportError(getApiFailure(error).message); }
2128
+ finally { setImportBusy(false); }
2129
+ }}>{importBusy ? "Reading..." : "Preview portfolio"}</Button>
2130
+ {importError ? <p className="import-error" role="alert">{importError}</p> : null}
2131
+ {importPreview ? <div className="import-preview">
2132
+ <p><strong>{importPreview.updatesExistingPortfolio ? `Update existing portfolio ${importPreview.portfolioName}` : `Portfolio: ${importPreview.portfolioName}`}</strong></p>
2133
+ <p>Broker: {provider.displayName} · Currency: {importPreview.currency}</p>
2134
+ {importPreview.statementAt ? <p>Statement date/time: {new Date(importPreview.statementAt).toLocaleString()}</p> : null}
2135
+ <p>Rows detected: {importPreview.rowsDetected} · Valid holdings: {importPreview.validHoldings} · Rejected rows: {importPreview.rejectedRows}</p>
2136
+ <p>Columns mapped: {importPreview.columnsMapped.join(", ")}</p>
2137
+ <div className="import-table-frame"><table className="import-table"><thead><tr><th>Company</th><th>Symbol</th><th>Quantity</th><th>Avg cost</th><th>Statement price</th><th>Market value</th><th>P&L</th></tr></thead><tbody>{importPreview.holdings.map((holding, index) => <tr key={`${holding.symbol}-${index}`}><td>{holding.companyName}</td><td>{holding.symbol}</td><td>{holding.quantity}</td><td>{formatMoney(holding.averageCost, importPreview.currency)}</td><td>{formatMoney(holding.importedPrice, importPreview.currency)}</td><td>{formatMoney(holding.marketValue, importPreview.currency)}</td><td>{formatMoney(holding.unrealizedPnl, importPreview.currency)}</td></tr>)}</tbody></table></div>
2138
+ {importPreview.issues.length ? <ul>{importPreview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul> : null}
2139
+ <Button disabled={importBusy || importPreview.validHoldings === 0} onClick={async () => {
2140
+ if (!importFile) return;
2141
+ setImportBusy(true); setImportError(null);
2142
+ try {
2143
+ const result = await portfolioApi.confirmImport(provider.brokerType, importFile, importName);
2144
+ await onImportComplete(result.portfolioId);
2145
+ setImportSuccess(true);
2146
+ window.setTimeout(() => setImportSuccess(false), 3500);
2147
+ setImportProvider(null); setImportFile(null); setImportPreview(null); setImportName("");
2148
+ } catch (error) { setImportError(getApiFailure(error).message); }
2149
+ finally { setImportBusy(false); }
2150
+ }}>{importBusy ? "Importing..." : importPreview.updatesExistingPortfolio ? "Update Portfolio" : "Confirm Import"}</Button>
2151
+ </div> : null}
2152
+ </>
2153
+ )}
2154
+ </section></div>
2155
+ ) : null}
2156
</div>
1048
- </Card>
1049
- );
2157
+ </Card>
2158
+ );
2159
})}
1051
- <Card className="wide-panel">
1052
- <EmptyState title="Real broker connections are not enabled" message="Interactive Brokers and ICICI Direct explicitly report provider not configured. The UI never asks for broker passwords." />
1053
- </Card>
1054
- </div>
2160
+ </div>{importSuccess ? <div className="success-toast" role="status"><CheckCircle2 size={18} /> Portfolio imported successfully</div> : null}</div>
2161
);
2162
}
2163
2164
+function detectedImportAccount(filename: string, brokerType: string): string | null {
2165
+ const clean = filename.replace(/ \(\d+\)(?=\.csv$)/i, "");
2166
+ const match = brokerType === "ICICI_DIRECT" ? clean.match(/^(\d+)_PortFolioEqtSummary\.csv$/i)
2167
+ : brokerType === "HDFC_SECURITIES" ? clean.match(/^Invest Right Equity Portfolio_(\d+)\.csv$/i) : null;
2168
+ return match?.[1] ?? null;
2169
+}
2170
+
2171
+function legacyCompositeCompanyName(value?: string | null): string | null {
2172
+ const segments = (value ?? "").split("/").map((segment) => segment.trim());
2173
+ return segments.length >= 3 && segments[2] ? segments[2] : null;
2174
+}
2175
+
2176
+function researchCompanyName(position: PortfolioPosition, resolved?: PortfolioResearchCompany): string {
2177
+ const instrument = position.instrument;
2178
+ const customName = position.customDisplayName?.trim();
2179
+ if (customName) return customName;
2180
+ const ticker = (resolved?.ticker ?? instrument.ticker).trim();
2181
+ const structuredName = instrument.companyName?.trim();
2182
+ if (structuredName && structuredName.toUpperCase() !== ticker.toUpperCase()
2183
+ && !legacyCompositeCompanyName(structuredName)) return structuredName;
2184
+ const trustedName = instrument.canonicalName?.trim() || resolved?.companyName?.trim();
2185
+ if (trustedName && trustedName.toUpperCase() !== ticker.toUpperCase()
2186
+ && !legacyCompositeCompanyName(trustedName)) return trustedName;
2187
+ for (const candidate of [position.displayName, structuredName, trustedName, instrument.brokerDescription]) {
2188
+ const parsed = legacyCompositeCompanyName(candidate);
2189
+ if (parsed) return parsed;
2190
+ }
2191
+ return ticker || instrument.brokerSymbol?.trim() || "Unknown company";
2192
+}
2193
+
2194
function ResearchView({
1059
- companies,
2195
+ positions,
2196
+ selectedPortfolioId,
2197
+ portfolioResearchSummary,
2198
+ portfolioResearchLoading,
2199
selectedInstrumentId,
2200
onSelectInstrument,
2201
summary,
2204
impact,
2205
onEventType,
2206
onImpact,
1068
- onRefresh
2207
+ onRefresh,
2208
+ onPortfolioRefresh
2209
}: {
1070
- companies: ResearchProfile[];
2210
+ positions: PortfolioPosition[];
2211
+ selectedPortfolioId: string;
2212
+ portfolioResearchSummary: PortfolioResearchSummary | null;
2213
+ portfolioResearchLoading: boolean;
2214
selectedInstrumentId: string;
2215
onSelectInstrument: (value: string) => void;
2216
summary: ResearchSummary | null;
2220
onEventType: (value: string) => void;
2221
onImpact: (value: string) => void;
2222
onRefresh: () => Promise<void>;
2223
+ onPortfolioRefresh: () => Promise<void>;
2224
}) {
2225
+ const [openSection, setOpenSection] = useState<ResearchSectionId | null>(null);
2226
+ const [openEventId, setOpenEventId] = useState<string | null>(null);
2227
const filteredEvents = (summary?.recentEvents ?? []).filter((event) => {
2228
return (!eventType || event.eventType === eventType) && (!impact || event.impact === impact);
2229
});
2230
const eventTypes = [...new Set((summary?.recentEvents ?? []).map((event) => event.eventType))].sort();
2231
const impacts = [...new Set((summary?.recentEvents ?? []).map((event) => event.impact))].sort();
2232
+ const documentsById = new Map((summary?.documents ?? []).map((document) => [document.documentId, document]));
2233
+ const researchSections = summary ? getResearchSections(summary, filteredEvents) : [];
2234
+ const selectedPortfolioResearchCompany =
2235
+ portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedInstrumentId) ?? null;
2236
+ const researchOptions = useMemo(() => {
2237
+ const resolvedByHoldingKey = new Map<string, PortfolioResearchCompany>();
2238
+ const tickerCounts = new Map<string, number>();
2239
+ for (const company of portfolioResearchSummary?.companies ?? []) {
2240
+ const ticker = (company.ticker ?? "").toUpperCase();
2241
+ if (ticker) {
2242
+ tickerCounts.set(ticker, (tickerCounts.get(ticker) ?? 0) + 1);
2243
+ }
2244
+ }
2245
+ for (const company of portfolioResearchSummary?.companies ?? []) {
2246
+ const ticker = (company.ticker ?? "").toUpperCase();
2247
+ const keys = [
2248
+ company.provider && company.providerInstrumentId ? `${company.provider}:${company.providerInstrumentId}` : "",
2249
+ company.isin ? `isin:${company.isin}` : "",
2250
+ `${company.ticker ?? ""}:${company.exchange ?? ""}`,
2251
+ ticker && tickerCounts.get(ticker) === 1 ? `ticker:${ticker}` : ""
2252
+ ].filter(Boolean);
2253
+ for (const key of keys) {
2254
+ resolvedByHoldingKey.set(key.toUpperCase(), company);
2255
+ }
2256
+ }
2257
+
2258
+ return positions.map((position) => {
2259
+ const instrument = position.instrument;
2260
+ const keys = [
2261
+ instrument.provider && instrument.providerInstrumentId ? `${instrument.provider}:${instrument.providerInstrumentId}` : "",
2262
+ instrument.isin ? `isin:${instrument.isin}` : "",
2263
+ `${instrument.ticker}:${instrument.exchange}`,
2264
+ `ticker:${instrument.ticker}`
2265
+ ].filter(Boolean);
2266
+ const resolved = keys.map((key) => resolvedByHoldingKey.get(key.toUpperCase())).find(Boolean);
2267
+ const status = resolved?.status ?? "COMPANY_NOT_RESOLVED";
2268
+ const displayTicker = resolved?.ticker ?? instrument.ticker;
2269
+ const displayExchange = resolved?.exchange ?? instrument.exchange;
2270
+ return {
2271
+ value: resolved?.instrumentId ?? instrument.instrumentId,
2272
+ companyName: researchCompanyName(position, resolved),
2273
+ ticker: displayTicker,
2274
+ exchange: displayExchange,
2275
+ status,
2276
+ loadable: Boolean(resolved?.instrumentId) && isResearchSummaryLoadable(status)
2277
+ };
2278
+ });
2279
+ }, [portfolioResearchSummary, positions]);
2280
+ const selectedResearchOption = researchOptions.find((option) => option.value === selectedInstrumentId);
2281
+
2282
+ function toggleSection(sectionId: ResearchSectionId) {
2283
+ setOpenSection((current) => (current === sectionId ? null : sectionId));
2284
+ }
2285
+
2286
+ function toggleEvent(eventId: string) {
2287
+ setOpenEventId((current) => (current === eventId ? null : eventId));
2288
+ }
2289
2290
return (
2291
<div className="research-layout">
2292
<Card className="wide-panel">
2293
+ <div className="panel-header">
2294
+ <div>
2295
+ <h2>Portfolio research</h2>
2296
+ <p>Company-level research status across current holdings.</p>
2297
+ </div>
2298
+ <Button variant="secondary" onClick={onPortfolioRefresh} disabled={portfolioResearchLoading || !selectedPortfolioId}>
2299
+ <RefreshCw size={16} />
2300
+ {portfolioResearchLoading ? "Refreshing..." : "Refresh portfolio research"}
2301
+ </Button>
2302
+ </div>
2303
+ {portfolioResearchSummary ? (
2304
+ <div className="portfolio-research-table" role="table" aria-label="Portfolio research summary">
2305
+ <div className="portfolio-research-row portfolio-research-head" role="row">
2306
+ <span role="columnheader">Company</span>
2307
+ <span role="columnheader">Catalyst</span>
2308
+ <span role="columnheader">Confidence</span>
2309
+ <span role="columnheader">Evidence</span>
2310
+ <span role="columnheader">Sources</span>
2311
+ <span role="columnheader">Status</span>
2312
+ </div>
2313
+ {portfolioResearchSummary.companies.map((company) => {
2314
+ const evidenceTotal = Object.keys(company.evidenceCoverage).length || 5;
2315
+ const evidenceCount = Object.values(company.evidenceCoverage).filter((value) => value !== "NO_EVIDENCE").length;
2316
+ return (
2317
+ <button
2318
+ className="portfolio-research-row portfolio-research-company"
2319
+ key={`${company.instrumentId ?? company.companyName}-${company.status}`}
2320
+ role="row"
2321
+ type="button"
2322
+ onClick={() => {
2323
+ if (company.instrumentId) {
2324
+ onSelectInstrument(company.instrumentId);
2325
+ }
2326
+ }}
2327
+ >
2328
+ <span role="cell">
2329
+ <strong>{company.companyName}</strong>
2330
+ <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small>
2331
+ </span>
2332
+ <span role="cell">{company.catalystScore ?? "N/A"}</span>
2333
+ <span role="cell">{company.confidence != null ? `${company.confidence}%` : "N/A"}</span>
2334
+ <span role="cell">
2335
+ {evidenceCount}/{evidenceTotal}
2336
+ </span>
2337
+ <span role="cell">
2338
+ {company.sourceCount} sources / {company.documentCount} docs
2339
+ </span>
2340
+ <span role="cell">
2341
+ <Badge tone={researchStatusTone(company.status)}>{company.status.replaceAll("_", " ")}</Badge>
2342
+ <small>{company.lastRefresh ? new Date(company.lastRefresh).toLocaleString() : company.mode}</small>
2343
+ </span>
2344
+ </button>
2345
+ );
2346
+ })}
2347
+ </div>
2348
+ ) : (
2349
+ <EmptyState title="No portfolio research run" message="Refresh portfolio research to summarize current holdings." />
2350
+ )}
2351
+ </Card>
2352
+
2353
+ <Card className="wide-panel research-sticky-panel">
2354
<div className="panel-header">
2355
<div>
2356
<h2>Research intelligence</h2>
1093
- <p>Structured evidence, catalyst scoring, and source citations. Final BUY/SELL recommendations are not implemented.</p>
2357
+ <p>Structured evidence, catalyst scoring, and source citations.</p>
2358
</div>
2359
<div className="research-actions">
2360
{summary?.demo ? <Badge tone="info">DEMO</Badge> : null}
1097
- <Button variant="secondary" onClick={onRefresh} disabled={loading || !selectedInstrumentId}>
2361
+ {summary && !summary.demo ? <Badge tone="positive">LIVE</Badge> : null}
2362
+ <Button
2363
+ variant="secondary"
2364
+ onClick={onRefresh}
2365
+ disabled={loading || !selectedInstrumentId || !canRefreshResearch(selectedPortfolioResearchCompany?.status)}
2366
+ >
2367
<RefreshCw size={16} />
2368
{loading ? "Refreshing..." : "Refresh research"}
2369
</Button>
2370
</div>
2371
</div>
2372
<div className="research-controls">
1104
- <label className="sort-control">
2373
+ <label className="sort-control research-company-control">
2374
<Search size={16} aria-hidden="true" />
2375
<span>Company</span>
1107
- <select value={selectedInstrumentId} onChange={(event) => onSelectInstrument(event.target.value)}>
1108
- {companies.map((company) => (
1109
- <option key={company.instrumentId} value={company.instrumentId}>
1110
- {company.ticker} · {company.exchange} · {company.companyName}
2376
+ <select
2377
+ className="research-company-select"
2378
+ value={selectedInstrumentId}
2379
+ title={selectedResearchOption?.companyName}
2380
+ onChange={(event) => onSelectInstrument(event.target.value)}
2381
+ >
2382
+ {researchOptions.map((option) => (
2383
+ <option key={`${option.value}-${option.companyName}`} value={option.value} title={option.companyName}>
2384
+ {option.companyName}
2385
</option>
2386
))}
2387
</select>
2388
+ {selectedResearchOption ? (
2389
+ <small className="research-company-meta">
2390
+ {[selectedResearchOption.ticker, selectedResearchOption.exchange].filter(Boolean).join(" · ")}
2391
+ </small>
2392
+ ) : null}
2393
</label>
2394
<label className="sort-control">
2395
<span>Event</span>
2434
<div>
2435
<h2>{summary.profile.companyName}</h2>
2436
<p>
1158
- {summary.profile.ticker} · {summary.profile.exchange} · {summary.profile.country} · {summary.profile.isin ?? "No ISIN"}
2437
+ {summary.profile.ticker} / {summary.profile.exchange} / {summary.profile.country} / {summary.profile.isin ?? "No ISIN"}
2438
</p>
2439
</div>
1161
- <Badge tone="neutral">No BUY/SELL rating</Badge>
1162
- </div>
1163
- <div className="research-tabs" role="tablist" aria-label="Research sections">
1164
- {["Overview", "Growth", "Orders & Backlog", "CAPEX & Capacity", "Customers", "Guidance", "News / Events", "Sources"].map((tab) => (
1165
- <span role="tab" aria-selected={tab === "Overview"} key={tab}>
1166
- {tab}
1167
- </span>
1168
- ))}
1169
- </div>
1170
- <div className="score-grid">
1171
- {Object.entries(summary.catalystScore.buckets).map(([label, value]) => (
1172
- <div className="score-row" key={label}>
1173
- <span>{label}</span>
1174
- <strong>{value}</strong>
1175
- <div className="bar-track" aria-hidden="true">
1176
- <span style={{ width: `${value}%` }} />
1177
- </div>
1178
- </div>
1179
- ))}
1180
- </div>
1181
- </Card>
1182
-
1183
- <section className="event-grid" aria-label="Research events">
1184
- {filteredEvents.length === 0 ? (
1185
- <Card className="wide-panel">
1186
- <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />
1187
- </Card>
1188
- ) : (
1189
- filteredEvents.map((event) => <ResearchEventCard event={event} key={event.eventId} />)
1190
- )}
1191
- </section>
1192
-
1193
- <Card className="wide-panel">
1194
- <div className="panel-header">
1195
- <div>
1196
- <h2>Sources</h2>
1197
- <p>Traceable citations for the extracted facts. Raw page bodies are not displayed.</p>
1198
- </div>
1199
- </div>
1200
- <div className="source-list">
1201
- {summary.documents.map((document) => (
1202
- <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
1203
- <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
1204
- <span>{document.sourceType} · {document.reliabilityLevel} · {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}</span>
1205
- </a>
1206
- ))}
2440
</div>
2441
+ <section className="research-accordion" aria-label="Research detail sections">
2442
+ {researchSections.map((section) => {
2443
+ const isOpen = openSection === section.id;
2444
+ const panelId = `research-section-${section.id}`;
2445
+ const buttonId = `research-section-button-${section.id}`;
2446
+
2447
+ return (
2448
+ <article className="research-accordion-item" key={section.id}>
2449
+ <h3>
2450
+ <button
2451
+ id={buttonId}
2452
+ className="research-accordion-button"
2453
+ type="button"
2454
+ aria-expanded={isOpen}
2455
+ aria-controls={panelId}
2456
+ onClick={() => toggleSection(section.id)}
2457
+ >
2458
+ <span>{section.title}</span>
2459
+ <span className="research-section-meta">{section.metricLabel}</span>
2460
+ <ChevronDown className="accordion-chevron" size={18} aria-hidden="true" />
2461
+ </button>
2462
+ </h3>
2463
+ <div
2464
+ id={panelId}
2465
+ className="research-accordion-panel"
2466
+ role="region"
2467
+ aria-labelledby={buttonId}
2468
+ hidden={!isOpen}
2469
+ >
2470
+ {section.id === "overview" ? (
2471
+ <ResearchOverview summary={summary} />
2472
+ ) : section.id === "news" ? (
2473
+ <ResearchEventRows
2474
+ events={section.events}
2475
+ documentsById={documentsById}
2476
+ openEventId={openEventId}
2477
+ onToggleEvent={toggleEvent}
2478
+ />
2479
+ ) : section.id === "sources" ? (
2480
+ <ResearchSources documents={summary.documents} />
2481
+ ) : section.events.length > 0 ? (
2482
+ <div className="research-detail-list">
2483
+ {section.events.map((event) => (
2484
+ <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} key={event.eventId} />
2485
+ ))}
2486
+ </div>
2487
+ ) : (
2488
+ <EmptyState title="No evidence in this section" message="No filtered research events currently map to this topic." />
2489
+ )}
2490
+ </div>
2491
+ </article>
2492
+ );
2493
+ })}
2494
+ </section>
2495
</Card>
2496
</>
2497
) : null}
2498
2499
{!loading && !summary ? (
2500
<Card className="wide-panel">
1214
- <EmptyState title="No research profile loaded" message="The research API is unavailable or has no fixture profiles." />
2501
+ <EmptyState
2502
+ title={researchEmptyTitle(selectedPortfolioResearchCompany?.status)}
2503
+ message={researchEmptyMessage(selectedPortfolioResearchCompany?.status)}
2504
+ />
2505
</Card>
2506
) : null}
2507
</div>
2508
);
2509
}
2510
1221
-function ResearchEventCard({ event }: { event: import("../lib/portfolio-api").ResearchEvent }) {
1222
- const impactTone = event.impact.includes("NEGATIVE") ? "negative" : event.impact.includes("POSITIVE") ? "positive" : event.impact === "UNCERTAIN" ? "warning" : "neutral";
2511
+function ResearchOverview({ summary }: { summary: ResearchSummary }) {
2512
+ const categoryRows = summary.catalystScore.categoryEvidence
2513
+ ? Object.entries(summary.catalystScore.categoryEvidence).map(([label, evidence]) => [label, evidence.score, evidence.status] as const)
2514
+ : Object.entries(summary.catalystScore.buckets).map(([label, value]) => [label, value, value === null ? "NO_EVIDENCE" : "NEUTRAL_EVIDENCE"] as const);
2515
+
2516
+ return (
2517
+ <div className="research-overview-grid">
2518
+ {categoryRows.map(([label, value, status]) => (
2519
+ <div className="score-row compact" key={label}>
2520
+ <span>{label}</span>
2521
+ <strong>{value ?? "N/A"}</strong>
2522
+ <div className="bar-track" aria-hidden="true">
2523
+ <span style={{ width: `${value ?? 0}%` }} />
2524
+ </div>
2525
+ <small>{status.replaceAll("_", " ")}</small>
2526
+ </div>
2527
+ ))}
2528
+ <div className="research-summary-note">
2529
+ <strong>{summary.demo ? "DEMO research data" : "Live research data"}</strong>
2530
+ <span>
2531
+ {summary.recentEvents.length} events / {summary.documents.length} documents / generated{" "}
2532
+ {new Date(summary.catalystScore.generatedAt).toLocaleDateString()}
2533
+ </span>
2534
+ </div>
2535
+ </div>
2536
+ );
2537
+}
2538
+
2539
+function ResearchEventRows({
2540
+ events,
2541
+ documentsById,
2542
+ openEventId,
2543
+ onToggleEvent
2544
+}: {
2545
+ events: ResearchEvent[];
2546
+ documentsById: Map<string, ResearchDocument>;
2547
+ openEventId: string | null;
2548
+ onToggleEvent: (eventId: string) => void;
2549
+}) {
2550
+ if (events.length === 0) {
2551
+ return <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />;
2552
+ }
2553
+
2554
return (
1224
- <Card className="research-event-card" as="article">
2555
+ <div className="research-event-rows">
2556
+ {events.map((event) => {
2557
+ const isOpen = openEventId === event.eventId;
2558
+ const panelId = `research-event-${event.eventId}`;
2559
+ const buttonId = `research-event-button-${event.eventId}`;
2560
+
2561
+ return (
2562
+ <article className="research-event-row" key={event.eventId}>
2563
+ <h4>
2564
+ <button
2565
+ id={buttonId}
2566
+ className="research-event-row-button"
2567
+ type="button"
2568
+ aria-expanded={isOpen}
2569
+ aria-controls={panelId}
2570
+ onClick={() => onToggleEvent(event.eventId)}
2571
+ >
2572
+ <span>
2573
+ <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
2574
+ <strong>{event.title}</strong>
2575
+ </span>
2576
+ <span>{event.eventType.replaceAll("_", " ")}</span>
2577
+ <ChevronDown className="accordion-chevron" size={16} aria-hidden="true" />
2578
+ </button>
2579
+ </h4>
2580
+ <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}>
2581
+ <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} />
2582
+ </div>
2583
+ </article>
2584
+ );
2585
+ })}
2586
+ </div>
2587
+ );
2588
+}
2589
+
2590
+function ResearchEventDetail({ event, document }: { event: ResearchEvent; document?: ResearchDocument }) {
2591
+ const supportingSources = event.supportingSources?.length
2592
+ ? event.supportingSources
2593
+ : [
2594
+ {
2595
+ publisher: document?.publisher ?? document?.sourceName ?? event.sourceType,
2596
+ url: event.sourceUrl,
2597
+ sourceType: event.sourceClassification ?? document?.sourceClassification ?? event.sourceType,
2598
+ publishedAt: event.publishedAt ?? event.eventDate ?? document?.publishedAt ?? null,
2599
+ retrievedAt: event.retrievedAt ?? document?.retrievedAt ?? event.detectedAt,
2600
+ reliability: event.reliability,
2601
+ sourceMode: event.sourceMode,
2602
+ documentId: event.sourceDocumentId,
2603
+ sourceName: document?.sourceName ?? event.sourceType,
2604
+ canonicalUrl: event.sourceUrl,
2605
+ independent: true
2606
+ }
2607
+ ];
2608
+ return (
2609
+ <div className="research-event-detail">
2610
<div className="panel-header compact">
2611
<div>
2612
<Badge tone="neutral">{event.eventType.replaceAll("_", " ")}</Badge>
1228
- <h2>{event.title}</h2>
2613
+ <h4>{event.title}</h4>
2614
</div>
1230
- <Badge tone={impactTone}>{event.impact.replaceAll("_", " ")}</Badge>
2615
+ <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
2616
</div>
2617
<div className="event-value-line">
2618
<strong>{event.monetaryOriginal ?? (event.capacityValue ? `${event.capacityValue} ${event.capacityUnit}` : event.percentageOriginal ?? "Value undisclosed")}</strong>
2634
</div>
2635
<div>
2636
<dt>Source</dt>
1252
- <dd>{event.sourceType}</dd>
2637
+ <dd>{document?.sourceName ?? event.sourceClassification ?? event.sourceType}</dd>
2638
+ </div>
2639
+ <div>
2640
+ <dt>Horizon</dt>
2641
+ <dd>{event.timeHorizon.replaceAll("_", " ")}</dd>
2642
</div>
2643
</dl>
2644
<blockquote>{event.rawEvidenceReference}</blockquote>
1256
- <a className="source-link" href={event.sourceUrl} target="_blank" rel="noreferrer">
1257
- Open source
1258
- </a>
1259
- </Card>
2645
+ <div className="supporting-sources">
2646
+ <strong>Supporting sources</strong>
2647
+ {supportingSources.map((source) => (
2648
+ <a className="source-link" href={source.url} target="_blank" rel="noreferrer" key={`${event.eventId}-${source.documentId}`}>
2649
+ <span>{source.publisher ?? source.sourceName}</span>
2650
+ <small>
2651
+ {source.sourceMode} / {source.sourceType.replaceAll("_", " ")} / {source.reliability} /{" "}
2652
+ {source.publishedAt ? new Date(source.publishedAt).toLocaleDateString() : "No publication date"} /{" "}
2653
+ {source.independent ? "Independent" : "Duplicate"}
2654
+ </small>
2655
+ </a>
2656
+ ))}
2657
+ </div>
2658
+ </div>
2659
+ );
2660
+}
2661
+
2662
+function ResearchSources({ documents }: { documents: ResearchDocument[] }) {
2663
+ if (documents.length === 0) {
2664
+ return <EmptyState title="No sources" message="No source documents are available for this research profile." />;
2665
+ }
2666
+
2667
+ return (
2668
+ <div className="source-list">
2669
+ {documents.map((document) => (
2670
+ <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
2671
+ <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
2672
+ <span>
2673
+ {document.sourceMode} / {(document.sourceClassification ?? document.sourceType).replaceAll("_", " ")} / {document.reliabilityLevel} /{" "}
2674
+ {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}
2675
+ </span>
2676
+ <small>
2677
+ {document.sourceName} / {document.status} / retrieved {new Date(document.retrievedAt).toLocaleDateString()} / hash{" "}
2678
+ {document.contentHash.slice(0, 12)}
2679
+ </small>
2680
+ </a>
2681
+ ))}
2682
+ </div>
2683
+ );
2684
+}
2685
+
2686
+function getResearchSections(summary: ResearchSummary, filteredEvents: ResearchEvent[]) {
2687
+ const orders = eventsMatching(filteredEvents, ["NEW_ORDER", "ORDER_BACKLOG_CHANGE", "MAJOR_CONTRACT", "GOVERNMENT_CONTRACT"]);
2688
+ const capex = eventsMatching(filteredEvents, ["CAPEX", "CAPACITY_EXPANSION", "NEW_FACILITY", "FACTORY_EXPANSION", "PROJECT_DELAY"]);
2689
+ const customers = eventsMatching(filteredEvents, ["NEW_CUSTOMER", "CUSTOMER_EXPANSION", "MAJOR_CUSTOMER", "CUSTOMER_LOSS"]);
2690
+ const guidance = eventsMatching(filteredEvents, ["GUIDANCE_RAISED", "GUIDANCE_LOWERED", "GUIDANCE_CUT", "GUIDANCE_MAINTAINED", "REVENUE_GUIDANCE", "MARGIN_GUIDANCE"]);
2691
+ const growth = filteredEvents.filter((event) =>
2692
+ ["GEOGRAPHIC_EXPANSION", "PARTNERSHIP", "PRODUCT_LAUNCH"].includes(event.eventType)
2693
);
2694
+
2695
+ return [
2696
+ { id: "overview" as const, title: "Overview", metricLabel: `Score ${summary.catalystScore.overallScore}`, events: filteredEvents },
2697
+ { id: "growth" as const, title: "Growth", metricLabel: categoryMetricLabel(summary, "Growth"), events: growth },
2698
+ { id: "orders" as const, title: "Orders & Backlog", metricLabel: categoryMetricLabel(summary, "Orders & Backlog"), events: orders },
2699
+ { id: "capex" as const, title: "CAPEX & Capacity", metricLabel: categoryMetricLabel(summary, "CAPEX & Capacity"), events: capex },
2700
+ { id: "customers" as const, title: "Customers", metricLabel: categoryMetricLabel(summary, "Customers"), events: customers },
2701
+ { id: "guidance" as const, title: "Guidance", metricLabel: categoryMetricLabel(summary, "Guidance"), events: guidance },
2702
+ { id: "news" as const, title: "News / Events", metricLabel: `Events ${filteredEvents.length}`, events: filteredEvents },
2703
+ { id: "sources" as const, title: "Sources", metricLabel: `Documents ${summary.documents.length}`, events: [] }
2704
+ ];
2705
+}
2706
+
2707
+function categoryMetricLabel(summary: ResearchSummary, category: string) {
2708
+ const evidence = summary.catalystScore.categoryEvidence?.[category];
2709
+ if (evidence?.status === "NO_EVIDENCE" || evidence?.score == null) {
2710
+ return "N/A / No evidence";
2711
+ }
2712
+ const count = evidence.independentSourceCount ?? evidence.sourceCount;
2713
+ const sources = count === 1 ? "1 source" : `${count} sources`;
2714
+ if (evidence.hasConflict || evidence.status === "MIXED_EVIDENCE") {
2715
+ return `Mixed evidence / Score ${evidence.score} / ${sources}`;
2716
+ }
2717
+ return `Score ${evidence.score} / ${sources}`;
2718
+}
2719
+
2720
+function eventsMatching(events: ResearchEvent[], eventTypes: string[]) {
2721
+ return events.filter((event) => eventTypes.includes(event.eventType));
2722
+}
2723
+
2724
+function impactTone(impact: string): "neutral" | "positive" | "negative" | "warning" | "info" {
2725
+ if (impact.includes("NEGATIVE")) {
2726
+ return "negative";
2727
+ }
2728
+ if (impact.includes("POSITIVE")) {
2729
+ return "positive";
2730
+ }
2731
+ if (impact === "UNCERTAIN") {
2732
+ return "warning";
2733
+ }
2734
+ return "neutral";
2735
+}
2736
+
2737
+function researchStatusTone(status: string): "neutral" | "positive" | "negative" | "warning" | "info" {
2738
+ if (status === "AVAILABLE") {
2739
+ return "positive";
2740
+ }
2741
+ if (status === "DEGRADED" || status === "RESEARCH_NOT_REFRESHED" || status === "NO_EVIDENCE") {
2742
+ return "warning";
2743
+ }
2744
+ if (status === "COMPANY_NOT_RESOLVED" || status === "RESEARCH_PROVIDER_UNAVAILABLE") {
2745
+ return "negative";
2746
+ }
2747
+ if (status === "RESEARCH_NOT_APPLICABLE") {
2748
+ return "info";
2749
+ }
2750
+ if (status?.startsWith("ETF_RESEARCH_")) {
2751
+ return status === "ETF_RESEARCH_AVAILABLE" ? "positive" : "info";
2752
+ }
2753
+ return "neutral";
2754
+}
2755
+
2756
+function isResearchSummaryLoadable(status?: string | null) {
2757
+ return status === "AVAILABLE" || status === "DEGRADED";
2758
+}
2759
+
2760
+function canRefreshResearch(status?: string | null) {
2761
+ return Boolean(status) && !status?.startsWith("ETF_RESEARCH_") && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE"].includes(status ?? "");
2762
+}
2763
+
2764
+function researchEmptyTitle(status?: string | null) {
2765
+ if (status === "ETF_RESEARCH_NOT_REFRESHED") {
2766
+ return "ETF research not refreshed";
2767
+ }
2768
+ if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
2769
+ return "ETF research unavailable";
2770
+ }
2771
+ if (status === "RESEARCH_NOT_APPLICABLE") {
2772
+ return "Research not applicable";
2773
+ }
2774
+ if (status === "COMPANY_NOT_RESOLVED") {
2775
+ return "Company not resolved";
2776
+ }
2777
+ if (status === "RESEARCH_NOT_REFRESHED") {
2778
+ return "Research not refreshed";
2779
+ }
2780
+ return "Research unavailable";
2781
+}
2782
+
2783
+function researchEmptyMessage(status?: string | null) {
2784
+ if (status === "ETF_RESEARCH_NOT_REFRESHED") {
2785
+ return "No shared public ETF research has been collected for this fund yet.";
2786
+ }
2787
+ if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
2788
+ return "No acceptable public ETF source was collected during the last refresh.";
2789
+ }
2790
+ if (status === "RESEARCH_NOT_APPLICABLE") {
2791
+ return "Company-level catalyst research is not applicable for this asset type.";
2792
+ }
2793
+ if (status === "COMPANY_NOT_RESOLVED") {
2794
+ return "This holding did not match a canonical research company.";
2795
+ }
2796
+ if (status === "RESEARCH_NOT_REFRESHED") {
2797
+ return "No shared public research has been collected for this company yet.";
2798
+ }
2799
+ return "Select a holding with available company research.";
2800
}
2801
2802
function SettingsView() {
2820
</div>
2821
<div>
2822
<h3>Risk indicators</h3>
1284
- <p>Portfolio risk presentation is prepared, but risk scoring is not implemented in Phase 2B.</p>
2823
+ <p>Portfolio risk scoring is not yet available.</p>
2824
</div>
2825
<div>
2826
<h3>Recommendation states</h3>