main
tsx 4,566 lines 225 KB
Raw
1 "use client";
2
3 /* eslint-disable react-hooks/set-state-in-effect -- these resets delimit authenticated async request lifecycles */
4
5 import {
6 Bell,
7 BriefcaseBusiness,
8 Building2,
9 ChevronDown,
10 Command,
11 Gauge,
12 LineChart,
13 Menu,
14 Moon,
15 RefreshCw,
16 Search,
17 Settings,
18 ShieldCheck,
19 SlidersHorizontal,
20 Sun,
21 TrendingDown,
22 TrendingUp,
23 UploadCloud,
24 CheckCircle2,
25 Eye,
26 EyeOff,
27 WalletCards,
28 X
29 } from "lucide-react";
30 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31 import { createPortal } from "react-dom";
32 import { OpportunityRadar } from "./opportunity-radar";
33 import { Backtesting } from "./backtesting";
34 import { frontendConfig } from "../config";
35 import {
36 type AuthenticatedUser,
37 type ApiFailure,
38 type BrokerConnection,
39 type BrokerProviderInfo,
40 type Portfolio,
41 type PortfolioHistory,
42 type PortfolioHistoryRange,
43 type PortfolioDashboard,
44 type MarketUniverseSector,
45 type SectorPerformance,
46 type SectorPerformanceStock,
47 type PortfolioListItem,
48 type PortfolioImportPreview,
49 type PortfolioPosition,
50 type PortfolioResearchCompany,
51 type PortfolioResearchSummary,
52 type FinancialResultPeriod,
53 type FinancialStatementPeriod,
54 type ProvenancedValue,
55 type PortfolioSummary,
56 type ResearchDocument,
57 type ResearchEvent,
58 type ResearchSummary,
59 type CatalystScore,
60 type ResearchReadiness,
61 type ResearchReadinessRequirement,
62 type StockRuleEngineAnalysis,
63 type ResearchWatchlist,
64 type WatchlistResearchInstrument,
65 type WatchlistResearchPresentation,
66 type ResearchInstrumentMatch,
67 brokerApi,
68 authApi,
69 portfolioApi,
70 researchApi
71 } from "../lib/portfolio-api";
72 import {
73 type MarketIntelligenceSelection,
74 formatSignedPerformancePct,
75 marketIntelligenceSelection,
76 performanceDirectionLabel,
77 performanceRowTone,
78 regionalWatchlistName
79 } from "../lib/market-intelligence";
80 import { Badge, Button, Card, EmptyState, ErrorState, Field, MetricCard, Skeleton } from "./ui";
81
82 type View = "dashboard" | "portfolio" | "research" | "backtesting" | "brokers" | "settings";
83 type Theme = "system" | "light" | "dark";
84 type SortKey = "company" | "ticker" | "marketValue" | "profitLoss" | "allocation";
85 type ResearchSectionId = "overview" | "growth" | "orders" | "capex" | "customers" | "guidance" | "news" | "sources";
86 type ResearchContext =
87 | { kind: "PORTFOLIO"; portfolioId: string }
88 | { kind: "WATCHLIST"; watchlistId: string; name: string; region: SectorPerformance["region"] }
89 | { kind: "SEARCH"; region: SectorPerformance["region"]; globalInstrumentId: string }
90 | { kind: "WATCHLIST_PENDING"; name: string; region: SectorPerformance["region"] };
91 const portfolioHistoryRanges: PortfolioHistoryRange[] = ["1D", "5D", "1W", "1M", "1Y", "2Y", "3Y", "4Y", "5Y", "MAX"];
92 const brokerAuthenticationPollMs = 2000;
93 const brokerAuthenticationTimeoutMs = 5 * 60 * 1000;
94
95 const navItems: Array<{ id: View; label: string; icon: typeof Gauge }> = [
96 { id: "dashboard", label: "Dashboard", icon: Gauge },
97 { id: "portfolio", label: "Portfolio", icon: BriefcaseBusiness },
98 { id: "research", label: "Research", icon: Search },
99 { id: "backtesting", label: "Backtesting", icon: LineChart },
100 { id: "brokers", label: "Brokers", icon: WalletCards },
101 { id: "settings", label: "Settings", icon: Settings }
102 ];
103
104 function formatMoney(amount?: number, currency?: string) {
105 if (amount === undefined || !currency) {
106 return "--";
107 }
108
109 return new Intl.NumberFormat("en", {
110 style: "currency",
111 currency,
112 maximumFractionDigits: 2
113 }).format(amount);
114 }
115
116 function formatBackendMoney(money?: { amount: number; currency: string } | null) {
117 return money ? formatMoney(money.amount, money.currency) : "N/A";
118 }
119
120 function formatPercent(value?: number | null) {
121 if (value == null || !Number.isFinite(value)) {
122 return "N/A";
123 }
124
125 return `${value.toFixed(2)}%`;
126 }
127
128 function formatChangePercent(start?: number, end?: number) {
129 if (!start || end === undefined) {
130 return "--";
131 }
132 return formatPercent(((end - start) / start) * 100);
133 }
134
135 function getAllocationValue(summary: PortfolioSummary | null, position: PortfolioPosition) {
136 if (!summary?.totalMarketValue || !position.marketValue || summary.totalMarketValue.amount === 0) {
137 return null;
138 }
139
140 return (position.marketValue.amount / summary.totalMarketValue.amount) * 100;
141 }
142
143 function compareNullableDescending(left?: number | null, right?: number | null) {
144 if (left == null && right == null) return 0;
145 if (left == null) return 1;
146 if (right == null) return -1;
147 return right - left;
148 }
149
150 function valueTone(value?: number | null): "positive" | "negative" | undefined {
151 return value == null ? undefined : value >= 0 ? "positive" : "negative";
152 }
153
154 function getApiFailure(error: unknown): ApiFailure {
155 if (typeof error === "object" && error !== null && "message" in error) {
156 return error as ApiFailure;
157 }
158
159 return { message: "The portfolio API is not reachable. Confirm the gateway or portfolio service is running." };
160 }
161
162 function marketEnsureErrorCategory(error: unknown): string {
163 if (typeof error === "object" && error !== null && "status" in error) {
164 const status = (error as { status?: unknown }).status;
165 if (typeof status === "number") return `HTTP_${status}`;
166 }
167 return error instanceof TypeError ? "NETWORK_OR_CLIENT" : "UNKNOWN";
168 }
169
170 function hasRealBrokerPositions(positions: PortfolioPosition[]) {
171 return positions.some((position) => position.dataFreshness === "REAL_BROKER");
172 }
173
174 function selectedPortfolioStorageKey(userId: string) {
175 return `aip.selectedPortfolioId.${userId}`;
176 }
177
178 function rememberSelectedPortfolioId(userId: string | undefined, portfolioId: string) {
179 if (!userId || typeof window === "undefined") {
180 return;
181 }
182 if (portfolioId) {
183 window.localStorage.setItem(selectedPortfolioStorageKey(userId), portfolioId);
184 } else {
185 window.localStorage.removeItem(selectedPortfolioStorageKey(userId));
186 }
187 }
188
189 function storedSelectedPortfolioId(userId: string | undefined) {
190 if (!userId || typeof window === "undefined") {
191 return "";
192 }
193 return window.localStorage.getItem(selectedPortfolioStorageKey(userId)) ?? "";
194 }
195
196 function portfolioSourceLabels(positions: PortfolioPosition[]) {
197 if (positions.some((position) => position.sourceType === "MANUAL_CSV_IMPORT")) {
198 return {
199 badge: "Imported positions", syncMeta: "CSV position snapshot", totalProfitLoss: "Unrealized P/L",
200 returnLabel: "Unrealized return", marketValue: "Latest market value", costBasis: "Acquisition cost",
201 unrealizedProfitLoss: "Unrealized P/L", lastUpdated: "from latest accepted public quote",
202 source: "CSV positions with public market data", syncButton: "Refresh prices",
203 emptyMessage: "Import a broker statement to populate this view.", sortMarketValue: "Latest market value",
204 sortProfitLoss: "Unrealized P/L"
205 };
206 }
207 if (hasRealBrokerPositions(positions)) {
208 return {
209 badge: "Real broker data",
210 syncMeta: "IBKR broker sync",
211 totalProfitLoss: "Total P/L",
212 returnLabel: "Return",
213 marketValue: "Market value",
214 costBasis: "Cost basis",
215 unrealizedProfitLoss: "Unrealized P/L",
216 lastUpdated: "from latest IBKR sync",
217 source: "Interactive Brokers",
218 syncButton: "Sync IBKR broker",
219 emptyMessage: "Sync a broker account to populate this view.",
220 sortMarketValue: "Market value",
221 sortProfitLoss: "P/L"
222 };
223 }
224
225 return {
226 badge: "Demo data",
227 syncMeta: "Demo broker sync",
228 totalProfitLoss: "Demo total P/L",
229 returnLabel: "Demo return",
230 marketValue: "Demo market value",
231 costBasis: "Demo cost basis",
232 unrealizedProfitLoss: "Demo unrealized P/L",
233 lastUpdated: "from latest mock sync",
234 source: "Broker demo data",
235 syncButton: "Sync mock broker",
236 emptyMessage: "Sync a mock broker account to populate this view.",
237 sortMarketValue: "Demo market value",
238 sortProfitLoss: "Demo P/L"
239 };
240 }
241
242 export function InvestmentWorkspace() {
243 const [accessToken, setAccessToken] = useState<string | null>(() =>
244 typeof window === "undefined" ? null : window.localStorage.getItem("aip.accessToken")
245 );
246 const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(() => {
247 if (typeof window === "undefined") {
248 return null;
249 }
250 const storedUser = window.localStorage.getItem("aip.user");
251 return storedUser ? (JSON.parse(storedUser) as AuthenticatedUser) : null;
252 });
253 const [authLoading, setAuthLoading] = useState(false);
254 const marketEnsureAuthReady = authenticatedUser !== null && Boolean(accessToken);
255 const [view, setView] = useState<View>("dashboard");
256 const [theme, setTheme] = useState<Theme>("system");
257 const [sidebarOpen, setSidebarOpen] = useState(false);
258 const [portfolios, setPortfolios] = useState<PortfolioListItem[]>([]);
259 const [portfolioDashboard, setPortfolioDashboard] = useState<PortfolioDashboard | null>(null);
260 const [sectorPerformance, setSectorPerformance] = useState<SectorPerformance | null>(null);
261 const [sectorPerformanceRegion, setSectorPerformanceRegion] = useState<SectorPerformance["region"]>("EUROPE");
262 const [sectorPerformanceSector, setSectorPerformanceSector] = useState("");
263 const [sectorOptions, setSectorOptions] = useState<MarketUniverseSector[]>([]);
264 const [sectorOptionsRegion, setSectorOptionsRegion] = useState<SectorPerformance["region"] | null>(null);
265 const [sectorOptionsLoading, setSectorOptionsLoading] = useState(false);
266 const [sectorPerformancePeriod, setSectorPerformancePeriod] = useState<SectorPerformance["period"]>("WEEK");
267 const [portfolioScope, setPortfolioScope] = useState<"ALL" | string>("ALL");
268 const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | undefined>();
269 const [selectedPortfolioId, setSelectedPortfolioId] = useState<string>("");
270 const [summary, setSummary] = useState<PortfolioSummary | null>(null);
271 const [positions, setPositions] = useState<PortfolioPosition[]>([]);
272 const [portfolioHistory, setPortfolioHistory] = useState<PortfolioHistory | null>(null);
273 const [portfolioHistoryRange, setPortfolioHistoryRange] = useState<PortfolioHistoryRange>("1M");
274 const [portfolioHistoryLoading, setPortfolioHistoryLoading] = useState(false);
275 const [loading, setLoading] = useState(false);
276 const [syncing, setSyncing] = useState(false);
277 const [creating, setCreating] = useState(false);
278 const [error, setError] = useState<ApiFailure | null>(null);
279 const [newPortfolioName, setNewPortfolioName] = useState("My Global Portfolio");
280 const [newPortfolioCurrency, setNewPortfolioCurrency] = useState("EUR");
281 const [searchText, setSearchText] = useState("");
282 const [sortKey, setSortKey] = useState<SortKey>("marketValue");
283 const [brokerProviders, setBrokerProviders] = useState<BrokerProviderInfo[]>([]);
284 const [brokerConnections, setBrokerConnections] = useState<BrokerConnection[]>([]);
285 const [brokerLoading, setBrokerLoading] = useState(false);
286 const [brokerErrors, setBrokerErrors] = useState<Record<string, string>>({});
287 const [authenticatingBroker, setAuthenticatingBroker] = useState<string | null>(null);
288 const [brokerAuthenticationTimedOut, setBrokerAuthenticationTimedOut] = useState(false);
289 const brokerAuthPopupRef = useRef<Window | null>(null);
290 const brokerAuthPollRef = useRef<number | null>(null);
291 const brokerAuthPollBusyRef = useRef(false);
292 const pendingBrokerAuthRef = useRef<{ connectionId: string; provider: string } | null>(null);
293 const brokerAuthStartedAtRef = useRef(0);
294 const ibkrBootstrapSyncsRef = useRef(new Map<string, Promise<boolean>>());
295 const [selectedResearchInstrumentId, setSelectedResearchInstrumentId] = useState("");
296 const [selectedMarketIntelligenceStock, setSelectedMarketIntelligenceStock] = useState<SectorPerformanceStock | null>(null);
297 const [selectedMarketIntelligenceRegion, setSelectedMarketIntelligenceRegion] = useState<SectorPerformance["region"] | null>(null);
298 const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null);
299 const [researchLoading, setResearchLoading] = useState(false);
300 const [portfolioResearchSummary, setPortfolioResearchSummary] = useState<PortfolioResearchSummary | null>(null);
301 const [portfolioResearchLoading, setPortfolioResearchLoading] = useState(false);
302 const [portfolioResearchError, setPortfolioResearchError] = useState<string | null>(null);
303 const [researchReadinessDialog, setResearchReadinessDialog] = useState<{
304 globalInstrumentId: string;
305 companyName: string;
306 } | null>(null);
307 const [researchReadiness, setResearchReadiness] = useState<ResearchReadiness | null>(null);
308 const [researchReadinessLoading, setResearchReadinessLoading] = useState(false);
309 const [researchReadinessError, setResearchReadinessError] = useState<string | null>(null);
310 const [ensuringResearchRequirements, setEnsuringResearchRequirements] = useState<string[]>([]);
311 const [stockRuleEngineAnalysis, setStockRuleEngineAnalysis] = useState<StockRuleEngineAnalysis | null>(null);
312 const [stockRuleEngineLoading, setStockRuleEngineLoading] = useState(false);
313 const [stockRuleEngineError, setStockRuleEngineError] = useState<string | null>(null);
314 const [researchEventType, setResearchEventType] = useState("");
315 const [researchImpact, setResearchImpact] = useState("");
316 const [watchlists, setWatchlists] = useState<ResearchWatchlist[]>([]);
317 const [savedWatchlistIds, setSavedWatchlistIds] = useState<Record<string, string[]>>({});
318 const [watchlistMutation, setWatchlistMutation] = useState<string | null>(null);
319 const [watchlistActionError, setWatchlistActionError] = useState<string | null>(null);
320 const [watchlistRevision, setWatchlistRevision] = useState(0);
321 const watchlistMutationRef = useRef(false);
322 const [researchContext, setResearchContext] = useState<ResearchContext>({ kind: "PORTFOLIO", portfolioId: "" });
323 const [watchlistResearch, setWatchlistResearch] = useState<WatchlistResearchPresentation | null>(null);
324 const [watchlistResearchLoading, setWatchlistResearchLoading] = useState(false);
325 const [watchlistResearchError, setWatchlistResearchError] = useState<string | null>(null);
326 const positionsRef = useRef<PortfolioPosition[]>([]);
327 const researchContextRef = useRef<ResearchContext>(researchContext);
328
329 useEffect(() => {
330 positionsRef.current = positions;
331 }, [positions]);
332
333 useEffect(() => {
334 researchContextRef.current = researchContext;
335 }, [researchContext]);
336
337 const clearUserScopedState = useCallback(() => {
338 rememberSelectedPortfolioId(authenticatedUser?.userId, "");
339 setPortfolios([]);
340 setPortfolioDashboard(null);
341 setPortfolioScope("ALL");
342 setSelectedPortfolio(undefined);
343 setSelectedPortfolioId("");
344 setSummary(null);
345 setPositions([]);
346 setPortfolioHistory(null);
347 setBrokerConnections([]);
348 setBrokerProviders([]);
349 setSelectedResearchInstrumentId("");
350 setSelectedMarketIntelligenceStock(null);
351 setSelectedMarketIntelligenceRegion(null);
352 setWatchlists([]);
353 setSavedWatchlistIds({});
354 setWatchlistActionError(null);
355 setResearchContext({ kind: "PORTFOLIO", portfolioId: "" });
356 setWatchlistResearch(null);
357 setWatchlistResearchError(null);
358 setResearchReadinessDialog(null);
359 setResearchReadiness(null);
360 setResearchReadinessError(null);
361 setStockRuleEngineAnalysis(null);
362 setStockRuleEngineError(null);
363 setResearchSummary(null);
364 setPortfolioResearchSummary(null);
365 setSearchText("");
366 }, [authenticatedUser?.userId]);
367
368 useEffect(() => {
369 document.documentElement.dataset.theme = theme;
370 }, [theme]);
371
372 useEffect(() => {
373 const params = new URLSearchParams(window.location.search);
374 const apiSession = params.get("API_Session") ?? params.get("api_session");
375 const requestToken = params.get("request_token");
376 const pending = window.localStorage.getItem("aip.pendingBrokerAuthentication");
377 if ((!apiSession && !requestToken) || !pending || !accessToken) return;
378 const value = JSON.parse(pending) as { connectionId: string; provider: string };
379 const completion = apiSession && value.provider === "ICICI_DIRECT"
380 ? brokerApi.attachIciciSession(value.connectionId, apiSession)
381 : requestToken && value.provider === "HDFC_SECURITIES"
382 ? brokerApi.attachHdfcRequestToken(value.connectionId, requestToken)
383 : null;
384 if (!completion) return;
385 void completion.then(() => {
386 window.localStorage.removeItem("aip.pendingBrokerAuthentication");
387 if (window.opener) window.close();
388 else window.history.replaceState({}, "", window.location.pathname);
389 }).catch(() => setBrokerErrors({}));
390 }, [accessToken]);
391
392 useEffect(() => {
393 function handleUnauthorized() {
394 setAccessToken(null);
395 setAuthenticatedUser(null);
396 clearUserScopedState();
397 setError({ message: "Your session expired. Sign in again." });
398 }
399
400 window.addEventListener("aip:unauthorized", handleUnauthorized);
401 return () => window.removeEventListener("aip:unauthorized", handleUnauthorized);
402 }, [clearUserScopedState]);
403
404 useEffect(() => {
405 function handleBrokerAuthentication(event: MessageEvent) {
406 if (event.origin !== window.location.origin || event.data?.type !== "aip:ibkr-authenticated") return;
407 const pending = pendingBrokerAuthRef.current;
408 if (!pending || pending.provider !== "IBKR") return;
409 void finishBrokerAuthentication(pending.connectionId);
410 }
411 window.addEventListener("message", handleBrokerAuthentication);
412 return () => window.removeEventListener("message", handleBrokerAuthentication);
413 });
414
415 useEffect(() => () => stopBrokerAuthenticationMonitoring(false), []);
416
417 useEffect(() => {
418 let cancelled = false;
419
420 async function loadPortfolios() {
421 setLoading(true);
422 setError(null);
423 try {
424 const dashboard = await portfolioApi.getDashboard();
425 const loaded = dashboard.portfolios;
426 if (cancelled) {
427 return;
428 }
429 setPortfolios(loaded);
430 setPortfolioDashboard(dashboard);
431 setSelectedPortfolioId((current) => {
432 const stored = storedSelectedPortfolioId(authenticatedUser?.userId);
433 const next = current && loaded.some((portfolio) => portfolio.portfolioId === current)
434 ? current
435 : stored && loaded.some((portfolio) => portfolio.portfolioId === stored)
436 ? stored
437 : loaded[0]?.portfolioId || "";
438 rememberSelectedPortfolioId(authenticatedUser?.userId, next);
439 return next;
440 });
441 if (loaded.length === 0) {
442 rememberSelectedPortfolioId(authenticatedUser?.userId, "");
443 setSelectedPortfolio(undefined);
444 setSummary(null);
445 setPositions([]);
446 }
447 } catch (err) {
448 if (!cancelled) {
449 setError(getApiFailure(err));
450 }
451 } finally {
452 if (!cancelled) {
453 setLoading(false);
454 }
455 }
456 }
457
458 if (accessToken && authenticatedUser) {
459 void loadPortfolios();
460 }
461 return () => {
462 cancelled = true;
463 };
464 }, [accessToken, authenticatedUser?.userId]);
465
466 useEffect(() => {
467 console.info("[AIP_MARKET_ENSURE]", { event: "EFFECT", authReady: marketEnsureAuthReady });
468 if (!marketEnsureAuthReady) return;
469
470 console.info("[AIP_MARKET_ENSURE]", { event: "DISPATCH" });
471 void portfolioApi.ensureMarketData("INDIA")
472 .then(() => console.info("[AIP_MARKET_ENSURE]", { event: "RESOLVED" }))
473 .catch((error: unknown) => console.info(
474 "[AIP_MARKET_ENSURE]",
475 { event: "REJECTED", category: marketEnsureErrorCategory(error) }
476 ));
477 }, [marketEnsureAuthReady]);
478
479 useEffect(() => {
480 if (!marketEnsureAuthReady) return;
481 let cancelled = false;
482 void researchApi.listWatchlists()
483 .then(async (values) => {
484 const memberships = await Promise.all(values.map(async (list) => {
485 const detail = await researchApi.getWatchlistResearch(list.watchlistId);
486 return [list.watchlistId, detail.instruments.map((item) => item.globalInstrumentId)] as const;
487 }));
488 if (!cancelled) {
489 setWatchlists(values);
490 setSavedWatchlistIds(Object.fromEntries(memberships));
491 }
492 })
493 .catch(() => { if (!cancelled) setWatchlistActionError("Saved watchlists could not be loaded. Please retry."); });
494 return () => { cancelled = true; };
495 }, [marketEnsureAuthReady]);
496
497 useEffect(() => {
498 setResearchContext((current) => current.kind === "PORTFOLIO"
499 ? { kind: "PORTFOLIO", portfolioId: selectedPortfolioId }
500 : current);
501 }, [selectedPortfolioId]);
502
503 useEffect(() => {
504 if (!accessToken || !authenticatedUser) return;
505 let cancelled = false;
506 setSectorOptions([]);
507 setSectorOptionsRegion(null);
508 setSectorPerformanceSector("");
509 setSectorPerformance(null);
510 setSectorOptionsLoading(true);
511 void portfolioApi.getMarketUniverseSectors(sectorPerformanceRegion)
512 .then((value) => {
513 if (cancelled) return;
514 setSectorOptions(value.sectors);
515 setSectorOptionsRegion(value.region);
516 setSectorPerformanceSector(value.sectors[0]?.name ?? "");
517 })
518 .catch(() => {
519 if (!cancelled) {
520 setSectorOptions([]);
521 setSectorOptionsRegion(sectorPerformanceRegion);
522 }
523 })
524 .finally(() => { if (!cancelled) setSectorOptionsLoading(false); });
525 return () => { cancelled = true; };
526 }, [accessToken, authenticatedUser, sectorPerformanceRegion]);
527
528 useEffect(() => {
529 const validSector = sectorOptionsRegion === sectorPerformanceRegion
530 && sectorOptions.some((option) => option.name === sectorPerformanceSector);
531 if (!accessToken || !authenticatedUser || !validSector) {
532 setSectorPerformance(null);
533 return;
534 }
535 let cancelled = false;
536 setSectorPerformance(null);
537 void portfolioApi.getSectorPerformance(sectorPerformanceRegion, sectorPerformanceSector, sectorPerformancePeriod)
538 .then((value) => { if (!cancelled) setSectorPerformance(value); })
539 .catch(() => { if (!cancelled) setSectorPerformance(null); });
540 return () => { cancelled = true; };
541 }, [accessToken, authenticatedUser, sectorOptions, sectorOptionsRegion, sectorPerformanceRegion, sectorPerformanceSector, sectorPerformancePeriod]);
542
543 useEffect(() => {
544 let cancelled = false;
545
546 async function loadResearchSummary() {
547 if (!selectedResearchInstrumentId) {
548 setResearchSummary(null);
549 return;
550 }
551 const selectedCompany = researchContext.kind === "WATCHLIST"
552 ? watchlistResearch?.instruments.find((value) => value.globalInstrumentId === selectedResearchInstrumentId)?.company
553 : portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedResearchInstrumentId);
554 if (selectedCompany && !canRefreshResearch(selectedCompany)) {
555 setResearchSummary(null);
556 setResearchLoading(false);
557 return;
558 }
559 setResearchLoading(true);
560 try {
561 const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
562 if (!cancelled) {
563 setResearchSummary(loaded);
564 }
565 } catch {
566 if (!cancelled) {
567 setResearchSummary(null);
568 }
569 } finally {
570 if (!cancelled) {
571 setResearchLoading(false);
572 }
573 }
574 }
575
576 void loadResearchSummary();
577 return () => {
578 cancelled = true;
579 };
580 }, [portfolioResearchSummary, researchContext, selectedResearchInstrumentId, watchlistResearch]);
581
582 useEffect(() => {
583 if (!marketEnsureAuthReady || researchContext.kind !== "WATCHLIST") {
584 setWatchlistResearch(null);
585 setWatchlistResearchError(null);
586 return;
587 }
588 let cancelled = false;
589 setWatchlistResearchLoading(true);
590 setWatchlistResearchError(null);
591 void researchApi.getWatchlistResearch(researchContext.watchlistId)
592 .then((value) => {
593 if (cancelled) return;
594 setWatchlistResearch(value);
595 setSelectedResearchInstrumentId((current) => value.instruments.some(
596 (item) => item.globalInstrumentId === current
597 ) ? current : value.instruments[0]?.globalInstrumentId ?? "");
598 })
599 .catch(() => {
600 if (!cancelled) {
601 setWatchlistResearch(null);
602 setWatchlistResearchError("Watchlist research is temporarily unavailable.");
603 }
604 })
605 .finally(() => { if (!cancelled) setWatchlistResearchLoading(false); });
606 return () => { cancelled = true; };
607 }, [marketEnsureAuthReady, researchContext, watchlistRevision]);
608
609 useEffect(() => {
610 let cancelled = false;
611
612 async function loadPortfolioResearchSummary() {
613 if (!selectedPortfolioId) {
614 setPortfolioResearchSummary(null);
615 setSelectedResearchInstrumentId("");
616 return;
617 }
618 setPortfolioResearchLoading(true);
619 setPortfolioResearchError(null);
620 try {
621 const loaded = await researchApi.getPortfolioSummary(selectedPortfolioId);
622 if (cancelled) {
623 return;
624 }
625 setPortfolioResearchSummary(loaded);
626 if (researchContextRef.current.kind === "PORTFOLIO") {
627 setSelectedResearchInstrumentId((current) => current
628 && loaded.companies.some((company) => company.instrumentId === current)
629 ? current : loaded.companies.find((company) => canRefreshResearch(company))?.instrumentId ?? "");
630 }
631 } catch {
632 if (!cancelled) {
633 setPortfolioResearchSummary(null);
634 setPortfolioResearchError("Portfolio research summary unavailable. Existing company research remains available.");
635 if (researchContextRef.current.kind === "PORTFOLIO") {
636 setSelectedResearchInstrumentId((current) => current || positionsRef.current.find(
637 (position) => position.instrument.globalInstrumentId
638 )?.instrument.globalInstrumentId || "");
639 }
640 }
641 } finally {
642 if (!cancelled) {
643 setPortfolioResearchLoading(false);
644 }
645 }
646 }
647
648 void loadPortfolioResearchSummary();
649 return () => {
650 cancelled = true;
651 };
652 }, [selectedPortfolioId]);
653
654 useEffect(() => {
655 let cancelled = false;
656
657 async function loadPortfolioDetail() {
658 setLoading(true);
659 setError(null);
660 try {
661 const [loadedPortfolio, loadedSummary, loadedPositions] = await Promise.all([
662 portfolioApi.getPortfolio(selectedPortfolioId),
663 portfolioApi.getSummary(selectedPortfolioId),
664 portfolioApi.getPositions(selectedPortfolioId)
665 ]);
666 if (!cancelled) {
667 setSelectedPortfolio(loadedPortfolio);
668 setSummary(loadedSummary);
669 setPositions(loadedPositions);
670 }
671 } catch (err) {
672 if (!cancelled) {
673 setError(getApiFailure(err));
674 setSummary(null);
675 setPositions([]);
676 }
677 } finally {
678 if (!cancelled) {
679 setLoading(false);
680 }
681 }
682 }
683
684 if (!selectedPortfolioId) {
685 return;
686 }
687
688 void loadPortfolioDetail();
689 return () => {
690 cancelled = true;
691 };
692 }, [selectedPortfolioId]);
693
694 useEffect(() => {
695 let cancelled = false;
696
697 async function loadPortfolioHistory() {
698 if (!selectedPortfolioId) {
699 return;
700 }
701 setPortfolioHistoryLoading(true);
702 try {
703 const loaded = await portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange);
704 if (!cancelled) {
705 setPortfolioHistory(loaded);
706 }
707 } catch {
708 if (!cancelled) {
709 setPortfolioHistory(null);
710 }
711 } finally {
712 if (!cancelled) {
713 setPortfolioHistoryLoading(false);
714 }
715 }
716 }
717
718 if (accessToken && authenticatedUser) {
719 void loadPortfolioHistory();
720 }
721 return () => {
722 cancelled = true;
723 };
724 }, [accessToken, selectedPortfolioId, portfolioHistoryRange]);
725
726 useEffect(() => {
727 let cancelled = false;
728
729 async function loadBrokers() {
730 setBrokerLoading(true);
731 try {
732 const [providers, connections] = await Promise.all([brokerApi.listBrokers(), brokerApi.listConnections()]);
733 if (!cancelled) {
734 setBrokerProviders(providers);
735 setBrokerConnections(connections);
736 }
737 } catch {
738 if (!cancelled) {
739 setBrokerProviders([]);
740 setBrokerConnections([]);
741 }
742 } finally {
743 if (!cancelled) {
744 setBrokerLoading(false);
745 }
746 }
747 }
748
749 if (accessToken && authenticatedUser) {
750 void loadBrokers();
751 }
752 return () => {
753 cancelled = true;
754 };
755 }, [accessToken]);
756
757 const sortedPositions = useMemo(() => {
758 const normalizedSearch = searchText.trim().toLowerCase();
759 return positions
760 .filter((position) => {
761 if (!normalizedSearch) {
762 return true;
763 }
764 return [
765 position.displayName,
766 position.instrument.companyName,
767 position.instrument.ticker,
768 position.instrument.isin,
769 position.instrument.exchange,
770 position.instrument.country
771 ]
772 .filter(Boolean)
773 .some((value) => String(value).toLowerCase().includes(normalizedSearch));
774 })
775 .sort((a, b) => {
776 switch (sortKey) {
777 case "company":
778 return a.displayName.localeCompare(b.displayName);
779 case "ticker":
780 return a.instrument.ticker.localeCompare(b.instrument.ticker);
781 case "profitLoss":
782 return compareNullableDescending(a.unrealizedProfitLoss?.amount, b.unrealizedProfitLoss?.amount);
783 case "allocation":
784 return compareNullableDescending(getAllocationValue(summary, a), getAllocationValue(summary, b));
785 case "marketValue":
786 default:
787 return compareNullableDescending(a.marketValue?.amount, b.marketValue?.amount);
788 }
789 });
790 }, [positions, searchText, sortKey, summary]);
791
792 async function updateHoldingDisplayName(position: PortfolioPosition, customDisplayName: string | null) {
793 const updated = await portfolioApi.updateHoldingDisplayName(position.portfolioId, position.positionId, customDisplayName);
794 setPositions((current) => current.map((item) => item.positionId === updated.positionId ? updated : item));
795 setPortfolioDashboard(await portfolioApi.getDashboard());
796 }
797
798 function clearMarketIntelligenceContext() {
799 setSelectedMarketIntelligenceStock(null);
800 setSelectedMarketIntelligenceRegion(null);
801 setWatchlistResearch(null);
802 setWatchlistResearchError(null);
803 setResearchContext({ kind: "PORTFOLIO", portfolioId: selectedPortfolioId });
804 setSelectedResearchInstrumentId(
805 portfolioResearchSummary?.companies.find((company) => canRefreshResearch(company))?.instrumentId ?? ""
806 );
807 setResearchSummary(null);
808 }
809
810 async function openResearchReadiness(
811 globalInstrumentId: string,
812 companyName: string
813 ) {
814 setResearchReadinessDialog({ globalInstrumentId, companyName });
815 setResearchReadiness(null);
816 setResearchReadinessError(null);
817 setResearchReadinessLoading(true);
818 try {
819 setResearchReadiness(await researchApi.getReadiness(globalInstrumentId));
820 } catch {
821 setResearchReadinessError("Research readiness is temporarily unavailable.");
822 } finally {
823 setResearchReadinessLoading(false);
824 }
825 }
826
827 async function findResearchData(requirements: string[]) {
828 const dialog = researchReadinessDialog;
829 if (!dialog || requirements.length === 0) return;
830 setEnsuringResearchRequirements(requirements);
831 setResearchReadinessError(null);
832 try {
833 setResearchReadiness(
834 await researchApi.ensureReadiness(dialog.globalInstrumentId, requirements)
835 );
836 setStockRuleEngineAnalysis(null);
837 } catch {
838 setResearchReadinessError("Targeted research acquisition could not be completed.");
839 } finally {
840 setEnsuringResearchRequirements([]);
841 }
842 }
843
844 async function runStockRuleEngineAnalysis(allowPartial: boolean) {
845 const dialog = researchReadinessDialog;
846 if (!dialog) return;
847 setStockRuleEngineLoading(true);
848 setStockRuleEngineError(null);
849 try {
850 setStockRuleEngineAnalysis(
851 await researchApi.analyze(dialog.globalInstrumentId, allowPartial)
852 );
853 } catch {
854 setStockRuleEngineError("Deterministic analysis could not be completed.");
855 } finally {
856 setStockRuleEngineLoading(false);
857 }
858 }
859
860 function openMarketIntelligenceResearch(selection: MarketIntelligenceSelection) {
861 const { stock, region } = selection;
862 setSelectedMarketIntelligenceStock(stock);
863 setSelectedMarketIntelligenceRegion(region);
864 setSelectedResearchInstrumentId(stock.globalInstrumentId);
865 setResearchContext({ kind: "SEARCH", region, globalInstrumentId: stock.globalInstrumentId });
866 void openResearchReadiness(stock.globalInstrumentId, stock.companyName);
867 void loadSearchPresentation(stock.globalInstrumentId, stock.companyName, region);
868 }
869
870 async function openRegionalWatchlist(region: SectorPerformance["region"]) {
871 setWatchlistActionError(null);
872 try {
873 const list = await researchApi.ensureDefaultWatchlist(region);
874 setWatchlists((current) => [...current.filter((item) => item.watchlistId !== list.watchlistId), list]);
875 setSelectedResearchInstrumentId("");
876 setResearchContext({ kind: "WATCHLIST", watchlistId: list.watchlistId, name: list.name, region: list.region });
877 } catch (error) {
878 setWatchlistActionError(getApiFailure(error).message);
879 }
880 }
881
882 async function addRankedStockToWatchlist(selection: MarketIntelligenceSelection) {
883 if (watchlistMutationRef.current || !selection.stock.globalInstrumentId?.trim()) return;
884 watchlistMutationRef.current = true;
885 setWatchlistMutation(selection.stock.globalInstrumentId);
886 setWatchlistActionError(null);
887 try {
888 const list = await researchApi.ensureDefaultWatchlist(selection.region);
889 await researchApi.addWatchlistInstrument(list.watchlistId, {
890 globalInstrumentId: selection.stock.globalInstrumentId,
891 sourcePeriod: selection.period,
892 sourcePerformancePct: selection.stock.performancePct,
893 });
894 const detail = await researchApi.getWatchlistResearch(list.watchlistId);
895 setWatchlists((current) => [...current.filter((item) => item.watchlistId !== list.watchlistId), detail.watchlist]);
896 setSavedWatchlistIds((current) => ({ ...current, [list.watchlistId]: detail.instruments.map((item) => item.globalInstrumentId) }));
897 setWatchlistRevision((value) => value + 1);
898 } catch (error) {
899 setWatchlistActionError(getApiFailure(error).message);
900 } finally {
901 watchlistMutationRef.current = false;
902 setWatchlistMutation(null);
903 }
904 }
905
906 async function removeWatchlistStock(globalInstrumentId: string) {
907 if (researchContext.kind !== "WATCHLIST" || watchlistMutationRef.current) return;
908 const { watchlistId } = researchContext;
909 watchlistMutationRef.current = true;
910 setWatchlistMutation(globalInstrumentId);
911 setWatchlistActionError(null);
912 try {
913 await researchApi.removeWatchlistInstrument(watchlistId, globalInstrumentId);
914 setSavedWatchlistIds((current) => ({ ...current, [watchlistId]: (current[watchlistId] ?? []).filter((id) => id !== globalInstrumentId) }));
915 setWatchlists((current) => current.map((list) => list.watchlistId === watchlistId ? { ...list, instrumentCount: Math.max(0, list.instrumentCount - 1) } : list));
916 setWatchlistRevision((value) => value + 1);
917 } catch (error) {
918 setWatchlistActionError(getApiFailure(error).message);
919 } finally {
920 watchlistMutationRef.current = false;
921 setWatchlistMutation(null);
922 }
923 }
924
925 function selectResearchContext(value: string) {
926 setSelectedMarketIntelligenceStock(null);
927 setSelectedMarketIntelligenceRegion(null);
928 setResearchSummary(null);
929 setSelectedResearchInstrumentId("");
930 if (value.startsWith("portfolio:")) {
931 const portfolioId = value.slice("portfolio:".length);
932 setResearchContext({ kind: "PORTFOLIO", portfolioId });
933 rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
934 setSelectedPortfolioId(portfolioId);
935 return;
936 }
937 const watchlistId = value.slice("watchlist:".length);
938 const watchlist = watchlists.find((candidate) => candidate.watchlistId === watchlistId);
939 if (watchlist) {
940 setResearchContext({
941 kind: "WATCHLIST", watchlistId: watchlist.watchlistId,
942 name: watchlist.name, region: watchlist.region,
943 });
944 }
945 }
946
947 const [searchQuery, setSearchQuery] = useState("");
948 const [searchResults, setSearchResults] = useState<ResearchInstrumentMatch[]>([]);
949 const [searchLoading, setSearchLoading] = useState(false);
950 const [searchError, setSearchError] = useState<string | null>(null);
951 const [searchPresentation, setSearchPresentation] = useState<PortfolioResearchCompany | null>(null);
952 const [searchSelectedMatch, setSearchSelectedMatch] = useState<ResearchInstrumentMatch | null>(null);
953 const searchWatchlistSaved = Boolean(searchSelectedMatch && watchlists.some((list) =>
954 list.systemDefault && list.region === searchSelectedMatch.region
955 && (savedWatchlistIds[list.watchlistId] ?? []).includes(searchSelectedMatch.globalInstrumentId)));
956 const searchPresentationRequest = useRef(0);
957 const [searchWatchlistBusy, setSearchWatchlistBusy] = useState(false);
958 const [searchWatchlistError, setSearchWatchlistError] = useState<string | null>(null);
959
960 async function loadSearchPresentation(
961 globalInstrumentId: string, _companyName: string, region: SectorPerformance["region"]
962 ) {
963 const requestId = ++searchPresentationRequest.current;
964 setSearchPresentation(null);
965 setResearchLoading(true);
966 try {
967 const presentation = await researchApi.getCompanyPresentation(globalInstrumentId, region);
968 if (requestId === searchPresentationRequest.current) setSearchPresentation(presentation);
969 } catch {
970 if (requestId === searchPresentationRequest.current) setSearchPresentation(null);
971 } finally {
972 if (requestId === searchPresentationRequest.current) setResearchLoading(false);
973 }
974 }
975
976 function handleSearchSelect(match: ResearchInstrumentMatch) {
977 setSelectedMarketIntelligenceStock(null);
978 setSelectedMarketIntelligenceRegion(null);
979 setSearchQuery("");
980 setSearchResults([]);
981 setSearchSelectedMatch(match);
982 setSelectedResearchInstrumentId(match.globalInstrumentId);
983 setResearchContext({ kind: "SEARCH", region: match.region, globalInstrumentId: match.globalInstrumentId });
984 void openResearchReadiness(match.globalInstrumentId, match.companyName);
985 void loadSearchPresentation(match.globalInstrumentId, match.companyName, match.region);
986 }
987
988 async function toggleSearchWatchlist() {
989 const match = searchSelectedMatch;
990 if (searchWatchlistBusy || !match?.globalInstrumentId?.trim()) return;
991 setSearchWatchlistBusy(true);
992 setSearchWatchlistError(null);
993 try {
994 const list = await researchApi.ensureDefaultWatchlist(match.region);
995 const already = (savedWatchlistIds[list.watchlistId] ?? []).includes(match.globalInstrumentId);
996 if (already) {
997 await researchApi.removeWatchlistInstrument(list.watchlistId, match.globalInstrumentId);
998 setSavedWatchlistIds((current) => ({
999 ...current,
1000 [list.watchlistId]: (current[list.watchlistId] ?? []).filter((id) => id !== match.globalInstrumentId),
1001 }));
1002 } else {
1003 await researchApi.addWatchlistInstrument(list.watchlistId, {
1004 globalInstrumentId: match.globalInstrumentId,
1005 sourcePeriod: "DAY",
1006 sourcePerformancePct: null,
1007 });
1008 setSavedWatchlistIds((current) => ({
1009 ...current,
1010 [list.watchlistId]: [...(current[list.watchlistId] ?? []), match.globalInstrumentId],
1011 }));
1012 }
1013 setWatchlists((current) => current.some((item) => item.watchlistId === list.watchlistId) ? current : [...current, list]);
1014 } catch (error) {
1015 setSearchWatchlistError(getApiFailure(error).message);
1016 } finally {
1017 setSearchWatchlistBusy(false);
1018 }
1019 }
1020
1021 async function createPortfolio() {
1022 setCreating(true);
1023 setError(null);
1024 try {
1025 const created = await portfolioApi.createPortfolio({
1026 name: newPortfolioName.trim(),
1027 baseCurrency: newPortfolioCurrency.trim().toUpperCase()
1028 });
1029 const loaded = await portfolioApi.listPortfolios();
1030 setPortfolios(loaded);
1031 rememberSelectedPortfolioId(authenticatedUser?.userId, created.portfolioId);
1032 setSelectedPortfolioId(created.portfolioId);
1033 clearMarketIntelligenceContext();
1034 setView("portfolio");
1035 } catch (err) {
1036 setError(getApiFailure(err));
1037 } finally {
1038 setCreating(false);
1039 }
1040 }
1041
1042 async function syncPortfolio() {
1043 if (!selectedPortfolioId) {
1044 return;
1045 }
1046
1047 setSyncing(true);
1048 setError(null);
1049 try {
1050 const syncedSummary = await portfolioApi.syncPortfolio(selectedPortfolioId);
1051 const loadedPositions = await portfolioApi.getPositions(selectedPortfolioId);
1052 const loaded = await portfolioApi.listPortfolios();
1053 setPortfolios(loaded);
1054 setSummary(syncedSummary);
1055 setPositions(loadedPositions);
1056 } catch (err) {
1057 setError(getApiFailure(err));
1058 } finally {
1059 setSyncing(false);
1060 }
1061 }
1062
1063 async function syncSelectedPortfolio() {
1064 const brokerPortfolio = portfolios.find((portfolio) => portfolio.portfolioId === selectedPortfolioId);
1065 if (!brokerPortfolio?.brokerConnectionId) {
1066 return syncPortfolio();
1067 }
1068 const connectionId = brokerPortfolio.brokerConnectionId;
1069 const brokerConnection = brokerConnections.find((connection) => connection.connectionId === connectionId);
1070 const brokerType = brokerConnection?.brokerType ?? brokerPortfolio.provider ?? "UNKNOWN";
1071 const provider = brokerProviders.find((candidate) => candidate.brokerType === brokerType);
1072 setSyncing(true);
1073 setError(null);
1074 const authWindow = provider?.consumerAuthMode === "BROKER_REDIRECT"
1075 ? openBrokerAuthenticationWindow(brokerType)
1076 : null;
1077 try {
1078 const result = await portfolioApi.syncBrokerConnection(connectionId);
1079 if (result.status === "AUTHENTICATION_REQUIRED") {
1080 if (provider?.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1081 setBrokerCardError(brokerType, "Direct customer account connection is not available yet.");
1082 return;
1083 }
1084 await completeBrokerAuthentication(
1085 authWindow,
1086 brokerType,
1087 () => brokerApi.authenticationAction(connectionId)
1088 );
1089 return;
1090 }
1091 authWindow?.close();
1092 const dashboard = await portfolioApi.getDashboard();
1093 const [loadedSummary, loadedPositions, loadedHistory] = await Promise.all([
1094 portfolioApi.getSummary(selectedPortfolioId), portfolioApi.getPositions(selectedPortfolioId),
1095 portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange)
1096 ]);
1097 setPortfolioDashboard(dashboard);
1098 setPortfolios(dashboard.portfolios);
1099 setSummary(loadedSummary);
1100 setPositions(loadedPositions);
1101 setPortfolioHistory(loadedHistory);
1102 } catch (err) {
1103 authWindow?.close();
1104 setError(getApiFailure(err));
1105 } finally {
1106 setSyncing(false);
1107 }
1108 }
1109
1110 async function refreshSelectedPrices() {
1111 if (!selectedPortfolioId) return;
1112 setSyncing(true);
1113 setError(null);
1114 try {
1115 await portfolioApi.refreshPrices(selectedPortfolioId);
1116 setPositions(await portfolioApi.getPositions(selectedPortfolioId));
1117 } catch (err) {
1118 setError(getApiFailure(err));
1119 } finally {
1120 setSyncing(false);
1121 }
1122 }
1123
1124 function setBrokerCardError(brokerType: string, message: string | null) {
1125 setBrokerErrors((current) => {
1126 const next = { ...current };
1127 if (message) next[brokerType] = message;
1128 else delete next[brokerType];
1129 return next;
1130 });
1131 }
1132
1133 function openBrokerAuthenticationWindow(brokerType: string): Window | null {
1134 if (brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
1135 brokerAuthPopupRef.current.focus();
1136 return null;
1137 }
1138 const width = 620;
1139 const height = 760;
1140 const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
1141 const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
1142 const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
1143 const authWindow = window.open("about:blank", "aip-ibkr-auth", features);
1144 if (!authWindow) {
1145 setBrokerCardError(brokerType, "Your browser blocked the IBKR sign-in window. Allow pop-ups for this site and try again.");
1146 return null;
1147 }
1148 brokerAuthPopupRef.current = authWindow;
1149 return authWindow;
1150 }
1151
1152 function stopBrokerAuthenticationMonitoring(closePopup: boolean) {
1153 if (brokerAuthPollRef.current !== null) {
1154 window.clearInterval(brokerAuthPollRef.current);
1155 brokerAuthPollRef.current = null;
1156 }
1157 if (closePopup && brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
1158 brokerAuthPopupRef.current.close();
1159 }
1160 brokerAuthPopupRef.current = null;
1161 brokerAuthPollBusyRef.current = false;
1162 pendingBrokerAuthRef.current = null;
1163 brokerAuthStartedAtRef.current = 0;
1164 window.localStorage.removeItem("aip.pendingBrokerAuthentication");
1165 setAuthenticatingBroker(null);
1166 setBrokerAuthenticationTimedOut(false);
1167 }
1168
1169 async function refreshAuthenticatedBroker(connectionId: string): Promise<boolean> {
1170 const authStatus = await brokerApi.getAuthStatus(connectionId);
1171 if (!authStatus.authenticated || authStatus.state !== "CONNECTED") return false;
1172 if (pendingBrokerAuthRef.current?.provider === "IBKR") {
1173 await bootstrapIbkrPortfolio(connectionId);
1174 }
1175 const [connections, dashboard] = await Promise.all([
1176 brokerApi.listConnections(),
1177 portfolioApi.getDashboard()
1178 ]);
1179 setBrokerConnections(connections);
1180 setPortfolioDashboard(dashboard);
1181 setPortfolios(dashboard.portfolios);
1182 const linkedPortfolio = dashboard.portfolios.find((portfolio) =>
1183 portfolio.brokerConnectionId === connectionId && portfolio.portfolioId === selectedPortfolioId
1184 );
1185 if (linkedPortfolio) {
1186 const [loadedPortfolio, loadedSummary, loadedPositions, loadedHistory] = await Promise.all([
1187 portfolioApi.getPortfolio(linkedPortfolio.portfolioId),
1188 portfolioApi.getSummary(linkedPortfolio.portfolioId),
1189 portfolioApi.getPositions(linkedPortfolio.portfolioId),
1190 portfolioApi.getHistory(linkedPortfolio.portfolioId, portfolioHistoryRange)
1191 ]);
1192 setSelectedPortfolio(loadedPortfolio);
1193 setSummary(loadedSummary);
1194 setPositions(loadedPositions);
1195 setPortfolioHistory(loadedHistory);
1196 }
1197 return true;
1198 }
1199
1200 async function bootstrapIbkrPortfolio(connectionId: string): Promise<boolean> {
1201 const existing = ibkrBootstrapSyncsRef.current.get(connectionId);
1202 if (existing) return existing;
1203 const sync = (async () => {
1204 try {
1205 const result = await portfolioApi.syncBrokerConnection(connectionId);
1206 if (result.status !== "CONNECTED") {
1207 throw new Error("Broker portfolio synchronization did not complete.");
1208 }
1209 const dashboard = await portfolioApi.getDashboard();
1210 setPortfolioDashboard(dashboard);
1211 setPortfolios(dashboard.portfolios);
1212 const returnedPortfolioId = result.portfolios.find((portfolio) =>
1213 portfolio.brokerConnectionId === connectionId
1214 )?.portfolioId;
1215 const portfolioId = returnedPortfolioId ?? dashboard.portfolios.find((portfolio) =>
1216 portfolio.brokerConnectionId === connectionId
1217 )?.portfolioId;
1218 if (portfolioId) {
1219 setSelectedPortfolioId((current) => {
1220 if (current) return current;
1221 rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
1222 return portfolioId;
1223 });
1224 }
1225 setBrokerCardError("IBKR", null);
1226 return true;
1227 } catch {
1228 setBrokerCardError("IBKR", "Interactive Brokers connected, but portfolio sync failed. Try syncing again.");
1229 return false;
1230 }
1231 })();
1232 ibkrBootstrapSyncsRef.current.set(connectionId, sync);
1233 return sync;
1234 }
1235
1236 async function finishBrokerAuthentication(connectionId: string) {
1237 if (brokerAuthPollBusyRef.current) return;
1238 brokerAuthPollBusyRef.current = true;
1239 try {
1240 if (await refreshAuthenticatedBroker(connectionId)) {
1241 stopBrokerAuthenticationMonitoring(true);
1242 }
1243 } catch {
1244 // Authentication may still be propagating from IBKR; the bounded parent poll remains authoritative.
1245 } finally {
1246 brokerAuthPollBusyRef.current = false;
1247 }
1248 }
1249
1250 function startBrokerAuthenticationMonitoring(authWindow: Window, connectionId: string, provider: string) {
1251 if (provider === "IBKR") {
1252 ibkrBootstrapSyncsRef.current.delete(connectionId);
1253 }
1254 pendingBrokerAuthRef.current = { connectionId, provider };
1255 setAuthenticatingBroker(provider);
1256 setBrokerAuthenticationTimedOut(false);
1257 brokerAuthStartedAtRef.current = Date.now();
1258 startBrokerAuthenticationPolling(authWindow, connectionId);
1259 }
1260
1261 function startBrokerAuthenticationPolling(authWindow: Window, connectionId: string) {
1262 if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
1263 const poll = async () => {
1264 if (brokerAuthPollBusyRef.current) return;
1265 if (Date.now() - brokerAuthStartedAtRef.current >= brokerAuthenticationTimeoutMs) {
1266 if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
1267 brokerAuthPollRef.current = null;
1268 setBrokerAuthenticationTimedOut(true);
1269 return;
1270 }
1271 if (authWindow.closed) {
1272 brokerAuthPollBusyRef.current = true;
1273 try {
1274 await refreshAuthenticatedBroker(connectionId);
1275 } catch {
1276 // A manually closed pending login remains unauthenticated.
1277 } finally {
1278 brokerAuthPollBusyRef.current = false;
1279 stopBrokerAuthenticationMonitoring(false);
1280 }
1281 return;
1282 }
1283 await finishBrokerAuthentication(connectionId);
1284 };
1285 brokerAuthPollRef.current = window.setInterval(() => void poll(), brokerAuthenticationPollMs);
1286 void poll();
1287 }
1288
1289 function continueBrokerAuthenticationChecking() {
1290 const pending = pendingBrokerAuthRef.current;
1291 const authWindow = brokerAuthPopupRef.current;
1292 if (!pending || !authWindow || authWindow.closed) return;
1293 setBrokerAuthenticationTimedOut(false);
1294 brokerAuthStartedAtRef.current = Date.now();
1295 startBrokerAuthenticationPolling(authWindow, pending.connectionId);
1296 }
1297
1298 function cancelBrokerAuthentication() {
1299 stopBrokerAuthenticationMonitoring(true);
1300 }
1301
1302 async function completeBrokerAuthentication(
1303 authWindow: Window | null,
1304 brokerType: string,
1305 requestAction: () => ReturnType<typeof brokerApi.authenticationAction>
1306 ): Promise<boolean> {
1307 if (!authWindow) return false;
1308 try {
1309 const action = await requestAction();
1310 if (action.action === "REDIRECT_REQUIRED" || action.action === "POPUP_REQUIRED") {
1311 if (!action.authenticationUrl) {
1312 stopBrokerAuthenticationMonitoring(true);
1313 setBrokerCardError(brokerType, "Broker authentication is temporarily unavailable. Your saved portfolio remains available.");
1314 return false;
1315 }
1316 window.localStorage.setItem("aip.pendingBrokerAuthentication", JSON.stringify({
1317 connectionId: action.connectionId, provider: action.provider
1318 }));
1319 authWindow.location.assign(action.authenticationUrl);
1320 setBrokerCardError(brokerType, null);
1321 startBrokerAuthenticationMonitoring(authWindow, action.connectionId, action.provider);
1322 return true;
1323 }
1324 authWindow.close();
1325 if (action.action === "NONE") {
1326 if (brokerType === "IBKR" && action.status === "CONNECTED") {
1327 ibkrBootstrapSyncsRef.current.delete(action.connectionId);
1328 await bootstrapIbkrPortfolio(action.connectionId);
1329 }
1330 setBrokerConnections(await brokerApi.listConnections());
1331 } else {
1332 setBrokerCardError(brokerType, action.message);
1333 }
1334 stopBrokerAuthenticationMonitoring(false);
1335 return false;
1336 } catch (err) {
1337 stopBrokerAuthenticationMonitoring(true);
1338 setBrokerCardError(brokerType, getApiFailure(err).message);
1339 return false;
1340 }
1341 }
1342
1343 async function login(userKey: "user-a" | "user-b") {
1344 setAuthLoading(true);
1345 setError(null);
1346 try {
1347 const session = await authApi.loginDev(userKey);
1348 window.localStorage.setItem("aip.accessToken", session.accessToken);
1349 window.localStorage.setItem("aip.user", JSON.stringify(session.user));
1350 // Authentication can complete in a tab that was opened before a frontend rollout.
1351 // Reload the root document so Dashboard effects always mount from the active build.
1352 window.location.replace("/");
1353 } catch (err) {
1354 setError(getApiFailure(err));
1355 } finally {
1356 setAuthLoading(false);
1357 }
1358 }
1359
1360 async function loginEmail(email: string, password: string) {
1361 setAuthLoading(true); setError(null);
1362 try {
1363 const session = await authApi.login(email, password);
1364 window.localStorage.setItem("aip.accessToken", session.accessToken);
1365 window.localStorage.setItem("aip.user", JSON.stringify(session.user));
1366 window.location.replace("/");
1367 } catch (err) { setError(getApiFailure(err)); } finally { setAuthLoading(false); }
1368 }
1369
1370 function logout() {
1371 window.localStorage.removeItem("aip.accessToken");
1372 window.localStorage.removeItem("aip.user");
1373 window.history.replaceState({}, "", "/");
1374 setError(null);
1375 setAccessToken(null);
1376 setAuthenticatedUser(null);
1377 clearUserScopedState();
1378 }
1379
1380 if (authLoading) {
1381 return <LoadingView />;
1382 }
1383
1384 if (!accessToken || !authenticatedUser) {
1385 return <SignInView error={error} onLogin={login} onEmailLogin={loginEmail} />;
1386 }
1387
1388 return (
1389 <main className="app-shell">
1390 <aside className={`sidebar ${sidebarOpen ? "sidebar-open" : ""}`}>
1391 <div className="brand-lockup">
1392 <div className="brand-mark">AI</div>
1393 <div>
1394 <strong>AI Investment</strong>
1395 <span>Intelligence</span>
1396 </div>
1397 </div>
1398
1399 <nav aria-label="Primary">
1400 {navItems.map((item) => {
1401 const Icon = item.icon;
1402 return (
1403 <button
1404 className={`nav-item ${view === item.id ? "nav-item-active" : ""}`}
1405 key={item.id}
1406 onClick={() => {
1407 clearMarketIntelligenceContext();
1408 setView(item.id);
1409 setSidebarOpen(false);
1410 }}
1411 type="button"
1412 >
1413 <Icon size={18} aria-hidden="true" />
1414 {item.label}
1415 </button>
1416 );
1417 })}
1418 </nav>
1419
1420 <div className="sidebar-panel">
1421 <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "info"}>
1422 {portfolioSourceLabels(positions).badge}
1423 </Badge>
1424 <p>{hasRealBrokerPositions(positions) ? "Read-only broker data is sourced from Interactive Brokers." : "Demo portfolios use generated broker data."}</p>
1425 </div>
1426 </aside>
1427
1428 <section className="workspace">
1429 <header className="topbar">
1430 <Button className="mobile-menu" variant="ghost" onClick={() => setSidebarOpen(true)} aria-label="Open navigation">
1431 <Menu size={20} />
1432 </Button>
1433 <div className="command-bar">
1434 <Command size={17} aria-hidden="true" />
1435 <input
1436 aria-label="Global security search"
1437 placeholder="Search ticker, company, or ISIN"
1438 type="search"
1439 />
1440 <kbd>/</kbd>
1441 </div>
1442 <div className="topbar-actions">
1443 <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "warning"}>
1444 {portfolioSourceLabels(positions).badge}
1445 </Badge>
1446 <button className="icon-button" type="button" aria-label="Notifications">
1447 <Bell size={18} />
1448 </button>
1449 <label className="theme-switch">
1450 <span className="sr-only">Theme</span>
1451 <Sun size={16} aria-hidden="true" />
1452 <select value={theme} onChange={(event) => setTheme(event.target.value as Theme)}>
1453 <option value="system">System</option>
1454 <option value="light">Light</option>
1455 <option value="dark">Dark</option>
1456 </select>
1457 <Moon size={16} aria-hidden="true" />
1458 </label>
1459 <button className="account-button" type="button">
1460 <span>{authenticatedUser.displayName ?? authenticatedUser.email ?? "Account"}</span>
1461 <ChevronDown size={16} aria-hidden="true" />
1462 </button>
1463 <Button variant="secondary" onClick={logout}>
1464 Logout
1465 </Button>
1466 </div>
1467 </header>
1468
1469 {sidebarOpen ? (
1470 <button className="sidebar-scrim" onClick={() => setSidebarOpen(false)} aria-label="Close navigation" type="button">
1471 <X size={20} />
1472 </button>
1473 ) : null}
1474
1475 <div className="content">
1476 <section className="page-header">
1477 <div>
1478 <p className="eyebrow">{view === "research" ? "Research intelligence" : "Brokers & Portfolios"}</p>
1479 <h1>{view === "dashboard" ? "Portfolio command center" : navItems.find((item) => item.id === view)?.label}</h1>
1480 <p>
1481 Backend: <code>{frontendConfig.apiBaseUrl || "same-origin gateway"}</code>
1482 </p>
1483 </div>
1484 {view === "research" ? (
1485 null
1486 ) : (
1487 <PortfolioSelector
1488 portfolios={portfolios}
1489 selectedPortfolioId={selectedPortfolioId}
1490 onChange={(portfolioId) => {
1491 setPortfolioResearchSummary(null);
1492 setSelectedResearchInstrumentId("");
1493 clearMarketIntelligenceContext();
1494 setResearchSummary(null);
1495 setPortfolioHistory(null);
1496 rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
1497 setSelectedPortfolioId(portfolioId);
1498 }}
1499 />
1500 )}
1501 </section>
1502
1503 {error ? (
1504 <ErrorState
1505 message={error.message}
1506 correlationId={error.correlationId}
1507 action={
1508 <Button variant="secondary" onClick={() => window.location.reload()}>
1509 Retry
1510 </Button>
1511 }
1512 />
1513 ) : null}
1514
1515 {view !== "research" && watchlistActionError ? <p role="alert">{watchlistActionError}</p> : null}
1516 {view === "backtesting" ? <Backtesting /> : null}
1517 {view === "dashboard" ? <OpportunityRadar heldIds={positions.flatMap(p => p.instrument.globalInstrumentId ? [p.instrument.globalInstrumentId] : [])} watchlistedIds={Object.values(savedWatchlistIds).flat()} /> : null}
1518 {loading ? <LoadingView /> : null}
1519
1520 {!loading && !error ? (
1521 <>
1522 {view === "dashboard" ? (
1523 <>
1524 <PortfolioTabs
1525 portfolios={portfolios}
1526 selected={portfolioScope}
1527 onSelect={(value) => {
1528 setPortfolioScope(value);
1529 if (value !== "ALL") setSelectedPortfolioId(value);
1530 }}
1531 />
1532 {portfolioScope === "ALL" ? (
1533 <MultiPortfolioDashboard
1534 dashboard={portfolioDashboard}
1535 onAddWatchlist={(selection) => { void addRankedStockToWatchlist(selection); }}
1536 savedInstrumentIds={watchlists.filter((list) => list.region === sectorPerformanceRegion && list.systemDefault).flatMap((list) => savedWatchlistIds[list.watchlistId] ?? [])}
1537 watchlistBusy={watchlistMutation !== null}
1538 performance={sectorPerformance}
1539 performanceRegion={sectorPerformanceRegion}
1540 performanceSector={sectorPerformanceSector}
1541 performancePeriod={sectorPerformancePeriod}
1542 sectorOptions={sectorOptions}
1543 sectorOptionsLoading={sectorOptionsLoading}
1544 onPerformanceRegion={(region) => {
1545 if (selectedMarketIntelligenceRegion && selectedMarketIntelligenceRegion !== region) {
1546 clearMarketIntelligenceContext();
1547 }
1548 setSectorPerformanceRegion(region);
1549 }}
1550 onPerformanceSector={setSectorPerformanceSector}
1551 onPerformancePeriod={setSectorPerformancePeriod}
1552 onCreate={createPortfolio}
1553 creating={creating}
1554 selectedResearchInstrumentId={selectedResearchInstrumentId}
1555 onOpenResearch={(selection) => { void openMarketIntelligenceResearch(selection); }}
1556 />
1557 ) : (
1558 <DashboardView
1559 portfolio={selectedPortfolio}
1560 summary={summary}
1561 positions={positions}
1562 onCreate={createPortfolio}
1563 onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
1564 onRefreshPrices={selectedPortfolio?.acquisitionSource === "MANUAL_CSV_IMPORT" ? refreshSelectedPrices : undefined}
1565 creating={creating}
1566 syncing={syncing}
1567 newPortfolioName={newPortfolioName}
1568 newPortfolioCurrency={newPortfolioCurrency}
1569 setNewPortfolioName={setNewPortfolioName}
1570 setNewPortfolioCurrency={setNewPortfolioCurrency}
1571 />
1572 )}
1573 </>
1574 ) : null}
1575 {view === "portfolio" ? (
1576 <PortfolioView
1577 portfolio={selectedPortfolio}
1578 summary={summary}
1579 positions={sortedPositions}
1580 rawPositions={positions}
1581 history={portfolioHistory}
1582 historyRange={portfolioHistoryRange}
1583 historyLoading={portfolioHistoryLoading}
1584 onHistoryRange={setPortfolioHistoryRange}
1585 searchText={searchText}
1586 sortKey={sortKey}
1587 onSearch={setSearchText}
1588 onSort={setSortKey}
1589 onCreate={createPortfolio}
1590 onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
1591 onRefreshPrices={selectedPortfolio?.acquisitionSource === "MANUAL_CSV_IMPORT" ? refreshSelectedPrices : undefined}
1592 creating={creating}
1593 syncing={syncing}
1594 newPortfolioName={newPortfolioName}
1595 newPortfolioCurrency={newPortfolioCurrency}
1596 setNewPortfolioName={setNewPortfolioName}
1597 setNewPortfolioCurrency={setNewPortfolioCurrency}
1598 onUpdateDisplayName={updateHoldingDisplayName}
1599 portfolioResearch={portfolioResearchSummary}
1600 />
1601 ) : null}
1602 {view === "brokers" ? (
1603 <BrokerView
1604 providers={brokerProviders}
1605 connections={brokerConnections}
1606 portfolios={portfolios}
1607 errors={brokerErrors}
1608 loading={brokerLoading}
1609 authenticatingBroker={authenticatingBroker}
1610 authenticationTimedOut={brokerAuthenticationTimedOut}
1611 onContinueAuthentication={continueBrokerAuthenticationChecking}
1612 onCancelAuthentication={cancelBrokerAuthentication}
1613 onConnectBroker={async (provider) => {
1614 if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1615 setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1616 return;
1617 }
1618 const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1619 if (!authWindow) return;
1620 setBrokerLoading(true);
1621 try {
1622 const connection = await brokerApi.connectBroker(provider.brokerType);
1623 await completeBrokerAuthentication(
1624 authWindow,
1625 provider.brokerType,
1626 () => brokerApi.authenticationAction(connection.connectionId)
1627 );
1628 setBrokerConnections(await brokerApi.listConnections());
1629 setBrokerProviders(await brokerApi.listBrokers());
1630 } catch (err) {
1631 stopBrokerAuthenticationMonitoring(true);
1632 setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1633 } finally {
1634 setBrokerLoading(false);
1635 }
1636 }}
1637 onAuthenticate={async (connectionId, provider) => {
1638 if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1639 setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1640 return;
1641 }
1642 const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1643 if (!authWindow) return;
1644 setBrokerLoading(true);
1645 try {
1646 await completeBrokerAuthentication(
1647 authWindow,
1648 provider.brokerType,
1649 () => brokerApi.authenticationAction(connectionId)
1650 );
1651 setBrokerConnections(await brokerApi.listConnections());
1652 } catch (err) {
1653 stopBrokerAuthenticationMonitoring(true);
1654 setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1655 } finally {
1656 setBrokerLoading(false);
1657 }
1658 }}
1659 onConfigureIndividual={async (provider, clientKey, clientSecret) => {
1660 setBrokerLoading(true);
1661 try {
1662 const existing = brokerConnections.find((connection) => connection.brokerType === provider.brokerType);
1663 const connection = existing ?? await brokerApi.connectBroker(provider.brokerType);
1664 await brokerApi.configureCredentials(connection.connectionId, clientKey, clientSecret);
1665 setBrokerCardError(provider.brokerType, null);
1666 setBrokerConnections(await brokerApi.listConnections());
1667 } catch (err) {
1668 setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1669 throw err;
1670 } finally {
1671 setBrokerLoading(false);
1672 }
1673 }}
1674 onImportComplete={async (portfolioId) => {
1675 const dashboard = await portfolioApi.getDashboard();
1676 setPortfolioDashboard(dashboard);
1677 setPortfolios(dashboard.portfolios);
1678 setSelectedPortfolioId(portfolioId);
1679 setPortfolioScope(portfolioId);
1680 rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
1681 clearMarketIntelligenceContext();
1682 setView("portfolio");
1683 }}
1684 onDisconnect={async (connectionId) => {
1685 setBrokerLoading(true);
1686 try {
1687 await brokerApi.disconnectConnection(connectionId);
1688 setBrokerConnections(await brokerApi.listConnections());
1689 setBrokerProviders(await brokerApi.listBrokers());
1690 } finally {
1691 setBrokerLoading(false);
1692 }
1693 }}
1694 />
1695 ) : null}
1696 {view === "research" ? (
1697 <>
1698 <Card className="wide-panel research-discovery">
1699 <h2>Research intelligence</h2>
1700 <StockSearchField selectedGlobalInstrumentId={selectedResearchInstrumentId} onSelect={handleSearchSelect} />
1701 </Card>
1702 <Card className="wide-panel research-saved-lists">
1703 <h3>Saved lists</h3>
1704 <div className="button-row" role="group" aria-label="Regional watchlists">
1705 {(["INDIA", "USA", "EUROPE"] as const).map((region) => <Button key={region} variant="secondary" onClick={() => { void openRegionalWatchlist(region); }}>{regionalWatchlistName(region)}</Button>)}
1706 </div>
1707 <ResearchContextSelector
1708 portfolios={portfolios}
1709 watchlists={watchlists}
1710 context={researchContext}
1711 onChange={selectResearchContext}
1712 />
1713 {watchlistActionError ? <p role="alert">{watchlistActionError}</p> : null}
1714 {researchContext.kind === "WATCHLIST" && selectedResearchInstrumentId ? <Button variant="secondary" disabled={watchlistMutation !== null} onClick={() => { void removeWatchlistStock(selectedResearchInstrumentId); }}>Remove selected company from {researchContext.name}</Button> : null}
1715 </Card>
1716 <ResearchView
1717 positions={positions}
1718 portfolioResearchSummary={portfolioResearchSummary}
1719 portfolioResearchLoading={portfolioResearchLoading}
1720 portfolioResearchError={portfolioResearchError}
1721 researchContext={researchContext}
1722 watchlistResearch={watchlistResearch}
1723 watchlistResearchLoading={watchlistResearchLoading}
1724 watchlistResearchError={watchlistResearchError}
1725 selectedResearchInstrumentId={selectedResearchInstrumentId}
1726 onSelectInstrument={setSelectedResearchInstrumentId}
1727 searchPresentation={searchPresentation}
1728 onSearchSelect={handleSearchSelect}
1729 searchSelectedMatch={searchSelectedMatch}
1730 searchWatchlistSaved={searchWatchlistSaved}
1731 searchWatchlistBusy={searchWatchlistBusy}
1732 searchWatchlistError={searchWatchlistError}
1733 onToggleSearchWatchlist={toggleSearchWatchlist}
1734 summary={researchSummary}
1735 loading={researchLoading}
1736 eventType={researchEventType}
1737 impact={researchImpact}
1738 onEventType={setResearchEventType}
1739 onImpact={setResearchImpact}
1740 onRefresh={async () => {
1741 if (!selectedResearchInstrumentId) {
1742 return;
1743 }
1744 const selectedCompany = portfolioResearchSummary?.companies.find(
1745 (company) => company.instrumentId === selectedResearchInstrumentId
1746 );
1747 await openResearchReadiness(
1748 selectedResearchInstrumentId,
1749 selectedCompany?.companyName ?? researchSummary?.profile.companyName ?? "Company research"
1750 );
1751 }}
1752 />
1753 </>
1754 ) : null}
1755 {view === "settings" ? <SettingsView /> : null}
1756 </>
1757 ) : null}
1758 </div>
1759 </section>
1760 {researchReadinessDialog ? (
1761 <ResearchReadinessDialog
1762 companyName={researchReadinessDialog.companyName}
1763 readiness={researchReadiness}
1764 loading={researchReadinessLoading}
1765 error={researchReadinessError}
1766 ensuringRequirementIds={ensuringResearchRequirements}
1767 analysis={stockRuleEngineAnalysis}
1768 analysisLoading={stockRuleEngineLoading}
1769 analysisError={stockRuleEngineError}
1770 onClose={() => {
1771 setResearchReadinessDialog(null);
1772 setResearchReadiness(null);
1773 setResearchReadinessError(null);
1774 setStockRuleEngineAnalysis(null);
1775 setStockRuleEngineError(null);
1776 }}
1777 onFindData={(requirements) => { void findResearchData(requirements); }}
1778 onRunAnalysis={(allowPartial) => { void runStockRuleEngineAnalysis(allowPartial); }}
1779 />
1780 ) : null}
1781 </main>
1782 );
1783 }
1784
1785 function ResearchReadinessDialog({
1786 companyName,
1787 readiness,
1788 loading,
1789 error,
1790 ensuringRequirementIds,
1791 analysis,
1792 analysisLoading,
1793 analysisError,
1794 onClose,
1795 onFindData,
1796 onRunAnalysis
1797 }: {
1798 companyName: string;
1799 readiness: ResearchReadiness | null;
1800 loading: boolean;
1801 error: string | null;
1802 ensuringRequirementIds: string[];
1803 analysis: StockRuleEngineAnalysis | null;
1804 analysisLoading: boolean;
1805 analysisError: string | null;
1806 onClose: () => void;
1807 onFindData: (requirements: string[]) => void;
1808 onRunAnalysis: (allowPartial: boolean) => void;
1809 }) {
1810 const dialogRef = useRef<HTMLElement>(null);
1811 const findable = readiness?.requirements.filter((item) =>
1812 item.supportedActions.includes("FIND_DATA") && item.status !== "READY_FRESH"
1813 ) ?? [];
1814
1815 useEffect(() => {
1816 const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
1817 const appShell = document.querySelector<HTMLElement>(".app-shell");
1818 const previousBodyOverflow = document.body.style.overflow;
1819 const previousBodyPaddingRight = document.body.style.paddingRight;
1820 const previousAppInert = appShell?.inert ?? false;
1821 const previousAppAriaHidden = appShell?.getAttribute("aria-hidden") ?? null;
1822 const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
1823
1824 document.body.style.overflow = "hidden";
1825 if (scrollbarWidth > 0) {
1826 const currentPadding = Number.parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
1827 document.body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
1828 }
1829 if (appShell) {
1830 appShell.inert = true;
1831 appShell.setAttribute("aria-hidden", "true");
1832 }
1833 dialogRef.current?.querySelector<HTMLElement>("[data-readiness-close]")?.focus();
1834
1835 return () => {
1836 document.body.style.overflow = previousBodyOverflow;
1837 document.body.style.paddingRight = previousBodyPaddingRight;
1838 if (appShell) {
1839 appShell.inert = previousAppInert;
1840 if (previousAppAriaHidden === null) appShell.removeAttribute("aria-hidden");
1841 else appShell.setAttribute("aria-hidden", previousAppAriaHidden);
1842 }
1843 previouslyFocused?.focus();
1844 };
1845 }, []);
1846
1847 useEffect(() => {
1848 function handleKeyDown(event: KeyboardEvent) {
1849 if (event.key === "Escape") {
1850 event.preventDefault();
1851 onClose();
1852 return;
1853 }
1854 if (event.key !== "Tab" || !dialogRef.current) return;
1855 const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
1856 "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"
1857 )).filter((element) => !element.hidden && element.getClientRects().length > 0);
1858 if (!focusable.length) {
1859 event.preventDefault();
1860 dialogRef.current.focus();
1861 return;
1862 }
1863 const first = focusable[0];
1864 const last = focusable[focusable.length - 1];
1865 if (event.shiftKey && document.activeElement === first) {
1866 event.preventDefault();
1867 last.focus();
1868 } else if (!event.shiftKey && document.activeElement === last) {
1869 event.preventDefault();
1870 first.focus();
1871 }
1872 }
1873
1874 document.addEventListener("keydown", handleKeyDown);
1875 return () => document.removeEventListener("keydown", handleKeyDown);
1876 }, [onClose]);
1877
1878 if (typeof document === "undefined") return null;
1879
1880 return createPortal(
1881 <div className="readiness-popup-backdrop" onMouseDown={(event) => {
1882 if (event.target === event.currentTarget) onClose();
1883 }}>
1884 <section
1885 ref={dialogRef}
1886 className="readiness-popup"
1887 role="dialog"
1888 aria-modal="true"
1889 aria-labelledby="research-readiness-title"
1890 tabIndex={-1}
1891 >
1892 <header className="readiness-popup-header">
1893 <div>
1894 <p className="eyebrow">Research readiness</p>
1895 <h2 id="research-readiness-title">{companyName}</h2>
1896 </div>
1897 <button data-readiness-close type="button" className="icon-button" aria-label="Close research readiness" onClick={onClose}>
1898 <X size={20} aria-hidden="true" />
1899 </button>
1900 </header>
1901 <div className="readiness-popup-body">
1902 {loading ? <Skeleton rows={5} /> : null}
1903 {error ? <p className="readiness-popup-error" role="alert">{error}</p> : null}
1904 {readiness ? <>
1905 <div className="readiness-summary" aria-label="Research data completeness">
1906 <div><span>Overall status</span><strong>{readiness.overallStatus.replaceAll("_", " ")}</strong></div>
1907 <div><span>Completeness</span><strong>{readiness.overallCompletenessPct}%</strong></div>
1908 <div><span>Critical completeness</span><strong>{readiness.criticalCompletenessPct}%</strong></div>
1909 <div><span>Data confidence</span><strong>{readiness.confidence} · {readiness.confidencePct}%</strong></div>
1910 </div>
1911 <div className="readiness-primary-actions">
1912 {findable.length ? <Button
1913 onClick={() => onFindData(findable.map((item) => item.requirementId))}
1914 disabled={ensuringRequirementIds.length > 0}
1915 >{ensuringRequirementIds.length ? "Finding targeted data…" : "Find required data"}</Button> : null}
1916 <div className="readiness-analysis-actions">
1917 {readiness.analysisEligibility?.fullAnalysisAllowed ? (
1918 <Button onClick={() => onRunAnalysis(false)} disabled={analysisLoading || ensuringRequirementIds.length > 0}>
1919 {analysisLoading ? "Running deterministic analysis…" : "Run Analysis"}
1920 </Button>
1921 ) : readiness.analysisEligibility?.partialAnalysisAllowed ? (
1922 <Button variant="secondary" onClick={() => onRunAnalysis(true)} disabled={analysisLoading || ensuringRequirementIds.length > 0}>
1923 {analysisLoading ? "Running deterministic analysis…" : "Run Partial Analysis"}
1924 </Button>
1925 ) : (
1926 <p className="readiness-refresh-state">Analysis needs more critical data. Use Find Data for the listed requirements.</p>
1927 )}
1928 </div>
1929 </div>
1930 {analysisError ? <p className="readiness-popup-error" role="alert">{analysisError}</p> : null}
1931 {analysis ? <StockRuleEngineBreakdown analysis={analysis} /> : null}
1932 {readiness.refreshState?.executedCapabilities.length ? <p className="readiness-refresh-state" role="status">
1933 Targeted capabilities: {readiness.refreshState.executedCapabilities.join(", ").replaceAll("_", " ")}
1934 </p> : null}
1935 <div className="readiness-requirements">
1936 {readiness.requirements.map((requirement) => (
1937 <ResearchReadinessRow
1938 key={requirement.requirementId}
1939 requirement={requirement}
1940 ensuring={ensuringRequirementIds.includes(requirement.requirementId)}
1941 onFindData={() => onFindData([requirement.requirementId])}
1942 />
1943 ))}
1944 </div>
1945 </> : null}
1946 </div>
1947 </section>
1948 </div>,
1949 document.body
1950 );
1951 }
1952
1953 function ResearchReadinessRow({
1954 requirement,
1955 ensuring,
1956 onFindData
1957 }: {
1958 requirement: ResearchReadinessRequirement;
1959 ensuring: boolean;
1960 onFindData: () => void;
1961 }) {
1962 const tone = readinessStatusTone(requirement.status);
1963 return <article className={`readiness-requirement readiness-requirement-${tone}`}>
1964 <div className="readiness-requirement-heading">
1965 <div>
1966 <strong>{requirement.area.replaceAll("_", " ")}</strong>
1967 <small>{requirement.requirementId.replaceAll("_", " ")} · {requirement.importance}</small>
1968 </div>
1969 <span className={`readiness-status readiness-status-${tone}`}>{requirement.status.replaceAll("_", " ")}</span>
1970 </div>
1971 <p>{requirement.asOf ? `As of ${new Date(requirement.asOf).toLocaleString()}` : "No eligible as-of date"}</p>
1972 <p>{requirement.sourceProvider ? <>Source: {requirement.sourceProvider.replaceAll("_", " ")}{requirement.sourceUrl ? <> · <a href={requirement.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a></> : null}</> : "Source unavailable"}</p>
1973 {requirement.applicabilityReason ? <p>Applicability: {requirement.applicability?.replaceAll("_", " ")} · {requirement.applicabilityReason.replaceAll("_", " ")}{requirement.businessClassification ? ` (${requirement.businessClassification})` : ""}</p> : null}
1974 {requirement.status === "NOT_APPLICABLE" ? <p>Excluded from completeness. No score assigned.</p> : null}
1975 {requirement.requirementId === "CURRENT_NEWS" && requirement.acquisitionObservation?.history?.some((scan) => scan.outcome === "SUCCESS_EMPTY") ? <p>The latest successful provider scan found no qualifying current events. No news score was inferred.</p> : null}
1976 {requirement.acquisitionObservation && requirement.acquisitionObservation.outcome && requirement.acquisitionObservation.provider ? <p>Last acquisition: {requirement.acquisitionObservation.outcome.replaceAll("_", " ")} · {requirement.acquisitionObservation.provider.replaceAll("_", " ")}{requirement.acquisitionObservation.failure_reason ? `: ${requirement.acquisitionObservation.failure_reason}` : ""}</p> : null}
1977 {requirement.missingInputIds.length ? <p>Missing inputs: {requirement.missingInputIds.map((id) => id.replaceAll("_", " ")).join(", ")}</p> : null}
1978 {requirement.concreteRequirements?.some((input) => input.applicability === "NOT_APPLICABLE") ? <p>Not applicable: {requirement.concreteRequirements.filter((input) => input.applicability === "NOT_APPLICABLE").map((input) => input.inputId.replaceAll("_", " ")).join(", ")}</p> : null}
1979 {requirement.missingReason ? <p className="readiness-reason">{requirement.missingReason.replaceAll("_", " ")}</p> : null}
1980 {requirement.conflictReason ? <p className="readiness-reason">{requirement.conflictReason.replaceAll("_", " ")}</p> : null}
1981 <div className="readiness-actions">
1982 {requirement.supportedActions.includes("FIND_DATA") ? <Button onClick={onFindData} disabled={ensuring}>{ensuring ? "Finding…" : "Find Data"}</Button> : null}
1983 {requirement.supportedActions.includes("UPLOAD_EVIDENCE") ? <Button variant="secondary" disabled title="Evidence upload arrives in a later iteration">Upload Evidence</Button> : null}
1984 </div>
1985 </article>;
1986 }
1987
1988 function StockRuleEngineBreakdown({ analysis }: { analysis: StockRuleEngineAnalysis }) {
1989 const tone = analysis.riskOverrides.length
1990 ? "danger"
1991 : analysis.overallScore != null && analysis.overallScore >= 65
1992 ? "ready"
1993 : analysis.overallScore != null && analysis.overallScore < 50
1994 ? "danger"
1995 : "neutral";
1996 return <section className={`rule-engine-result rule-engine-result-${tone}`} aria-label="Deterministic stock analysis">
1997 <header>
1998 <div>
1999 <p className="eyebrow">{analysis.ruleEngineVersion.replaceAll("_", " ")}</p>
2000 <h3>{analysis.overallScore == null ? "Insufficient data" : `${analysis.overallScore.toFixed(2)}/100`}</h3>
2001 </div>
2002 <span className={`readiness-status readiness-status-${tone}`}>{analysis.decisionSignal.replaceAll("_", " ")}</span>
2003 </header>
2004 <div className="rule-engine-score-grid">
2005 <div><span>Quality</span><strong>{scoreText(analysis.qualityScore)}</strong></div>
2006 <div><span>Opportunity</span><strong>{scoreText(analysis.opportunityScore)}</strong></div>
2007 <div><span>Risk</span><strong>{scoreText(analysis.riskScore)}</strong></div>
2008 <div><span>Confidence</span><strong>{analysis.confidence} · {analysis.confidenceScore.toFixed(2)}%</strong></div>
2009 </div>
2010 <p className="rule-engine-meta">
2011 Calculated {new Date(analysis.calculatedAt).toLocaleString()} · input fingerprint {analysis.inputFingerprint.slice(0, 12)} · {analysis.cacheHit ? "cached exact-input result" : "new calculation"}
2012 </p>
2013 {analysis.riskOverrides.length ? <div className="rule-engine-overrides" role="alert">
2014 <strong>Risk override</strong>
2015 {analysis.riskOverrides.map((override) => <p key={override.code}>{override.severity}: {override.code.replaceAll("_", " ")} · {override.effect.replaceAll("_", " ")}</p>)}
2016 </div> : null}
2017 <div className="rule-engine-area-table" role="table" aria-label="Rule Engine area score breakdown">
2018 <div className="rule-engine-area-row rule-engine-area-header" role="row">
2019 <span>Area</span><span>Weight</span><span>Score</span><span>Contribution</span><span>Status</span>
2020 </div>
2021 {analysis.areaScores.map((area) => <details key={area.area} className="rule-engine-area-row">
2022 <summary>
2023 <span>{area.area.replaceAll("_", " ")}</span>
2024 <span>{area.weight}%</span>
2025 <span>{scoreText(area.rawScore)}</span>
2026 <span>{area.rawScore == null ? "—" : area.weightedContribution.toFixed(2)}</span>
2027 <span>{area.status.replaceAll("_", " ")}</span>
2028 </summary>
2029 <div className="rule-engine-area-detail">
2030 {area.metrics.length ? <div className="rule-engine-metrics">
2031 {area.metrics.map((metric, index) => <article key={`${metric.rule}-${index}`}>
2032 <strong>{metric.metric.replaceAll("_", " ")} · {metric.score.toFixed(2)}/100</strong>
2033 <p>{formatRuleMetricValue(metric.value, metric.unit)} · rule {metric.rule} · applied weight {metric.appliedWeightPct.toFixed(2)}%</p>
2034 <p>Source: {metric.source.replaceAll("_", " ")}{metric.asOf ? ` · as of ${new Date(metric.asOf).toLocaleDateString()}` : ""}{metric.sourceUrl ? <> · <a href={metric.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a></> : null}</p>
2035 </article>)}
2036 </div> : <p>No scoreable metric is available for this area.</p>}
2037 {area.sourceReferences.length ? <p><strong>Area evidence sources:</strong>{" "}{area.sourceReferences.map((source, index) => <span key={`${source.sourceUrl}-${index}`}>{index ? " · " : ""}<a href={source.sourceUrl} target="_blank" rel="noreferrer">{source.sourceProvider?.replaceAll("_", " ") ?? "Source"} ↗</a>{source.asOf ? ` (${new Date(source.asOf).toLocaleDateString()})` : ""}</span>)}</p> : null}
2038 {area.positiveFactors.length ? <p><strong>Positive:</strong> {area.positiveFactors.join(" · ")}</p> : null}
2039 {area.negativeFactors.length ? <p><strong>Negative:</strong> {area.negativeFactors.join(" · ")}</p> : null}
2040 {area.missingInputs.length ? <p><strong>Missing:</strong> {area.missingInputs.join(", ").replaceAll("_", " ")}</p> : null}
2041 </div>
2042 </details>)}
2043 </div>
2044 </section>;
2045 }
2046
2047 function scoreText(value?: number | null): string {
2048 return value == null ? "—" : value.toFixed(2);
2049 }
2050
2051 function formatRuleMetricValue(value: unknown, unit?: string | null): string {
2052 const rendered = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "—");
2053 return unit ? `${rendered} ${unit.replaceAll("_", " ")}` : rendered;
2054 }
2055
2056 function readinessStatusTone(status: ResearchReadinessRequirement["status"]): "ready" | "warning" | "danger" | "neutral" {
2057 if (status === "READY_FRESH") return "ready";
2058 if (["READY_STALE", "PARTIAL", "REFRESHING"].includes(status)) return "warning";
2059 if (status === "UNSUPPORTED" || status === "NOT_APPLICABLE") return "neutral";
2060 return "danger";
2061 }
2062
2063 function filingSourceLabel(sourceName: string): string {
2064 return sourceName.trim().split(/\s+/)[0] || "Official";
2065 }
2066
2067 function AuthPasswordInput({ name, placeholder }: { name: string; placeholder: string }) {
2068 const [visible, setVisible] = useState(false);
2069 return <span className="password-input"><input name={name} type={visible ? "text" : "password"} required minLength={12} placeholder={placeholder} /><button className="password-toggle" type="button" aria-label={visible ? "Hide password" : "Show password"} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={18} aria-hidden="true" /> : <Eye size={18} aria-hidden="true" />}</button></span>;
2070 }
2071
2072 function SignInView({
2073 error,
2074 onLogin,
2075 onEmailLogin
2076 }: {
2077 error: ApiFailure | null;
2078 onLogin: (userKey: "user-a" | "user-b") => void;
2079 onEmailLogin: (email: string, password: string) => void;
2080 }) {
2081 const [mode, setMode] = useState<"LOGIN" | "REGISTER" | "VERIFY" | "FORGOT" | "RESET" | "RESEND">("LOGIN");
2082 const [message, setMessage] = useState<string | null>(null);
2083 const [verificationEmail, setVerificationEmail] = useState("");
2084 useEffect(() => { const token = new URLSearchParams(window.location.search).get("token"); if (window.location.pathname === "/verify-email" && token) { queueMicrotask(() => { setMode("VERIFY"); setMessage("Verifying your email..."); authApi.verifyEmail(token).then(() => setMessage("Email verified successfully. You can now sign in.")).catch(() => setMessage("Verification link is invalid or expired.")); }); } }, []);
2085 async function submit(form: FormData) {
2086 const email = String(form.get("email") || ""), password = String(form.get("password") || "");
2087 if (mode === "LOGIN") return onEmailLogin(email, password);
2088 if (mode === "VERIFY") { await authApi.verifyEmail(String(form.get("token") || "")); setMessage("Email verified. You can now sign in."); setMode("LOGIN"); return; }
2089 if (mode === "FORGOT") { const result = await authApi.requestPasswordReset(email); setMessage(result.message); setMode("RESET"); return; }
2090 if (mode === "RESET") { if (password !== String(form.get("confirmPassword") || "")) { setMessage("Passwords do not match."); return; } await authApi.confirmPasswordReset(String(form.get("token") || ""), password); setMessage("Password reset. You can now sign in."); setMode("LOGIN"); return; }
2091 if (mode === "RESEND") { await authApi.resendVerification(email); setMessage("If this account is awaiting verification, a new verification email has been sent."); return; }
2092 if (password !== String(form.get("confirmPassword") || "")) { setMessage("Passwords do not match."); return; }
2093 await authApi.register({ email, password, firstName: String(form.get("firstName") || ""), lastName: String(form.get("lastName") || "") });
2094 setVerificationEmail(email); setMessage("We sent a verification link to your email address."); setMode("VERIFY");
2095 }
2096 return (
2097 <main className="signin-shell">
2098 <section className="signin-panel">
2099 <div className="brand-lockup">
2100 <div className="brand-mark">AI</div>
2101 <div>
2102 <strong>AI Investment</strong>
2103 <span>Secure workspace</span>
2104 </div>
2105 </div>
2106 <h1>{mode === "LOGIN" ? "Sign in" : mode === "REGISTER" ? "Create account" : mode === "VERIFY" ? "Verify email" : mode === "FORGOT" ? "Forgot password" : mode === "RESEND" ? "Resend verification email" : "Reset password"}</h1>
2107 <p>{mode === "LOGIN" ? "Use your verified account." : "Complete the account verification step to continue."}</p>
2108 {error ? <ErrorState message={error.message} correlationId={error.correlationId} /> : null}
2109 {message ? <p role="status">{message}</p> : null}
2110 <form className="signin-actions" action={(form) => void submit(form).catch((err) => setMessage(getApiFailure(err).message))}>
2111 {mode === "VERIFY" ? (frontendConfig.authDevLoginEnabled ? <input name="token" required placeholder="Verification token" /> : <p>Check your email and open the verification link.</p>) : mode === "RESET" ? <><input name="token" required placeholder="Reset token" /><AuthPasswordInput name="password" placeholder="New password" /><AuthPasswordInput name="confirmPassword" placeholder="Confirm new password" /></> : mode === "FORGOT" || mode === "RESEND" ? <input name="email" type="email" required placeholder="Email" /> : <><input name="email" type="email" required placeholder="Email" />{mode === "REGISTER" ? <><input name="firstName" required placeholder="First name" /><input name="lastName" required placeholder="Last name" /></> : null}<AuthPasswordInput name="password" placeholder="Password (minimum 12 characters)" />{mode === "REGISTER" ? <AuthPasswordInput name="confirmPassword" placeholder="Confirm password" /> : null}</>}
2112 <Button type="submit">{mode === "LOGIN" ? "Sign in" : mode === "REGISTER" ? "Register" : mode === "RESEND" ? "Resend verification email" : "Verify email"}</Button>
2113 </form>
2114 <p><button type="button" onClick={() => setMode(mode === "LOGIN" ? "REGISTER" : "LOGIN")}>{mode === "LOGIN" ? "Create an account" : "Back to sign in"}</button></p>
2115 {mode === "VERIFY" ? <p><button type="button" onClick={() => authApi.resendVerification(verificationEmail).then((result) => setMessage(result.message))}>Resend verification email</button></p> : null}
2116 {mode === "LOGIN" ? <><p><button type="button" onClick={() => setMode("FORGOT")}>Forgot password?</button></p><p><button type="button" onClick={() => setMode("RESEND")}>Resend verification email</button></p></> : null}
2117 {frontendConfig.authDevLoginEnabled ? <div className="signin-actions">
2118 <Button onClick={() => onLogin("user-a")}>Sign in as User A</Button>
2119 <Button variant="secondary" onClick={() => onLogin("user-b")}>Sign in as User B</Button>
2120 </div> : null}
2121 </section>
2122 </main>
2123 );
2124 }
2125
2126 function PortfolioSelector({
2127 portfolios,
2128 selectedPortfolioId,
2129 onChange
2130 }: {
2131 portfolios: PortfolioListItem[];
2132 selectedPortfolioId: string;
2133 onChange: (value: string) => void;
2134 }) {
2135 if (portfolios.length === 0) {
2136 return null;
2137 }
2138
2139 return (
2140 <label className="portfolio-select">
2141 <span>Portfolio</span>
2142 <select value={selectedPortfolioId} onChange={(event) => onChange(event.target.value)}>
2143 {portfolios.map((portfolio) => (
2144 <option value={portfolio.portfolioId} key={portfolio.portfolioId}>
2145 {portfolio.name}
2146 </option>
2147 ))}
2148 </select>
2149 </label>
2150 );
2151 }
2152
2153 function ResearchContextSelector({
2154 portfolios,
2155 watchlists,
2156 context,
2157 onChange,
2158 }: {
2159 portfolios: PortfolioListItem[];
2160 watchlists: ResearchWatchlist[];
2161 context: ResearchContext;
2162 onChange: (value: string) => void;
2163 }) {
2164 const value = context.kind === "PORTFOLIO"
2165 ? `portfolio:${context.portfolioId}`
2166 : context.kind === "WATCHLIST"
2167 ? `watchlist:${context.watchlistId}`
2168 : `pending:${context.region}`;
2169 return (
2170 <label className="portfolio-select research-context-select">
2171 <span>Research context</span>
2172 <select value={value} onChange={(event) => onChange(event.target.value)}>
2173 {context.kind === "WATCHLIST_PENDING" ? (
2174 <option value={value}>Opening {context.name}…</option>
2175 ) : null}
2176 <optgroup label="Portfolios">
2177 {portfolios.map((portfolio) => (
2178 <option value={`portfolio:${portfolio.portfolioId}`} key={`portfolio:${portfolio.portfolioId}`}>
2179 {portfolio.name}
2180 </option>
2181 ))}
2182 </optgroup>
2183 <optgroup label="Watchlists">
2184 {watchlists.map((watchlist) => (
2185 <option value={`watchlist:${watchlist.watchlistId}`} key={`watchlist:${watchlist.watchlistId}`}>
2186 {watchlist.name}
2187 </option>
2188 ))}
2189 </optgroup>
2190 </select>
2191 </label>
2192 );
2193 }
2194
2195 function LoadingView() {
2196 return (
2197 <div className="loading-grid">
2198 <Skeleton rows={4} />
2199 <Skeleton rows={4} />
2200 <Skeleton rows={8} />
2201 </div>
2202 );
2203 }
2204
2205 function PortfolioTabs({
2206 portfolios,
2207 selected,
2208 onSelect
2209 }: {
2210 portfolios: PortfolioListItem[];
2211 selected: "ALL" | string;
2212 onSelect: (value: "ALL" | string) => void;
2213 }) {
2214 return (
2215 <nav className="portfolio-tabs" aria-label="Portfolio views">
2216 <button className={selected === "ALL" ? "active" : ""} onClick={() => onSelect("ALL")} type="button">All</button>
2217 {portfolios.map((portfolio) => (
2218 <button
2219 className={selected === portfolio.portfolioId ? "active" : ""}
2220 onClick={() => onSelect(portfolio.portfolioId)}
2221 type="button"
2222 key={portfolio.portfolioId}
2223 >
2224 {portfolio.name}
2225 </button>
2226 ))}
2227 </nav>
2228 );
2229 }
2230
2231 function MultiPortfolioDashboard({
2232 dashboard,
2233 performance,
2234 performanceRegion,
2235 performanceSector,
2236 performancePeriod,
2237 sectorOptions,
2238 sectorOptionsLoading,
2239 onPerformanceRegion,
2240 onPerformanceSector,
2241 onPerformancePeriod,
2242 onCreate,
2243 creating,
2244 selectedResearchInstrumentId,
2245 onOpenResearch,
2246 onAddWatchlist,
2247 savedInstrumentIds,
2248 watchlistBusy,
2249 researchOnly = false,
2250 }: {
2251 onAddWatchlist: (selection: MarketIntelligenceSelection) => void;
2252 savedInstrumentIds: string[];
2253 watchlistBusy: boolean;
2254 researchOnly?: boolean;
2255 dashboard: PortfolioDashboard | null;
2256 performance: SectorPerformance | null;
2257 performanceRegion: SectorPerformance["region"];
2258 performanceSector: string;
2259 performancePeriod: SectorPerformance["period"];
2260 sectorOptions: MarketUniverseSector[];
2261 sectorOptionsLoading: boolean;
2262 onPerformanceRegion: (value: SectorPerformance["region"]) => void;
2263 onPerformanceSector: (value: string) => void;
2264 onPerformancePeriod: (value: SectorPerformance["period"]) => void;
2265 onCreate: () => void;
2266 creating: boolean;
2267 selectedResearchInstrumentId: string;
2268 onOpenResearch: (selection: MarketIntelligenceSelection) => void;
2269 }) {
2270 if (!dashboard && !researchOnly) return null;
2271 return (
2272 <div className="dashboard-grid">
2273 {!researchOnly && dashboard ? <>
2274 <Card className="wide-panel">
2275 <div className="panel-header"><div><p className="eyebrow">My portfolios</p><h2>Total portfolio value</h2></div></div>
2276 <div className="currency-total-grid">
2277 {Object.entries(dashboard.currencyTotals).map(([currency, amount]) => (
2278 <MetricCard label={currency} value={formatMoney(amount, currency)} key={currency} />
2279 ))}
2280 </div>
2281 {dashboard.incompleteValuationPortfolioIds.length > 0 ? (
2282 <p className="broker-note">Some portfolios have incomplete valuation data and are not combined with another currency.</p>
2283 ) : null}
2284 </Card>
2285 <section className="portfolio-card-grid" aria-label="Persisted portfolios">
2286 {dashboard.portfolios.map((portfolio) => (
2287 <Card as="article" key={portfolio.portfolioId}>
2288 <Badge tone={portfolio.provider ? "positive" : "neutral"}>{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual"}</Badge>
2289 <h2>{portfolio.name}</h2>
2290 <strong>{formatBackendMoney(portfolio.totalMarketValue)}</strong>
2291 <p>{portfolio.acquisitionSource === "MANUAL_CSV_IMPORT"
2292 ? `Imported snapshot: ${portfolio.lastImportedAt ? new Date(portfolio.lastImportedAt).toLocaleString() : "Not available"}`
2293 : `Broker holdings synced: ${portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}`}</p>
2294 <p>Market price updated independently in the holdings view.</p>
2295 </Card>
2296 ))}
2297 </section>
2298 {dashboard.portfolios.length === 0 ? <Card className="wide-panel"><EmptyState title="No portfolios" message="Connect a broker or create a portfolio to begin." /><Button onClick={onCreate} disabled={creating}>{creating ? "Creating..." : "Create portfolio"}</Button></Card> : null}
2299 </> : null}
2300 <Card className="wide-panel">
2301 <div className="panel-header"><div><p className="eyebrow">Market intelligence</p><h2>Sector Performance</h2><p>Top gainers from durable market-price observations.</p></div></div>
2302 <div className="form-grid">
2303 <Field label="Region"><select aria-label="Sector performance region" value={performanceRegion} onChange={(event) => onPerformanceRegion(event.target.value as SectorPerformance["region"])}>{["USA", "EUROPE", "INDIA"].map((value) => <option key={value}>{value}</option>)}</select></Field>
2304 <Field label="Sector"><select aria-label="Sector performance sector" value={performanceSector} disabled={sectorOptionsLoading || sectorOptions.length === 0} onChange={(event) => onPerformanceSector(event.target.value)}>{sectorOptions.length ? sectorOptions.map((value) => <option key={value.name} value={value.name}>{value.name} ({value.instrumentCount})</option>) : <option value="">{sectorOptionsLoading ? "Loading durable sectors…" : "No durable sectors"}</option>}</select></Field>
2305 <Field label="Period"><select aria-label="Sector performance period" value={performancePeriod} onChange={(event) => onPerformancePeriod(event.target.value as SectorPerformance["period"])}>{["DAY", "WEEK", "MONTH", "YEAR"].map((value) => <option key={value}>{value}</option>)}</select></Field>
2306 </div>
2307 {sectorOptionsLoading ? <p className="broker-note">Loading durable sector universe…</p>
2308 : sectorOptions.length === 0 ? <p className="broker-note">No durable sector-classified universe is available for this region.</p>
2309 : performance ? <div className="stack-gap">
2310 <div><h3>Top 5 Performers</h3>{performance.bestPerformers.length ? <section className="portfolio-card-grid" aria-label="Sector performance top performers">{performance.bestPerformers.map((stock) => <MarketPerformanceRow stock={stock} key={`best-${stock.globalInstrumentId}`} onOpenResearch={(value) => onOpenResearch(marketIntelligenceSelection(performance, value))} selected={selectedResearchInstrumentId === stock.globalInstrumentId} watchlistName={regionalWatchlistName(performance.region)} saved={savedInstrumentIds.includes(stock.globalInstrumentId)} busy={watchlistBusy} onAddWatchlist={() => onAddWatchlist(marketIntelligenceSelection(performance, stock))} />)}</section> : <p className="broker-note">Insufficient historical data for top performers.</p>}</div>
2311 <div><h3>Worst 5 Performers</h3>{performance.worstPerformers.length ? <section className="portfolio-card-grid" aria-label="Sector performance worst performers">{performance.worstPerformers.map((stock) => <MarketPerformanceRow stock={stock} key={`worst-${stock.globalInstrumentId}`} onOpenResearch={(value) => onOpenResearch(marketIntelligenceSelection(performance, value))} selected={selectedResearchInstrumentId === stock.globalInstrumentId} watchlistName={regionalWatchlistName(performance.region)} saved={savedInstrumentIds.includes(stock.globalInstrumentId)} busy={watchlistBusy} onAddWatchlist={() => onAddWatchlist(marketIntelligenceSelection(performance, stock))} />)}</section> : <p className="broker-note">Insufficient historical data for worst performers.</p>}</div>
2312 </div> : <p className="broker-note">Loading sector performance…</p>}
2313 </Card>
2314 </div>
2315 );
2316 }
2317
2318 function MarketPerformanceRow({
2319 stock,
2320 selected,
2321 onOpenResearch,
2322 onAddWatchlist,
2323 watchlistName,
2324 saved,
2325 busy,
2326 }: {
2327 onAddWatchlist: () => void;
2328 watchlistName: string;
2329 saved: boolean;
2330 busy: boolean;
2331 stock: SectorPerformanceStock;
2332 selected: boolean;
2333 onOpenResearch: (stock: SectorPerformanceStock) => void;
2334 }) {
2335 const tone = performanceRowTone(stock.performancePct);
2336 const signedPerformance = formatSignedPerformancePct(stock.performancePct);
2337 return (
2338 <article className="stack-gap" data-global-instrument-id={stock.globalInstrumentId}>
2339 <button
2340 className={`market-performance-row market-performance-row-${tone}`}
2341 type="button"
2342 data-performance-direction={tone}
2343 aria-current={selected ? "true" : undefined}
2344 aria-label={`${stock.companyName}, ${signedPerformance}, ${performanceDirectionLabel(stock)}`}
2345 onClick={() => onOpenResearch(stock)}
2346 >
2347 <strong>{stock.companyName}</strong>
2348 <span className="market-performance-identity">{stock.ticker} · {stock.exchange}</span>
2349 <span className="market-performance-return">{signedPerformance}</span>
2350 </button>
2351 <Button variant="secondary" disabled={saved || busy || !stock.globalInstrumentId?.trim()} onClick={onAddWatchlist}>
2352 {saved ? `Saved in ${watchlistName}` : `Add to ${watchlistName}`}
2353 </Button>
2354 </article>
2355 );
2356 }
2357
2358 function PortfolioCreatePanel({
2359 onCreate,
2360 creating,
2361 newPortfolioName,
2362 newPortfolioCurrency,
2363 setNewPortfolioName,
2364 setNewPortfolioCurrency
2365 }: {
2366 onCreate: () => void;
2367 creating: boolean;
2368 newPortfolioName: string;
2369 newPortfolioCurrency: string;
2370 setNewPortfolioName: (value: string) => void;
2371 setNewPortfolioCurrency: (value: string) => void;
2372 }) {
2373 return (
2374 <Card className="create-panel">
2375 <div>
2376 <h2>Create a portfolio</h2>
2377 <p>Connect a broker or create a portfolio to get started.</p>
2378 </div>
2379 <div className="form-grid">
2380 <Field label="Name">
2381 <input value={newPortfolioName} onChange={(event) => setNewPortfolioName(event.target.value)} />
2382 </Field>
2383 <Field label="Base currency" hint="ISO 4217 code">
2384 <input
2385 value={newPortfolioCurrency}
2386 maxLength={3}
2387 onChange={(event) => setNewPortfolioCurrency(event.target.value.toUpperCase())}
2388 />
2389 </Field>
2390 </div>
2391 <Button onClick={onCreate} disabled={creating || newPortfolioName.trim().length === 0}>
2392 {creating ? "Creating..." : "Create portfolio"}
2393 </Button>
2394 </Card>
2395 );
2396 }
2397
2398 function DashboardView({
2399 portfolio,
2400 summary,
2401 positions,
2402 onCreate,
2403 onSync,
2404 onRefreshPrices,
2405 creating,
2406 syncing,
2407 newPortfolioName,
2408 newPortfolioCurrency,
2409 setNewPortfolioName,
2410 setNewPortfolioCurrency
2411 }: {
2412 portfolio?: Portfolio;
2413 summary: PortfolioSummary | null;
2414 positions: PortfolioPosition[];
2415 onCreate: () => void;
2416 onSync?: () => void;
2417 onRefreshPrices?: () => void;
2418 creating: boolean;
2419 syncing: boolean;
2420 newPortfolioName: string;
2421 newPortfolioCurrency: string;
2422 setNewPortfolioName: (value: string) => void;
2423 setNewPortfolioCurrency: (value: string) => void;
2424 }) {
2425 if (!portfolio) {
2426 return (
2427 <PortfolioCreatePanel
2428 onCreate={onCreate}
2429 creating={creating}
2430 newPortfolioName={newPortfolioName}
2431 newPortfolioCurrency={newPortfolioCurrency}
2432 setNewPortfolioName={setNewPortfolioName}
2433 setNewPortfolioCurrency={setNewPortfolioCurrency}
2434 />
2435 );
2436 }
2437
2438 const pricedPositions = positions.filter((position) => position.unrealizedProfitLossPercent != null);
2439 const topGainers = [...pricedPositions].sort((a, b) => (b.unrealizedProfitLossPercent ?? 0) - (a.unrealizedProfitLossPercent ?? 0)).slice(0, 3);
2440 const topLosers = [...pricedPositions].sort((a, b) => (a.unrealizedProfitLossPercent ?? 0) - (b.unrealizedProfitLossPercent ?? 0)).slice(0, 3);
2441 const sourceLabels = portfolioSourceLabels(positions);
2442 const liveTotals = onRefreshPrices && positions.every((position) => position.marketValue && position.unrealizedProfitLoss)
2443 ? positions.reduce((totals, position) => ({
2444 marketValue: totals.marketValue + (position.marketValue?.amount ?? 0),
2445 pnl: totals.pnl + (position.unrealizedProfitLoss?.amount ?? 0),
2446 cost: totals.cost + position.costBasis.amount,
2447 }), { marketValue: 0, pnl: 0, cost: 0 }) : null;
2448 const syncAction = onSync ? (
2449 <Button onClick={onSync} disabled={syncing} variant="secondary">
2450 <RefreshCw size={16} />
2451 {syncing ? "Syncing..." : sourceLabels.syncButton}
2452 </Button>
2453 ) : null;
2454 const refreshAction = onRefreshPrices ? (
2455 <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"}</Button>
2456 ) : null;
2457
2458 return (
2459 <div className="dashboard-grid">
2460 <section className="metrics-grid" aria-label="Portfolio summary">
2461 <MetricCard
2462 label="Portfolio value"
2463 value={liveTotals ? formatMoney(liveTotals.marketValue, portfolio.baseCurrency) : formatBackendMoney(summary?.totalMarketValue)}
2464 meta={sourceLabels.syncMeta}
2465 />
2466 <MetricCard label={sourceLabels.totalProfitLoss} value={liveTotals ? formatMoney(liveTotals.pnl, portfolio.baseCurrency) : formatBackendMoney(summary?.unrealizedProfitLoss)} tone={valueTone(liveTotals?.pnl ?? summary?.unrealizedProfitLoss?.amount)} />
2467 <MetricCard label={sourceLabels.returnLabel} value={formatPercent(liveTotals && liveTotals.cost ? liveTotals.pnl / liveTotals.cost * 100 : summary?.unrealizedProfitLossPercent)} tone={valueTone(liveTotals?.pnl ?? summary?.unrealizedProfitLossPercent)} />
2468 <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
2469 <MetricCard label="Holdings" value={String(summary?.positions ?? 0)} />
2470 <MetricCard label="Portfolio risk" value="Pending" meta="Risk service not implemented" tone="warning" />
2471 </section>
2472
2473 <Card className="wide-panel">
2474 <div className="panel-header">
2475 <div>
2476 <h2>{portfolio.name}</h2>
2477 <p>Last updated: {summary ? sourceLabels.lastUpdated : "not synced"} - Source: {sourceLabels.source}</p>
2478 </div>
2479 {syncAction}{refreshAction}
2480 </div>
2481 {summary && !onRefreshPrices ? <AllocationCharts summary={summary} /> : positions.length ? <p>Allocation uses refreshed holding values in the Portfolio view.</p> : <EmptyState title="No positions" message="This portfolio currently has no positions." />}
2482 </Card>
2483
2484 <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} sourceLabels={sourceLabels} />
2485 <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} sourceLabels={sourceLabels} />
2486
2487 <Card className="wide-panel">
2488 <div className="panel-header">
2489 <div>
2490 <h2>AI opportunities</h2>
2491 <p>Recommendation logic is not yet available.</p>
2492 </div>
2493 <Badge tone="neutral">Future ready</Badge>
2494 </div>
2495 <EmptyState title="No AI ratings yet" message="BUY, HOLD, SELL, and opportunity scores will appear after recommendation services are approved and implemented." />
2496 </Card>
2497 </div>
2498 );
2499 }
2500
2501 function MovementPanel({
2502 title,
2503 icon: Icon,
2504 positions,
2505 sourceLabels
2506 }: {
2507 title: string;
2508 icon: typeof TrendingUp;
2509 positions: PortfolioPosition[];
2510 sourceLabels: ReturnType<typeof portfolioSourceLabels>;
2511 }) {
2512 return (
2513 <Card>
2514 <div className="panel-header compact">
2515 <h2>{title}</h2>
2516 <Icon size={18} aria-hidden="true" />
2517 </div>
2518 {positions.length === 0 ? (
2519 <EmptyState title="No synced positions" message={sourceLabels.emptyMessage} />
2520 ) : (
2521 <div className="movement-list">
2522 {positions.map((position) => (
2523 <div key={position.positionId}>
2524 <span>{position.instrument.ticker}</span>
2525 <strong className={position.unrealizedProfitLossPercent == null ? undefined : position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}>
2526 {formatPercent(position.unrealizedProfitLossPercent)}
2527 </strong>
2528 </div>
2529 ))}
2530 </div>
2531 )}
2532 </Card>
2533 );
2534 }
2535
2536 function PortfolioView({
2537 portfolio,
2538 summary,
2539 positions,
2540 rawPositions,
2541 history,
2542 historyRange,
2543 historyLoading,
2544 onHistoryRange,
2545 searchText,
2546 sortKey,
2547 onSearch,
2548 onSort,
2549 onCreate,
2550 onSync,
2551 onRefreshPrices,
2552 creating,
2553 syncing,
2554 newPortfolioName,
2555 newPortfolioCurrency,
2556 setNewPortfolioName,
2557 setNewPortfolioCurrency,
2558 onUpdateDisplayName,
2559 portfolioResearch
2560 }: {
2561 portfolio?: Portfolio;
2562 summary: PortfolioSummary | null;
2563 positions: PortfolioPosition[];
2564 rawPositions: PortfolioPosition[];
2565 history: PortfolioHistory | null;
2566 historyRange: PortfolioHistoryRange;
2567 historyLoading: boolean;
2568 onHistoryRange: (value: PortfolioHistoryRange) => void;
2569 searchText: string;
2570 sortKey: SortKey;
2571 onSearch: (value: string) => void;
2572 onSort: (value: SortKey) => void;
2573 onCreate: () => void;
2574 onSync?: () => void;
2575 onRefreshPrices?: () => void;
2576 creating: boolean;
2577 syncing: boolean;
2578 newPortfolioName: string;
2579 newPortfolioCurrency: string;
2580 setNewPortfolioName: (value: string) => void;
2581 setNewPortfolioCurrency: (value: string) => void;
2582 onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
2583 portfolioResearch: PortfolioResearchSummary | null;
2584 }) {
2585 if (!portfolio) {
2586 return (
2587 <PortfolioCreatePanel
2588 onCreate={onCreate}
2589 creating={creating}
2590 newPortfolioName={newPortfolioName}
2591 newPortfolioCurrency={newPortfolioCurrency}
2592 setNewPortfolioName={setNewPortfolioName}
2593 setNewPortfolioCurrency={setNewPortfolioCurrency}
2594 />
2595 );
2596 }
2597
2598 const sourceLabels = portfolioSourceLabels(rawPositions);
2599 const manualMarketTotals = onRefreshPrices && rawPositions.every((position) => position.marketValue && position.unrealizedProfitLoss)
2600 ? rawPositions.reduce((totals, position) => ({
2601 marketValue: totals.marketValue + (position.marketValue?.amount ?? 0),
2602 costBasis: totals.costBasis + position.costBasis.amount,
2603 pnl: totals.pnl + (position.unrealizedProfitLoss?.amount ?? 0)
2604 }), { marketValue: 0, costBasis: 0, pnl: 0 }) : null;
2605 const displayCurrency = summary?.baseCurrency ?? portfolio.baseCurrency;
2606 const displayReturn = manualMarketTotals && manualMarketTotals.costBasis !== 0
2607 ? manualMarketTotals.pnl / manualMarketTotals.costBasis * 100
2608 : summary?.unrealizedProfitLossPercent;
2609
2610 return (
2611 <div className="portfolio-layout">
2612 <Card className="wide-panel">
2613 <div className="panel-header">
2614 <div>
2615 <p className="eyebrow">{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual portfolio"}</p>
2616 <h2>{portfolio.name}</h2>
2617 {portfolio.brokerConnectionId ? <p>Last successful sync: {portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : "Not yet available"}</p> : null}
2618 </div>
2619 {onSync ? <Button onClick={onSync} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Syncing..." : "Sync"}</Button> : null}
2620 {onRefreshPrices ? <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"}</Button> : null}
2621 </div>
2622 <p>Base currency: {portfolio.baseCurrency}</p>
2623 </Card>
2624 <section className="metrics-grid" aria-label="Portfolio totals">
2625 <MetricCard label="Securities market value" value={manualMarketTotals ? formatMoney(manualMarketTotals.marketValue, displayCurrency) : formatBackendMoney(summary?.totalMarketValue)} />
2626 <MetricCard label="Total portfolio value" value={manualMarketTotals ? formatMoney(manualMarketTotals.marketValue + (summary?.cash.amount ?? 0), displayCurrency) : summary?.totalMarketValue ? formatMoney(summary.totalMarketValue.amount + summary.cash.amount, summary.baseCurrency) : "N/A"} />
2627 <MetricCard label={sourceLabels.costBasis} value={manualMarketTotals ? formatMoney(manualMarketTotals.costBasis, displayCurrency) : formatBackendMoney(summary?.totalCostBasis)} />
2628 <MetricCard label={sourceLabels.unrealizedProfitLoss} value={manualMarketTotals ? formatMoney(manualMarketTotals.pnl, displayCurrency) : formatBackendMoney(summary?.unrealizedProfitLoss)} tone={valueTone(manualMarketTotals?.pnl ?? summary?.unrealizedProfitLoss?.amount)} />
2629 <MetricCard label={sourceLabels.returnLabel} value={formatPercent(displayReturn)} />
2630 <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
2631 <MetricCard label="Position count" value={String(summary?.positions ?? 0)} />
2632 </section>
2633
2634 <PortfolioHistoryPanel
2635 history={history}
2636 range={historyRange}
2637 loading={historyLoading}
2638 onRange={onHistoryRange}
2639 />
2640
2641 <Card className="wide-panel">
2642 <div className="panel-header">
2643 <div>
2644 <h2>Holdings</h2>
2645 <p>Search respects ticker, company, ISIN, exchange, and country.</p>
2646 <ResearchCoverage research={portfolioResearch} />
2647 </div>
2648 {onSync ? (
2649 <Button onClick={onSync} disabled={syncing} variant="secondary">
2650 <RefreshCw size={16} />
2651 {syncing ? "Syncing..." : sourceLabels.syncButton}
2652 </Button>
2653 ) : null}
2654 {onRefreshPrices ? (
2655 <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary">
2656 <RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"}
2657 </Button>
2658 ) : null}
2659 </div>
2660 <div className="table-toolbar">
2661 <div className="table-search">
2662 <Search size={16} aria-hidden="true" />
2663 <input
2664 aria-label="Filter holdings"
2665 placeholder="Filter holdings"
2666 value={searchText}
2667 onChange={(event) => onSearch(event.target.value)}
2668 />
2669 </div>
2670 <label className="sort-control">
2671 <SlidersHorizontal size={16} aria-hidden="true" />
2672 <span>Sort</span>
2673 <select value={sortKey} onChange={(event) => onSort(event.target.value as SortKey)}>
2674 <option value="marketValue">{sourceLabels.sortMarketValue}</option>
2675 <option value="profitLoss">{sourceLabels.sortProfitLoss}</option>
2676 <option value="allocation">Allocation</option>
2677 <option value="company">Company</option>
2678 <option value="ticker">Ticker</option>
2679 </select>
2680 </label>
2681 </div>
2682 {rawPositions.length === 0 ? (
2683 <EmptyState
2684 title="No positions"
2685 message="This portfolio currently has no positions."
2686 action={onSync ? <Button onClick={onSync}>{sourceLabels.syncButton}</Button> : undefined}
2687 />
2688 ) : (
2689 <HoldingsTable positions={positions} summary={summary} portfolioResearch={portfolioResearch} onUpdateDisplayName={onUpdateDisplayName} />
2690 )}
2691 </Card>
2692
2693 {summary && !onRefreshPrices ? (
2694 <Card className="wide-panel">
2695 <div className="panel-header">
2696 <div>
2697 <h2>Allocation</h2>
2698 <p>Normalized into {summary.baseCurrency}; charts include textual values for accessibility.</p>
2699 </div>
2700 <LineChart size={20} aria-hidden="true" />
2701 </div>
2702 <AllocationCharts summary={summary} />
2703 </Card>
2704 ) : null}
2705 </div>
2706 );
2707 }
2708
2709 function PortfolioHistoryPanel({
2710 history,
2711 range,
2712 loading,
2713 onRange
2714 }: {
2715 history: PortfolioHistory | null;
2716 range: PortfolioHistoryRange;
2717 loading: boolean;
2718 onRange: (value: PortfolioHistoryRange) => void;
2719 }) {
2720 const points = history?.points ?? [];
2721 const first = points[0];
2722 const last = points[points.length - 1];
2723 const absoluteChange = first && last ? last.marketValue.amount - first.marketValue.amount : undefined;
2724 const hasInvestedCapital = points.some((point) => point.investedCapital);
2725
2726 return (
2727 <Card className="wide-panel">
2728 <div className="panel-header">
2729 <div>
2730 <h2>Portfolio value history</h2>
2731 <p>{history?.investedCapitalStatus === "AVAILABLE" ? "Market value and invested capital." : "Portfolio value change; investment return needs cash-flow history."}</p>
2732 </div>
2733 <div className="range-control" aria-label="Portfolio history range">
2734 {portfolioHistoryRanges.map((value) => (
2735 <button
2736 className={range === value ? "range-active" : ""}
2737 key={value}
2738 onClick={() => onRange(value)}
2739 type="button"
2740 >
2741 {value}
2742 </button>
2743 ))}
2744 </div>
2745 </div>
2746 {loading ? (
2747 <Skeleton rows={4} />
2748 ) : points.length === 0 ? (
2749 <EmptyState title="No portfolio history" message="No portfolio history available yet." />
2750 ) : (
2751 <>
2752 <section className="history-metrics" aria-label="Portfolio value change">
2753 <MetricCard label="Starting value" value={formatBackendMoney(first?.marketValue)} />
2754 <MetricCard label="Ending value" value={formatBackendMoney(last?.marketValue)} />
2755 <MetricCard
2756 label="Portfolio value change"
2757 value={absoluteChange === undefined ? "--" : formatMoney(absoluteChange, last?.marketValue.currency)}
2758 tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
2759 />
2760 <MetricCard
2761 label="Value change %"
2762 value={formatChangePercent(first?.marketValue.amount, last?.marketValue.amount)}
2763 tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
2764 />
2765 </section>
2766 <PortfolioHistoryChart points={points} showInvestedCapital={hasInvestedCapital} />
2767 {points.length === 1 ? (
2768 <p className="history-note">Portfolio history will build as real broker snapshots are collected.</p>
2769 ) : null}
2770 {history?.investedCapitalStatus !== "AVAILABLE" ? (
2771 <p className="history-note">INVESTED_CAPITAL_HISTORY_UNAVAILABLE</p>
2772 ) : null}
2773 </>
2774 )}
2775 </Card>
2776 );
2777 }
2778
2779 function PortfolioHistoryChart({
2780 points,
2781 showInvestedCapital
2782 }: {
2783 points: PortfolioHistory["points"];
2784 showInvestedCapital: boolean;
2785 }) {
2786 const width = 900;
2787 const height = 260;
2788 const padding = { top: 18, right: 28, bottom: 34, left: 64 };
2789 const marketValues = points.map((point) => point.marketValue.amount);
2790 const investedValues = showInvestedCapital
2791 ? points.map((point) => point.investedCapital?.amount).filter((value): value is number => value !== undefined)
2792 : [];
2793 const values = [...marketValues, ...investedValues];
2794 const min = Math.min(...values);
2795 const max = Math.max(...values);
2796 const span = max - min || 1;
2797 const times = points.map((point) => new Date(point.timestamp).getTime());
2798 const minTime = Math.min(...times);
2799 const maxTime = Math.max(...times);
2800 const timeSpan = maxTime - minTime || 1;
2801 const singlePoint = points.length === 1;
2802 const x = (timestamp: string) =>
2803 singlePoint
2804 ? padding.left + (width - padding.left - padding.right) / 2
2805 : padding.left + ((new Date(timestamp).getTime() - minTime) / timeSpan) * (width - padding.left - padding.right);
2806 const y = (value: number) =>
2807 singlePoint
2808 ? padding.top + (height - padding.top - padding.bottom) / 2
2809 : padding.top + (1 - (value - min) / span) * (height - padding.top - padding.bottom);
2810 const marketPath = points.length > 1 ? linePath(points.map((point) => [x(point.timestamp), y(point.marketValue.amount)])) : "";
2811 const investedPath = showInvestedCapital && points.length > 1
2812 ? linePath(points.filter((point) => point.investedCapital).map((point) => [x(point.timestamp), y(point.investedCapital?.amount ?? 0)]))
2813 : "";
2814 const latest = points[points.length - 1];
2815
2816 return (
2817 <div className="history-chart">
2818 <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Portfolio value history chart">
2819 <line x1={padding.left} y1={padding.top} x2={padding.left} y2={height - padding.bottom} />
2820 <line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
2821 <text x={padding.left} y={14}>{formatMoney(max, latest?.marketValue.currency)}</text>
2822 <text x={padding.left} y={height - 8}>{formatMoney(min, latest?.marketValue.currency)}</text>
2823 {marketPath ? <path className="market-line" d={marketPath} /> : null}
2824 {investedPath ? <path className="invested-line" d={investedPath} /> : null}
2825 {points.map((point) => (
2826 <circle className="market-point" cx={x(point.timestamp)} cy={y(point.marketValue.amount)} r={points.length === 1 ? 5 : 3} key={point.timestamp}>
2827 <title>
2828 {new Date(point.timestamp).toLocaleString()} | Portfolio {formatBackendMoney(point.marketValue)}
2829 {point.investedCapital ? ` | Invested ${formatBackendMoney(point.investedCapital)}` : ""}
2830 {` | P/L ${formatBackendMoney(point.unrealizedPnl)}`}
2831 </title>
2832 </circle>
2833 ))}
2834 </svg>
2835 </div>
2836 );
2837 }
2838
2839 function linePath(points: number[][]) {
2840 return points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`).join(" ");
2841 }
2842
2843 function FreshnessBadge({ freshness }: { freshness: string }) {
2844 const label = freshness === "END_OF_DAY" ? "EOD" : freshness === "MOCK" ? "DEMO" : freshness.replaceAll("_", "-");
2845 const tone = freshness === "REAL_TIME" || freshness === "REAL_BROKER" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
2846 return <Badge tone={tone}>{label}</Badge>;
2847 }
2848
2849 function ResearchStatusBadge({ status }: { status?: string | null }) {
2850 const normalized = status ?? "UNAVAILABLE";
2851 const label = normalized === "RESOLVED_RESEARCH_AVAILABLE" ? "Available"
2852 : normalized === "RESOLVED_PARTIAL_DATA" ? "Partial"
2853 : normalized === "ETF_UNSUPPORTED" || normalized === "RESEARCH_NOT_APPLICABLE" ? "ETF unsupported"
2854 : normalized.replaceAll("_", " ");
2855 const tone = normalized === "RESOLVED_RESEARCH_AVAILABLE" ? "positive"
2856 : normalized === "RESOLVED_PARTIAL_DATA" ? "warning"
2857 : normalized === "ETF_UNSUPPORTED" || normalized === "RESEARCH_NOT_APPLICABLE" ? "neutral" : "negative";
2858 return <Badge tone={tone}>Research: {label}</Badge>;
2859 }
2860
2861 function ResearchCoverage({ research }: { research: PortfolioResearchSummary | null }) {
2862 if (!research) return null;
2863 const companies = research.companies;
2864 const available = companies.filter((company) => company.status === "RESOLVED_RESEARCH_AVAILABLE").length;
2865 const partial = companies.filter((company) => company.status === "RESOLVED_PARTIAL_DATA").length;
2866 const etfUnsupported = companies.filter((company) => company.status === "ETF_UNSUPPORTED" || company.status === "RESEARCH_NOT_APPLICABLE").length;
2867 const fresh = companies.filter((company) => company.priceFreshness === "FRESH").length;
2868 const stale = companies.filter((company) => company.priceFreshness === "STALE").length;
2869 return <p className="research-coverage" aria-label="Portfolio research coverage"><strong>Research coverage:</strong> {companies.length} holdings · {available} available · {partial} partial · {etfUnsupported} ETF unsupported · {fresh} fresh · {stale} stale</p>;
2870 }
2871
2872 function HoldingsTable({ positions, summary, portfolioResearch, onUpdateDisplayName }: {
2873 positions: PortfolioPosition[];
2874 summary: PortfolioSummary | null;
2875 portfolioResearch: PortfolioResearchSummary | null;
2876 onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
2877 }) {
2878 const [detail, setDetail] = useState<{ position: PortfolioPosition; researchInstrumentId?: string | null } | null>(null);
2879 const manualMarketTotal = positions.some((position) => position.sourceType === "MANUAL_CSV_IMPORT")
2880 && positions.every((position) => position.marketValue)
2881 ? positions.reduce((total, position) => total + (position.marketValue?.amount ?? 0), 0) : null;
2882
2883 function researchFor(position: PortfolioPosition) {
2884 return portfolioResearch?.companies.find((company) =>
2885 company.instrumentId === position.instrument.globalInstrumentId
2886 || company.instrumentId === position.instrument.instrumentId
2887 || Boolean(position.instrument.isin && company.isin === position.instrument.isin)
2888 || (company.ticker === position.instrument.ticker && company.exchange === position.instrument.exchange)
2889 );
2890 }
2891
2892 return (
2893 <div className="table-frame">
2894 <table>
2895 <thead>
2896 <tr>
2897 <th>Company</th>
2898 <th>Ticker</th>
2899 <th>Quantity</th>
2900 <th>Average cost</th>
2901 <th>Latest price</th>
2902 <th>Market value</th>
2903 <th>Unrealized P/L</th>
2904 <th>Unrealized P/L %</th>
2905 <th>Allocation</th>
2906 <th>Currency</th>
2907 <th>Research & price status</th>
2908 </tr>
2909 </thead>
2910 <tbody>
2911 {positions.map((position) => {
2912 const allocation = manualMarketTotal !== null
2913 ? (manualMarketTotal === 0 || !position.marketValue ? null : position.marketValue.amount / manualMarketTotal * 100)
2914 : getAllocationValue(summary, position);
2915 const research = researchFor(position);
2916 const ownershipClass = research?.ownershipIncreases?.length
2917 ? `ownership-${research.ownershipIncreases.map((value) => value.toLowerCase().replace("_fpi", "")).join("-")}`
2918 : "";
2919 const valuationClass = `valuation-${(research?.valuation.state ?? "UNKNOWN").toLowerCase()}`;
2920 return (
2921 <tr className={`${valuationClass} ${ownershipClass}`.trim()} key={position.positionId} onClick={() => setDetail({ position, researchInstrumentId: research?.instrumentId ?? position.instrument.globalInstrumentId })}>
2922 <td>
2923 <button className="security-button" type="button" onClick={() => setDetail({ position, researchInstrumentId: research?.instrumentId ?? position.instrument.globalInstrumentId })}>
2924 <strong>{position.displayName}</strong>
2925 <span>{position.instrument.isin ?? "No ISIN"}</span>
2926 </button>
2927 </td>
2928 <td>{position.instrument.ticker}</td>
2929 <td>{position.quantity.toLocaleString("en")}</td>
2930 <td>{formatMoney(position.averageCost.amount, position.averageCost.currency)}</td>
2931 <td>{formatBackendMoney(position.currentPrice)}</td>
2932 <td>{formatBackendMoney(position.marketValue)}</td>
2933 <td className={position.unrealizedProfitLoss == null ? undefined : position.unrealizedProfitLoss.amount >= 0 ? "positive-text" : "negative-text"}>
2934 {formatBackendMoney(position.unrealizedProfitLoss)}
2935 </td>
2936 <td className={position.unrealizedProfitLossPercent == null ? undefined : position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}>
2937 {formatPercent(position.unrealizedProfitLossPercent)}
2938 </td>
2939 <td>{formatPercent(allocation)}</td>
2940 <td>{position.instrument.tradingCurrency}</td>
2941 <td className="holding-status-cell">
2942 <ResearchStatusBadge status={research?.status} />
2943 <span>Position price: {position.dataFreshness === "IMPORTED_SNAPSHOT" ? "Imported snapshot" : position.dataFreshness.replaceAll("_", " ")}</span>
2944 <span>Live quote: <FreshnessBadge freshness={position.quote?.freshness ?? "UNAVAILABLE"} /></span>
2945 <span>Research price: <FreshnessBadge freshness={research?.priceFreshness ?? "UNKNOWN"} /></span>
2946 <span>Valuation: {(research?.valuation.state ?? "UNKNOWN").replaceAll("_", " ")}</span>
2947 {research?.ownershipIncreases?.length ? <span>Ownership increase: {research.ownershipIncreases.map((value) => value.replaceAll("_", "/")).join(", ")}</span> : <span>Ownership increase: None reported</span>}
2948 </td>
2949 </tr>
2950 );
2951 })}
2952 </tbody>
2953 </table>
2954 {detail ? <StockResearchDrawer position={detail.position} research={detail.researchInstrumentId ? portfolioResearch?.companies.find((company) => company.instrumentId === detail.researchInstrumentId) : researchFor(detail.position)} onUpdateDisplayName={onUpdateDisplayName} onClose={() => setDetail(null)} /> : null}
2955 </div>
2956 );
2957 }
2958
2959 type MetricDisplay = { text: string; title?: string };
2960
2961 function metricDisplay(metric?: ProvenancedValue | null): MetricDisplay {
2962 if (!metric || metric.value === null || metric.value === undefined || metric.value === "") return { text: "N/A" };
2963 const numeric = Number(metric.value);
2964 const unit = metric.unit?.trim();
2965 if (unit?.toUpperCase() === "INR" && Number.isFinite(numeric)) {
2966 const exact = `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric)}`;
2967 const absolute = Math.abs(numeric);
2968 if (absolute >= 10_000_000) return { text: `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric / 10_000_000)} Cr`, title: exact };
2969 if (absolute >= 100_000) return { text: `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric / 100_000)} Lakh`, title: exact };
2970 return { text: exact };
2971 }
2972 const value = Number.isFinite(numeric) ? new Intl.NumberFormat("en", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric) : String(metric.value);
2973 return { text: `${value}${unit === "percent" || unit === "%" ? "%" : unit && unit !== "ratio" ? ` ${unit}` : ""}` };
2974 }
2975
2976 function metricText(metric?: ProvenancedValue | null) {
2977 return metricDisplay(metric).text;
2978 }
2979
2980 function MetricValue({ metric }: { metric?: ProvenancedValue | null }) {
2981 const display = metricDisplay(metric);
2982 return <span title={display.title}>{display.text}</span>;
2983 }
2984
2985 function statementPeriod(value?: string | null) {
2986 return value ? value.slice(0, 10) : "N/A";
2987 }
2988
2989 function latestResultText(research?: PortfolioResearchCompany) {
2990 const result = research?.latestQuarterlyResult;
2991 if (!result) return research ? "Not publicly available" : "Awaiting research";
2992 const growth = result.patYoYPercent ? ` PAT ${metricText(result.patYoYPercent)} YoY` : result.revenueYoYPercent ? ` Revenue ${metricText(result.revenueYoYPercent)} YoY` : "";
2993 return `${statementPeriod(result.period)}${growth}`;
2994 }
2995
2996 function valuationTone(state?: string): "neutral" | "positive" | "negative" | "warning" | "info" {
2997 if (state === "CHEAP") return "info";
2998 if (state === "FAIR") return "positive";
2999 if (state === "EXPENSIVE") return "negative";
3000 return "neutral";
3001 }
3002
3003 function EvidenceMetric({ label, metric }: { label: string; metric?: ProvenancedValue | null }) {
3004 return (
3005 <div className="research-metric">
3006 <span>{label}</span><strong><MetricValue metric={metric} /></strong>
3007 {metric?.sourceUrl ? <a href={metric.sourceUrl} target="_blank" rel="noreferrer">{metric.sourceName}</a> : <small>Not publicly available</small>}
3008 {metric?.calculationBasis ? <small>{metric.calculationBasis}{metric.period ? ` · ${metric.period}` : ""}</small> : null}
3009 </div>
3010 );
3011 }
3012
3013 function structuredFact(research: PortfolioResearchCompany | undefined, key: string): ProvenancedValue | undefined {
3014 return research?.structuredMarket?.facts[key];
3015 }
3016
3017 function FinancialHistoryTable({ periods }: { periods: FinancialResultPeriod[] }) {
3018 if (!periods.length) return <p>Not publicly available.</p>;
3019 return <table className="shareholding-table financial-history-table"><thead><tr><th>Period</th><th>Basis</th><th>Revenue / Total Income</th><th>Operating Income</th><th>EBIT</th><th>EBITDA</th><th>PAT</th><th>EPS</th></tr></thead><tbody>
3020 {periods.map((period) => <tr key={`${period.periodType}:${period.period}:${period.reportingBasis ?? "UNKNOWN"}`}><td>{statementPeriod(period.period)}</td><td>{period.reportingBasis ?? "N/A"}</td><td><MetricValue metric={period.revenue} /></td><td><MetricValue metric={period.operatingIncome} /></td><td><MetricValue metric={period.ebit} /></td><td><MetricValue metric={period.ebitda} /></td><td><MetricValue metric={period.pat} /></td><td><MetricValue metric={period.eps} /></td></tr>)}
3021 </tbody></table>;
3022 }
3023
3024 function StatementHistoryTable({ periods, labels }: { periods: FinancialStatementPeriod[]; labels: Array<[string[], string]> }) {
3025 if (!periods.length) return <p>Not publicly available.</p>;
3026 return <table className="shareholding-table financial-history-table"><thead><tr><th>Period</th><th>Basis</th>{labels.map(([, label]) => <th key={label}>{label}</th>)}</tr></thead><tbody>
3027 {periods.map((period) => <tr key={`${period.periodType}:${period.period}:${period.reportingBasis ?? "UNKNOWN"}`}><td>{statementPeriod(period.period)}</td><td>{period.reportingBasis ?? "N/A"}</td>{labels.map(([keys]) => <td key={keys.join("/")}><MetricValue metric={keys.map((key) => period.metrics[key]).find(Boolean)} /></td>)}</tr>)}
3028 </tbody></table>;
3029 }
3030
3031 function DurableEvidenceSection({ title, evidence }: { title: string; evidence?: NonNullable<CatalystScore["categoryEvidence"]>[string] }) {
3032 const events = evidence?.supportingEvents ?? [];
3033 return <section><h3>{title}</h3>{events.length ? <><p>{events.length} verified item{events.length === 1 ? "" : "s"}</p>{events.map((event) => <article key={event.eventId}><strong>{statementPeriod(event.eventDate ?? event.publishedAt)} · {event.eventType.replaceAll("_", " ")}</strong>{event.summary ? <p>{event.summary}</p> : null}<a href={event.sourceUrl} target="_blank" rel="noreferrer">Source: {event.sourceType}</a></article>)}</> : <p>N/A — No verified evidence.</p>}</section>;
3034 }
3035
3036 const shareholdingCategoryRows = [
3037 ["PROMOTER", "Promoters"],
3038 ["PROMOTER_PLEDGE", "Promoter Pledge*"],
3039 ["FII_FPI", "FII / FPI"],
3040 ["DII", "DII"],
3041 ["PUBLIC_RETAIL", "Retail Public"],
3042 ["MUTUAL_FUNDS", "Mutual Funds"],
3043 ["INSURANCE", "Insurance"],
3044 ["GOVERNMENT", "Government"],
3045 ["OTHERS", "Others"],
3046 ] as const;
3047
3048 const coreShareholdingCategories = new Set(["PROMOTER", "FII_FPI", "DII", "PUBLIC_RETAIL"]);
3049
3050 function shareholdingPeriodLabel(periodEnd: string): string {
3051 return new Intl.DateTimeFormat("en", { month: "short", year: "numeric", timeZone: "UTC" }).format(new Date(periodEnd));
3052 }
3053
3054 function shareholdingPercentage(value: string | undefined): string {
3055 if (value === undefined) return "—";
3056 const percentage = Number(value);
3057 return Number.isFinite(percentage) ? `${percentage.toFixed(2)}%` : "—";
3058 }
3059
3060 function ShareholdingPatternTable({ snapshots }: { snapshots: NonNullable<PortfolioResearchCompany["shareholdingSnapshots"]> }) {
3061 const periods = [...snapshots].slice(0, 4).sort((left, right) =>
3062 new Date(left.periodEnd).getTime() - new Date(right.periodEnd).getTime());
3063 const rows = shareholdingCategoryRows.filter(([category]) =>
3064 coreShareholdingCategories.has(category) || periods.some((snapshot) => snapshot.values.some((value) => value.category === category)));
3065 const newest = periods[periods.length - 1];
3066 const hasPromoterPledge = rows.some(([category]) => category === "PROMOTER_PLEDGE");
3067
3068 return <>
3069 <div className="shareholding-table-frame">
3070 <table className="shareholding-table">
3071 <thead><tr><th scope="col">Category</th>{periods.map((snapshot) => <th scope="col" key={snapshot.id}>{shareholdingPeriodLabel(snapshot.periodEnd)}</th>)}</tr></thead>
3072 <tbody>{rows.map(([category, label]) => <tr key={category}>
3073 <th scope="row" title={category === "PUBLIC_RETAIL" ? "Resident individual shareholders holding nominal share capital up to ₹2 lakh." : undefined}>{label}</th>
3074 {periods.map((snapshot) => {
3075 const value = snapshot.values.find((candidate) => candidate.category === category);
3076 return <td key={snapshot.id}>{shareholdingPercentage(value?.percentage)}</td>;
3077 })}
3078 </tr>)}</tbody>
3079 </table>
3080 </div>
3081 {hasPromoterPledge ? <p className="shareholding-note">* % of promoter holding</p> : null}
3082 {newest?.sourceUrl ? <p className="shareholding-source">Source: <a href={newest.sourceUrl} target="_blank" rel="noreferrer">{newest.sourceProvider} Shareholding XBRL</a></p> : null}
3083 </>;
3084 }
3085
3086 function isFinancialCompany(research: PortfolioResearchCompany | undefined): boolean {
3087 const identity = `${structuredFact(research, "sector")?.value ?? ""} ${structuredFact(research, "industry")?.value ?? ""}`.toLowerCase();
3088 return /financial|bank|insurance|credit|capital market/.test(identity);
3089 }
3090
3091 function StructuredMetric({ label, research, fact }: { label: string; research?: PortfolioResearchCompany; fact: string }) {
3092 return <EvidenceMetric label={label} metric={structuredFact(research, fact)} />;
3093 }
3094
3095 function StockResearchDrawer({ position, watchlistItem, research, onUpdateDisplayName, onClose }: { position?: PortfolioPosition; watchlistItem?: WatchlistResearchInstrument; research?: PortfolioResearchCompany; onUpdateDisplayName?: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>; onClose: () => void }) {
3096 const instrument = position?.instrument;
3097 const displayName = position?.displayName ?? research?.companyName ?? "Watchlist instrument";
3098 const ticker = instrument?.ticker ?? research?.ticker ?? "Ticker N/A";
3099 const exchange = instrument?.exchange ?? research?.exchange ?? "Exchange N/A";
3100 const isin = instrument?.isin ?? research?.isin;
3101 const country = instrument?.country ?? watchlistItem?.country ?? undefined;
3102 const currency = research?.structuredMarket?.resolution.currency ?? instrument?.tradingCurrency ?? watchlistItem?.currency ?? undefined;
3103 const assetType = instrument?.assetType ?? research?.assetType ?? watchlistItem?.assetType ?? "EQUITY";
3104 const result = research?.latestQuarterlyResult;
3105 const financial = isFinancialCompany(research);
3106 const isEtf = assetType === "ETF" || research?.structuredMarket?.resolution.quoteType === "ETF";
3107 const market = research?.structuredMarket;
3108 const latestPriceFact = structuredFact(research, "latestPrice");
3109 const researchCurrentPrice =
3110 latestPriceFact?.value != null
3111 ? Number(latestPriceFact.value)
3112 : research?.currentPrice == null
3113 ? null
3114 : Number(research.currentPrice);
3115 const currentPrice = researchCurrentPrice != null && Number.isFinite(researchCurrentPrice) && researchCurrentPrice > 0
3116 ? formatMoney(researchCurrentPrice, currency)
3117 : position?.currentPrice && position.currentPrice.amount > 0
3118 ? formatBackendMoney(position.currentPrice)
3119 : "N/A";
3120 const [editing, setEditing] = useState(false);
3121 const [name, setName] = useState(position?.customDisplayName ?? displayName);
3122 const [saving, setSaving] = useState(false);
3123 const [renameError, setRenameError] = useState<string | null>(null);
3124 async function saveName() {
3125 if (!name.trim()) { setRenameError("Enter a display name."); return; }
3126 setSaving(true); setRenameError(null);
3127 try { if (onUpdateDisplayName && position) await onUpdateDisplayName(position, name.trim()); setEditing(false); }
3128 catch (error) { setRenameError(getApiFailure(error).message); }
3129 finally { setSaving(false); }
3130 }
3131 return (
3132 <div className="research-drawer-backdrop" role="presentation" onClick={onClose}>
3133 <aside className="research-drawer" role="dialog" aria-modal="true" aria-label={`${displayName} research details`} onClick={(event) => event.stopPropagation()}>
3134 <div className="panel-header"><div><p className="eyebrow">Stock research</p><h2>{displayName}</h2><p>{ticker} · {exchange} · {isin ?? "ISIN N/A"}</p></div><button className="icon-button" type="button" aria-label="Close stock research" onClick={onClose}><X size={20} /></button></div>
3135 <section><h3>Overview</h3>{editing && position ? <div className="holding-name-editor"><label>Display name<input maxLength={160} value={name} onChange={(event) => setName(event.target.value)} /></label><Button disabled={saving} onClick={() => void saveName()}>Save</Button><Button variant="secondary" disabled={saving} onClick={() => { setEditing(false); setName(position.customDisplayName ?? position.displayName); setRenameError(null); }}>Cancel</Button>{renameError ? <small role="alert">{renameError}</small> : null}</div> : <p><strong>{displayName}</strong> {position?.sourceType === "MANUAL_CSV_IMPORT" && onUpdateDisplayName ? <button className="text-action" type="button" onClick={() => setEditing(true)}>Edit</button> : null}</p>}<p>{ticker} · {isin ?? "ISIN N/A"} · {market?.resolution.exchange ?? exchange} · {country ?? "Country N/A"} · {currency ?? "Currency N/A"} · {market?.resolution.quoteType ?? assetType}</p><p>Provider ticker: {market?.resolution.providerTicker ?? "N/A"} · Provider identity: {market?.resolution.companyName ?? "N/A"}</p><p>Sector: {metricText(structuredFact(research, "sector"))} · Industry: {metricText(structuredFact(research, "industry"))}</p><p>{position ? <>Broker/source: {brokerDisplayName(position.brokerType)} · {position.sourceType}</> : <>Watchlist status: Not held</>}</p></section>
3136 {position ? <section><h3>Position</h3><div className="research-metric-grid"><div className="research-metric"><span>Quantity</span><strong>{position.quantity}</strong></div><div className="research-metric"><span>Average Cost</span><strong>{formatBackendMoney(position.averageCost)}</strong></div><div className="research-metric"><span>Cost Basis</span><strong>{formatBackendMoney(position.costBasis)}</strong></div><div className="research-metric"><span>Imported Price</span><strong>{formatBackendMoney(position.importedPrice)}</strong></div><div className="research-metric"><span>Latest Market Price</span><strong>{formatBackendMoney(position.currentPrice)}</strong></div><div className="research-metric"><span>Market Value</span><strong>{formatBackendMoney(position.marketValue)}</strong></div><div className="research-metric"><span>Unrealized P/L</span><strong>{formatBackendMoney(position.unrealizedProfitLoss)}</strong></div></div></section> : <section><h3>Watchlist status</h3><p>Public company research</p><div className="research-metric-grid">{watchlistItem?.sourcePeriod ? <div className="research-metric"><span>Market return ({watchlistItem.sourcePeriod})</span><strong className={`${performanceRowTone(watchlistItem.sourcePerformancePct)}-text`}>{formatSignedPerformancePct(watchlistItem.sourcePerformancePct)}</strong></div> : null}</div></section>}
3137 <section><h3>Market Data</h3><div className="research-metric-grid"><div className="research-metric"><span>Latest Price</span><strong>{currentPrice}</strong></div><EvidenceMetric label="Previous close" metric={structuredFact(research, "previousClose")} /><EvidenceMetric label="Bid" metric={structuredFact(research, "bid")} /><EvidenceMetric label="Ask" metric={structuredFact(research, "ask")} /><EvidenceMetric label="Volume" metric={structuredFact(research, "volume")} /><EvidenceMetric label="10-day avg volume" metric={structuredFact(research, "averageVolume10Day")} /><EvidenceMetric label="3-month avg volume" metric={structuredFact(research, "averageVolume")} /><EvidenceMetric label="52-week low" metric={structuredFact(research, "fiftyTwoWeekLow")} /><EvidenceMetric label="52-week high" metric={structuredFact(research, "fiftyTwoWeekHigh")} /><div className="research-metric"><span>Price freshness</span><strong>{research?.priceFreshness ?? "UNKNOWN"}</strong></div><div className="research-metric"><span>Market As Of</span><strong>{market?.marketAsOf ? new Date(market.marketAsOf).toLocaleString() : position?.quote?.sourceTimestamp ? new Date(position.quote.sourceTimestamp).toLocaleString() : "N/A"}</strong></div><div className="research-metric"><span>Retrieved At</span><strong>{market?.retrievedAt ? new Date(market.retrievedAt).toLocaleString() : position?.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : "N/A"}</strong></div><div className="research-metric"><span>Provider</span><strong>{market?.sourceName ?? position?.quote?.source ?? "N/A"}</strong></div></div></section>
3138 <section><h3>Valuation</h3><div className="research-metric-grid"><StructuredMetric label="Market cap" research={research} fact="marketCap" /><StructuredMetric label="Enterprise value" research={research} fact="enterpriseValue" /><StructuredMetric label="Trailing P/E" research={research} fact="trailingPE" /><StructuredMetric label="Forward P/E" research={research} fact="forwardPE" /><StructuredMetric label="P/B" research={research} fact="priceToBook" /><StructuredMetric label="P/S" research={research} fact="priceToSales" /><StructuredMetric label="PEG" research={research} fact="pegRatio" />{!financial && !isEtf ? <><StructuredMetric label="EV/revenue" research={research} fact="evToRevenue" /><StructuredMetric label="EV/EBITDA" research={research} fact="evToEbitda" /></> : null}<StructuredMetric label="Trailing EPS" research={research} fact="trailingEps" /><StructuredMetric label="Forward EPS" research={research} fact="forwardEps" /></div><p><Badge tone={valuationTone(research?.valuation?.state ?? "UNKNOWN")}>{research?.valuation?.state ?? "UNKNOWN"}</Badge> {research?.valuation?.reason ?? "Awaiting contextual public research."}</p></section>
3139 {!isEtf ? <section><h3>Quality / Fundamentals</h3><div className="research-metric-grid"><EvidenceMetric label="ROE" metric={structuredFact(research, "roe") ?? research?.valuation?.roe} /><StructuredMetric label="ROA" research={research} fact="roa" />{!financial ? <EvidenceMetric label="ROCE" metric={structuredFact(research, "roce") ?? research?.valuation?.roce} /> : null}<StructuredMetric label="Operating margin" research={research} fact="operatingMargin" /><StructuredMetric label="Net margin" research={research} fact="profitMargin" /><StructuredMetric label="Revenue growth" research={research} fact="revenueGrowth" /><StructuredMetric label="Earnings growth" research={research} fact="earningsGrowth" /><StructuredMetric label="Cash" research={research} fact="totalCash" /><StructuredMetric label="Debt" research={research} fact="totalDebt" /><StructuredMetric label="Debt / equity" research={research} fact="debtToEquity" /><StructuredMetric label="Free cash flow" research={research} fact="freeCashFlow" /><StructuredMetric label="Operating cash flow" research={research} fact="operatingCashFlow" /></div></section> : null}
3140 <section><h3>Analyst View</h3><p>External public analyst consensus; not an application recommendation.</p><div className="research-metric-grid"><div className="research-metric"><span>Current Price</span><strong>{currentPrice}</strong></div><StructuredMetric label="Target low" research={research} fact="publicAnalystTargetLowPrice" /><StructuredMetric label="Target median" research={research} fact="publicAnalystTargetMedianPrice" /><StructuredMetric label="Target mean" research={research} fact="publicAnalystTargetMeanPrice" /><StructuredMetric label="Target high" research={research} fact="publicAnalystTargetHighPrice" /><StructuredMetric label="Number of analysts" research={research} fact="publicAnalystCount" /><StructuredMetric label="Consensus" research={research} fact="publicAnalystConsensus" /><StructuredMetric label="Consensus score" research={research} fact="publicAnalystRecommendationMean" /></div></section>
3141 <section><h3>Latest Quarterly Result</h3>{result ? <><p>{result.documentTitle ?? "Quarterly financial result"} · {statementPeriod(result.period)} · {result.reportingBasis ?? "Reporting basis N/A"} · {result.resultDate ? statementPeriod(result.resultDate) : "Result date N/A"}</p><div className="research-metric-grid">{financial ? <><EvidenceMetric label="Total income" metric={result.revenue} /><EvidenceMetric label="PAT / net profit" metric={result.pat} /><EvidenceMetric label="EPS" metric={result.eps} /><EvidenceMetric label="NIM" metric={result.nim} /><EvidenceMetric label="ROA" metric={result.roa} /><EvidenceMetric label="ROE" metric={result.roe} /><EvidenceMetric label="Gross NPA" metric={result.grossNpa} /><EvidenceMetric label="Net NPA" metric={result.netNpa} /><EvidenceMetric label="Deposits" metric={result.deposits} /><EvidenceMetric label="Advances" metric={result.advances} /><EvidenceMetric label="Capital adequacy" metric={result.capitalAdequacy} /><EvidenceMetric label="Credit cost" metric={result.creditCost} /></> : <><EvidenceMetric label="Revenue" metric={result.revenue} /><EvidenceMetric label="Revenue YoY" metric={result.revenueYoYPercent} /><EvidenceMetric label="EBITDA / operating profit" metric={result.ebitda} /><EvidenceMetric label="EBITDA / operating margin" metric={result.ebitdaMargin} /><EvidenceMetric label="PAT / net profit" metric={result.pat} /><EvidenceMetric label="PAT YoY" metric={result.patYoYPercent} /><EvidenceMetric label="EPS" metric={result.eps} /><EvidenceMetric label="Debt / borrowings" metric={result.debtOrBorrowings} /></>}</div>{result.yoySummary ? <p>{result.yoySummary}</p> : null}<p>Source: {filingSourceLabel(result.sourceName)} · Published {result.publishedAt ? new Date(result.publishedAt).toLocaleDateString() : "N/A"} · Retrieved {new Date(result.retrievedAt).toLocaleString()}</p><a href={result.sourceUrl} target="_blank" rel="noreferrer">View {filingSourceLabel(result.sourceName)} Filing ↗</a></> : <p>{research?.quarterlyResultStatus === "PDF_SCANNED_OCR_REQUIRED" ? "PDF scanned; OCR required." : "Not publicly available."}</p>}</section>
3142 {!isEtf ? <section><h3>Financial History</h3><h4>Quarterly</h4><FinancialHistoryTable periods={(research?.financialResultHistory ?? []).filter((period) => period.periodType === "QUARTERLY").slice(0, 4)} /><h4>Annual</h4><FinancialHistoryTable periods={(research?.financialResultHistory ?? []).filter((period) => period.periodType === "ANNUAL")} /></section> : null}
3143 {(country === "IN" || exchange === "NSE" || exchange === "XNSE") ? <section><h3>Shareholding Pattern</h3>{research?.shareholdingSnapshots?.length ? <ShareholdingPatternTable snapshots={research.shareholdingSnapshots} /> : research?.shareholdingChanges?.length ? research.shareholdingChanges.map((change) => <p className={Number(change.changePercentagePoints) >= 0.1 ? `ownership-increase ownership-${change.category.toLowerCase()}` : undefined} key={change.category}><strong>{change.category.replaceAll("_", "/")}</strong>: {metricText(change.current)} ({Number(change.changePercentagePoints) >= 0 ? "+" : ""}{change.changePercentagePoints} pp), {change.previousPeriod} → {change.currentPeriod} · <a href={change.current.sourceUrl} target="_blank" rel="noreferrer">Source</a></p>) : <p>Unavailable.</p>}</section> : null}
3144 {!isEtf ? <section><h3>Debt & Balance Sheet</h3><StatementHistoryTable periods={research?.balanceSheetHistory ?? []} labels={[[["total_assets"], "Total Assets"], [["total_liabilities"], "Total Liabilities"], [["total_equity", "equity"], "Equity / Net Worth"], [["cash_and_cash_equivalents", "cash_and_equivalents"], "Cash / Cash Equivalents"], [["total_debt", "debt_or_borrowings"], "Total Debt"], [["current_assets"], "Current Assets"], [["current_liabilities"], "Current Liabilities"]]} /></section> : null}
3145 {!isEtf ? <section><h3>Cash Flow</h3><StatementHistoryTable periods={research?.cashFlowHistory ?? []} labels={[[["operating_cash_flow", "cash_flow_from_operating_activities"], "Operating Cash Flow"], [["investing_cash_flow", "cash_flow_from_investing_activities"], "Investing Cash Flow"], [["financing_cash_flow", "cash_flow_from_financing_activities"], "Financing Cash Flow"], [["capex"], "Capital Expenditure / Capex"]]} /></section> : null}
3146 <section><h3>Current Quarter Catalysts</h3>{research?.currentQuarterCatalysts?.length ? research.currentQuarterCatalysts.map((event) => <article key={event.eventId}><strong>{event.eventDate ?? event.publishedAt?.slice(0, 10) ?? "Date unavailable"} · {event.eventType.replaceAll("_", " ")}</strong><p>{event.summary}</p><a href={event.sourceUrl} target="_blank" rel="noreferrer">Source: {event.sourceType}</a></article>) : <p>No verified current-quarter catalyst.</p>}</section>
3147 <DurableEvidenceSection title="Orders & Backlog" evidence={research?.durableCategoryEvidence?.ORDERS_BACKLOG} />
3148 <DurableEvidenceSection title="CAPEX & Capacity" evidence={research?.durableCategoryEvidence?.CAPEX} />
3149 <DurableEvidenceSection title="Customers" evidence={research?.durableCategoryEvidence?.CLIENTS} />
3150 <DurableEvidenceSection title="Catalysts" evidence={research?.durableCategoryEvidence?.CATALYSTS} />
3151 <section><h3>News</h3>{market?.news?.length ? market.news.map((article) => <article key={article.url}><strong>{article.headline}</strong><p>{article.publisher} · {article.publishedAt ? new Date(article.publishedAt).toLocaleString() : "Date unavailable"}</p><a href={article.url} target="_blank" rel="noreferrer">Open source</a></article>) : <p>No public provider news available.</p>}</section>
3152 <section><h3>Research & Evidence</h3><p>Status: {research?.status?.replaceAll("_", " ") ?? "Awaiting research"}; {research?.sourceDiversity?.domainsFound ?? 0} unique domains, {research?.sourceDiversity?.exchangeSources ?? 0} exchange sources, {research?.sourceDiversity?.companySources ?? 0} company sources, {research?.sourceDiversity?.secondarySources ?? 0} secondary sources.</p>{research?.latestEvent ? <a href={research.latestEvent.sourceUrl} target="_blank" rel="noreferrer">Latest evidence source</a> : null}</section>
3153 </aside>
3154 </div>
3155 );
3156 }
3157
3158 function AllocationCharts({ summary }: { summary: PortfolioSummary }) {
3159 return (
3160 <div className="allocation-grid">
3161 <AllocationGroup title="Country" values={summary.allocation.country} />
3162 <AllocationGroup title="Sector" values={summary.allocation.sector} />
3163 <AllocationGroup title="Currency" values={summary.allocation.currency} />
3164 <AllocationGroup title="Asset type" values={summary.allocation.assetType} />
3165 <AllocationGroup title="Broker" values={summary.allocation.broker} />
3166 </div>
3167 );
3168 }
3169
3170 function AllocationGroup({ title, values }: { title: string; values: Record<string, number> }) {
3171 const entries = Object.entries(values).sort(([, a], [, b]) => b - a).slice(0, 5);
3172
3173 return (
3174 <section className="allocation-group" aria-label={`${title} allocation`}>
3175 <h3>{title}</h3>
3176 {entries.length === 0 ? (
3177 <p>No allocation data.</p>
3178 ) : (
3179 entries.map(([label, value]) => (
3180 <div className="allocation-row" key={label}>
3181 <div>
3182 <span>{label}</span>
3183 <strong>{formatPercent(value)}</strong>
3184 </div>
3185 <div className="bar-track" aria-hidden="true">
3186 <span style={{ width: `${Math.min(value, 100)}%` }} />
3187 </div>
3188 </div>
3189 ))
3190 )}
3191 </section>
3192 );
3193 }
3194
3195 function BrokerView({
3196 providers,
3197 connections,
3198 portfolios,
3199 errors,
3200 loading,
3201 authenticatingBroker,
3202 authenticationTimedOut,
3203 onContinueAuthentication,
3204 onCancelAuthentication,
3205 onConnectBroker,
3206 onAuthenticate,
3207 onConfigureIndividual,
3208 onImportComplete,
3209 onDisconnect
3210 }: {
3211 providers: BrokerProviderInfo[];
3212 connections: BrokerConnection[];
3213 portfolios: PortfolioListItem[];
3214 errors: Record<string, string>;
3215 loading: boolean;
3216 authenticatingBroker: string | null;
3217 authenticationTimedOut: boolean;
3218 onContinueAuthentication: () => void;
3219 onCancelAuthentication: () => void;
3220 onConnectBroker: (provider: BrokerProviderInfo) => Promise<void>;
3221 onAuthenticate: (connectionId: string, provider: BrokerProviderInfo) => Promise<void>;
3222 onConfigureIndividual: (provider: BrokerProviderInfo, clientKey: string, clientSecret: string) => Promise<void>;
3223 onImportComplete: (portfolioId: string) => Promise<void>;
3224 onDisconnect: (connectionId: string) => Promise<void>;
3225 }) {
3226 const [developerProvider, setDeveloperProvider] = useState<string | null>(null);
3227 const [developerKey, setDeveloperKey] = useState("");
3228 const [developerSecret, setDeveloperSecret] = useState("");
3229 const [importProvider, setImportProvider] = useState<string | null>(null);
3230 const [importFile, setImportFile] = useState<File | null>(null);
3231 const [importName, setImportName] = useState("");
3232 const [importPreview, setImportPreview] = useState<PortfolioImportPreview | null>(null);
3233 const [importBusy, setImportBusy] = useState(false);
3234 const [importError, setImportError] = useState<string | null>(null);
3235 const [importSuccess, setImportSuccess] = useState(false);
3236 const visibleProviders = providers.filter((provider) => provider.brokerType !== "MOCK");
3237 if (visibleProviders.length === 0) return <EmptyState title="No brokers available" message="Broker discovery is unavailable." />;
3238 return (
3239 <div>
3240 <div className="broker-grid">
3241 {visibleProviders.map((provider) => {
3242 const activeConnection = connections.find((connection) => connection.brokerType === provider.brokerType);
3243 const linkedPortfolio = activeConnection
3244 ? portfolios.find((portfolio) => portfolio.brokerConnectionId === activeConnection.connectionId)
3245 : undefined;
3246 const authenticationRequired = activeConnection?.status === "AUTHENTICATION_REQUIRED"
3247 || activeConnection?.providerStatus === "AUTHENTICATION_REQUIRED";
3248 const connected = activeConnection?.status === "CONNECTED"
3249 && (!activeConnection.providerStatus || activeConnection.providerStatus === "CONNECTED");
3250 const recoverable = Boolean(activeConnection) && !connected;
3251 const connectionError = activeConnection?.status === "ERROR"
3252 || activeConnection?.providerStatus === "ERROR"
3253 || activeConnection?.lastErrorCode === "BROKER_UNAVAILABLE";
3254 const partnerUnavailable = provider.consumerAuthMode === "PARTNER_UNAVAILABLE";
3255 const developerIndividualMode = provider.individualApiSupported && provider.advancedIndividualMode;
3256 const meaningfulPersistedLinkage = Boolean(linkedPortfolio || activeConnection?.lastSuccessfulSyncAt || connected);
3257 const authenticationPending = authenticatingBroker === provider.brokerType;
3258 const statusLabel = partnerUnavailable ? "Direct connection unavailable"
3259 : !provider.connectable ? "Unavailable"
3260 : authenticationPending && authenticationTimedOut ? "Authentication pending"
3261 : authenticationPending ? "Waiting for IBKR authentication…"
3262 : authenticationRequired ? "Authentication required"
3263 : connected ? "Connected"
3264 : connectionError ? "Connection error"
3265 : activeConnection ? "Not connected" : "Not connected";
3266 const message = partnerUnavailable
3267 ? "Direct customer account connection is not available yet."
3268 : !provider.connectable
3269 ? provider.unavailableReason ?? "Connection not available yet."
3270 : authenticationPending && authenticationTimedOut
3271 ? "IBKR authentication is still pending."
3272 : authenticationPending
3273 ? "Waiting for IBKR authentication…"
3274 : authenticationRequired
3275 ? "Authentication is required to refresh holdings. Your saved portfolio remains available."
3276 : connected ? "Your broker connection is ready. Sync holdings from the linked portfolio."
3277 : connectionError ? "Broker authentication is temporarily unavailable. Your saved portfolio remains available."
3278 : "Connect this broker to import read-only holdings.";
3279 return (
3280 <Card className="broker-card" as="article" key={provider.brokerType}>
3281 <div className="broker-icon">
3282 <Building2 size={22} aria-hidden="true" />
3283 </div>
3284 <div className="broker-card-content">
3285 <div className="panel-header compact">
3286 <h2>{provider.displayName}</h2>
3287 <Badge tone={connected ? "positive" : "warning"}>{statusLabel}</Badge>
3288 <Badge tone="positive">Read-only</Badge>
3289 </div>
3290 <p className="broker-message">{message}</p>
3291 {!partnerUnavailable && errors[provider.brokerType] ? <div className="broker-inline-error" role="alert">{errors[provider.brokerType]}</div> : null}
3292 {linkedPortfolio ? <p className="broker-linked-portfolio">Portfolio: <strong>{linkedPortfolio.name}</strong></p> : null}
3293 <div className="broker-actions">
3294 {!authenticationPending && !partnerUnavailable && !developerIndividualMode && !activeConnection && provider.connectable ? (
3295 <Button onClick={() => onConnectBroker(provider)} disabled={loading}>
3296 {loading ? "Connecting..." : provider.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
3297 </Button>
3298 ) : null}
3299 {!authenticationPending && !partnerUnavailable && !developerIndividualMode && activeConnection && recoverable ? (
3300 <Button onClick={() => onAuthenticate(activeConnection.connectionId, provider)} disabled={loading}>
3301 {loading ? "Opening sign-in..." : activeConnection.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
3302 </Button>
3303 ) : null}
3304 {authenticationPending && !authenticationTimedOut ? (
3305 <Button variant="ghost" onClick={onCancelAuthentication}>Cancel authentication</Button>
3306 ) : null}
3307 {authenticationPending && authenticationTimedOut ? (
3308 <>
3309 <Button variant="secondary" onClick={onContinueAuthentication}>Continue checking</Button>
3310 {activeConnection ? <Button onClick={() => {
3311 onCancelAuthentication();
3312 void onAuthenticate(activeConnection.connectionId, provider);
3313 }}>Try again</Button> : null}
3314 <Button variant="ghost" onClick={onCancelAuthentication}>Cancel</Button>
3315 </>
3316 ) : null}
3317 {activeConnection && connected ? <Button variant="secondary" disabled>Manage</Button> : null}
3318 {activeConnection && (!partnerUnavailable || meaningfulPersistedLinkage) ? (
3319 <Button variant="ghost" onClick={() => onDisconnect(activeConnection.connectionId)} disabled={loading}>Disconnect</Button>
3320 ) : null}
3321 {developerIndividualMode ? (
3322 <Button variant="secondary" onClick={() => setDeveloperProvider(
3323 developerProvider === provider.brokerType ? null : provider.brokerType
3324 )} disabled={loading}>Configure DEV API</Button>
3325 ) : null}
3326 {provider.manualImportSupported ? (
3327 <Button variant="secondary" onClick={() => {
3328 setImportProvider(importProvider === provider.brokerType ? null : provider.brokerType);
3329 setImportFile(null); setImportPreview(null); setImportError(null); setImportName("");
3330 }} disabled={loading}>Import portfolio</Button>
3331 ) : null}
3332 </div>
3333 {developerIndividualMode && developerProvider === provider.brokerType ? (
3334 <form className="broker-developer-form" onSubmit={async (event) => {
3335 event.preventDefault();
3336 await onConfigureIndividual(provider, developerKey, developerSecret);
3337 setDeveloperKey("");
3338 setDeveloperSecret("");
3339 setDeveloperProvider(null);
3340 }}>
3341 <p>Developer/test individual API setup. Credentials are stored write-only.</p>
3342 <label>API key<input type="password" autoComplete="off" value={developerKey} onChange={(event) => setDeveloperKey(event.target.value)} required /></label>
3343 <label>API secret<input type="password" autoComplete="new-password" value={developerSecret} onChange={(event) => setDeveloperSecret(event.target.value)} required /></label>
3344 <Button type="submit" disabled={loading}>Save DEV credentials</Button>
3345 </form>
3346 ) : null}
3347 {provider.manualImportSupported && importProvider === provider.brokerType ? (
3348 <div className="import-modal-backdrop" role="presentation">
3349 <section className="import-modal" role="dialog" aria-modal="true" aria-label={`Import ${provider.displayName} Portfolio`}>
3350 <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>
3351 {provider.manualImportParserStatus !== "SUPPORTED" ? (
3352 <p role="status">This broker portfolio file format is not yet supported. No columns have been guessed.</p>
3353 ) : (
3354 <>
3355 <p className="import-section-label">Select statement</p>
3356 <label className="import-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
3357 event.preventDefault(); const selected = event.dataTransfer.files[0] ?? null;
3358 if (!selected || !selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024) {
3359 setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
3360 }
3361 setImportFile(selected); setImportPreview(null); setImportError(null);
3362 }}><UploadCloud size={32} /><strong>Drag &amp; 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) => {
3363 const selected = event.target.files?.[0] ?? null;
3364 if (selected && (!selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024)) {
3365 setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
3366 }
3367 setImportFile(selected); setImportPreview(null); setImportError(null);
3368 }} /></label>
3369 {importFile ? <p className="import-file"><CheckCircle2 size={18} /> <strong>{importFile.name.replace(/[\\/]/g, "")}</strong></p> : null}
3370 {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}
3371 <Button variant="secondary" disabled={!importFile || importBusy} onClick={async () => {
3372 if (!importFile) return;
3373 setImportBusy(true); setImportError(null);
3374 try { setImportPreview(await portfolioApi.previewImport(provider.brokerType, importFile, importName)); }
3375 catch (error) { setImportError(getApiFailure(error).message); }
3376 finally { setImportBusy(false); }
3377 }}>{importBusy ? "Reading..." : "Preview portfolio"}</Button>
3378 {importError ? <p className="import-error" role="alert">{importError}</p> : null}
3379 {importPreview ? <div className="import-preview">
3380 <p><strong>{importPreview.updatesExistingPortfolio ? `Update existing portfolio ${importPreview.portfolioName}` : `Portfolio: ${importPreview.portfolioName}`}</strong></p>
3381 <p>Broker: {provider.displayName} · Currency: {importPreview.currency}</p>
3382 {importPreview.statementAt ? <p>Statement date/time: {new Date(importPreview.statementAt).toLocaleString()}</p> : null}
3383 <p>Rows detected: {importPreview.rowsDetected} · Valid holdings: {importPreview.validHoldings} · Rejected rows: {importPreview.rejectedRows}</p>
3384 <p>Columns mapped: {importPreview.columnsMapped.join(", ")}</p>
3385 <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&amp;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>
3386 {importPreview.issues.length ? <ul>{importPreview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul> : null}
3387 <Button disabled={importBusy || importPreview.validHoldings === 0} onClick={async () => {
3388 if (!importFile) return;
3389 setImportBusy(true); setImportError(null);
3390 try {
3391 const result = await portfolioApi.confirmImport(provider.brokerType, importFile, importName);
3392 await onImportComplete(result.portfolioId);
3393 setImportSuccess(true);
3394 window.setTimeout(() => setImportSuccess(false), 3500);
3395 setImportProvider(null); setImportFile(null); setImportPreview(null); setImportName("");
3396 } catch (error) { setImportError(getApiFailure(error).message); }
3397 finally { setImportBusy(false); }
3398 }}>{importBusy ? "Importing..." : importPreview.updatesExistingPortfolio ? "Update Portfolio" : "Confirm Import"}</Button>
3399 </div> : null}
3400 </>
3401 )}
3402 </section></div>
3403 ) : null}
3404 </div>
3405 </Card>
3406 );
3407 })}
3408 </div>{importSuccess ? <div className="success-toast" role="status"><CheckCircle2 size={18} /> Portfolio imported successfully</div> : null}</div>
3409 );
3410 }
3411
3412 function detectedImportAccount(filename: string, brokerType: string): string | null {
3413 const clean = filename.replace(/ \(\d+\)(?=\.csv$)/i, "");
3414 const match = brokerType === "ICICI_DIRECT" ? clean.match(/^(\d+)_PortFolioEqtSummary\.csv$/i)
3415 : brokerType === "HDFC_SECURITIES" ? clean.match(/^Invest Right Equity Portfolio_(\d+)\.csv$/i) : null;
3416 return match?.[1] ?? null;
3417 }
3418
3419 function legacyCompositeCompanyName(value?: string | null): string | null {
3420 const segments = (value ?? "").split("/").map((segment) => segment.trim());
3421 return segments.length >= 3 && segments[2] ? segments[2] : null;
3422 }
3423
3424 function researchCompanyName(position: PortfolioPosition, resolved?: PortfolioResearchCompany): string {
3425 const instrument = position.instrument;
3426 const customName = position.customDisplayName?.trim();
3427 if (customName) return customName;
3428 const ticker = (resolved?.ticker ?? instrument.ticker).trim();
3429 const structuredName = instrument.companyName?.trim();
3430 if (structuredName && structuredName.toUpperCase() !== ticker.toUpperCase()
3431 && !legacyCompositeCompanyName(structuredName)) return structuredName;
3432 const trustedName = instrument.canonicalName?.trim() || resolved?.companyName?.trim();
3433 if (trustedName && trustedName.toUpperCase() !== ticker.toUpperCase()
3434 && !legacyCompositeCompanyName(trustedName)) return trustedName;
3435 for (const candidate of [position.displayName, structuredName, trustedName, instrument.brokerDescription]) {
3436 const parsed = legacyCompositeCompanyName(candidate);
3437 if (parsed) return parsed;
3438 }
3439 return ticker || instrument.brokerSymbol?.trim() || "Unknown company";
3440 }
3441
3442 function ResearchView({
3443 positions,
3444 portfolioResearchSummary,
3445 portfolioResearchLoading,
3446 portfolioResearchError,
3447 researchContext,
3448 watchlistResearch,
3449 watchlistResearchLoading,
3450 watchlistResearchError,
3451 selectedResearchInstrumentId,
3452 onSelectInstrument,
3453 summary,
3454 loading,
3455 eventType,
3456 impact,
3457 onEventType,
3458 onImpact,
3459 onRefresh,
3460 searchPresentation,
3461 onSearchSelect,
3462 searchSelectedMatch,
3463 searchWatchlistSaved,
3464 searchWatchlistBusy,
3465 searchWatchlistError,
3466 onToggleSearchWatchlist
3467 }: {
3468 positions: PortfolioPosition[];
3469 portfolioResearchSummary: PortfolioResearchSummary | null;
3470 portfolioResearchLoading: boolean;
3471 portfolioResearchError: string | null;
3472 researchContext: ResearchContext;
3473 watchlistResearch: WatchlistResearchPresentation | null;
3474 watchlistResearchLoading: boolean;
3475 watchlistResearchError: string | null;
3476 selectedResearchInstrumentId: string;
3477 onSelectInstrument: (value: string) => void;
3478 summary: ResearchSummary | null;
3479 loading: boolean;
3480 eventType: string;
3481 impact: string;
3482 onEventType: (value: string) => void;
3483 onImpact: (value: string) => void;
3484 onRefresh: () => Promise<void>;
3485 searchPresentation: PortfolioResearchCompany | null;
3486 onSearchSelect: (match: ResearchInstrumentMatch) => void;
3487 searchSelectedMatch: ResearchInstrumentMatch | null;
3488 searchWatchlistSaved: boolean;
3489 searchWatchlistBusy: boolean;
3490 searchWatchlistError: string | null;
3491 onToggleSearchWatchlist: () => void;
3492 }) {
3493 const [openSection, setOpenSection] = useState<ResearchSectionId | null>(null);
3494 const [openEventId, setOpenEventId] = useState<string | null>(null);
3495 const [researchDetail, setResearchDetail] = useState<{
3496 position?: PortfolioPosition;
3497 watchlistInstrumentId?: string;
3498 researchInstrumentId: string;
3499 } | null>(null);
3500 useEffect(() => {
3501 setResearchDetail(null);
3502 }, [researchContext.kind, selectedResearchInstrumentId]);
3503 const [watchlistDetailResearch, setWatchlistDetailResearch] = useState<{
3504 instrumentId: string;
3505 research: PortfolioResearchCompany;
3506 } | null>(null);
3507 const [watchlistDetailError, setWatchlistDetailError] = useState<string | null>(null);
3508 const [watchlistDetailLoading, setWatchlistDetailLoading] = useState(false);
3509 const [watchlistDetailRetryKey, setWatchlistDetailRetryKey] = useState(0);
3510 const watchlistDetailFetchId = useRef(0);
3511 const detailRegion =
3512 researchContext.kind === "WATCHLIST" ? researchContext.region : undefined;
3513 useEffect(() => {
3514 const detail = researchDetail;
3515 const region = detailRegion;
3516 if (!detail || !detail.watchlistInstrumentId || !region) {
3517 setWatchlistDetailResearch(null);
3518 setWatchlistDetailError(null);
3519 setWatchlistDetailLoading(false);
3520 return;
3521 }
3522 const fetchId = ++watchlistDetailFetchId.current;
3523 const instrumentId = detail.researchInstrumentId;
3524 setWatchlistDetailLoading(true);
3525 setWatchlistDetailError(null);
3526 researchApi
3527 .getCompanyPresentation(instrumentId, region)
3528 .then((research) => {
3529 if (fetchId !== watchlistDetailFetchId.current) return;
3530 setWatchlistDetailResearch({ instrumentId, research });
3531 setWatchlistDetailLoading(false);
3532 })
3533 .catch((error) => {
3534 if (fetchId !== watchlistDetailFetchId.current) return;
3535 setWatchlistDetailError(getApiFailure(error).message);
3536 setWatchlistDetailLoading(false);
3537 });
3538 }, [researchDetail?.watchlistInstrumentId, researchDetail?.researchInstrumentId, detailRegion, watchlistDetailRetryKey]);
3539 const filteredEvents = (summary?.recentEvents ?? []).filter((event) => {
3540 return (!eventType || event.eventType === eventType) && (!impact || event.impact === impact);
3541 });
3542 const eventTypes = [...new Set((summary?.recentEvents ?? []).map((event) => event.eventType))].sort();
3543 const impacts = [...new Set((summary?.recentEvents ?? []).map((event) => event.impact))].sort();
3544 const documentsById = new Map((summary?.documents ?? []).map((document) => [document.documentId, document]));
3545 const researchSections = summary ? getResearchSections(summary, filteredEvents, eventType, impact) : [];
3546 const selectedPortfolioResearchCompany =
3547 portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedResearchInstrumentId) ?? null;
3548 const selectedWatchlistItem = watchlistResearch?.instruments.find(
3549 (item) => item.globalInstrumentId === selectedResearchInstrumentId
3550 ) ?? null;
3551 const selectedContextResearchCompany = researchContext.kind === "WATCHLIST"
3552 ? selectedWatchlistItem?.company ?? null
3553 : researchContext.kind === "SEARCH" ? searchPresentation : selectedPortfolioResearchCompany;
3554 const detailWatchlistItem = researchDetail?.watchlistInstrumentId
3555 ? watchlistResearch?.instruments.find((item) => item.globalInstrumentId === researchDetail.watchlistInstrumentId)
3556 : undefined;
3557 const portfolioDetailResearch = researchDetail
3558 ? portfolioResearchSummary?.companies.find(
3559 (company) => company.instrumentId === researchDetail.researchInstrumentId
3560 )
3561 : undefined;
3562 const detailResearch: PortfolioResearchCompany | undefined = !researchDetail
3563 ? undefined
3564 : researchDetail.watchlistInstrumentId
3565 ? watchlistDetailResearch?.instrumentId === researchDetail.researchInstrumentId
3566 ? watchlistDetailResearch.research
3567 : undefined
3568 : portfolioDetailResearch
3569 ?? (researchContext.kind === "SEARCH" ? searchPresentation ?? undefined : undefined);
3570 const researchOptions = useMemo(() => {
3571 if (researchContext.kind === "WATCHLIST") {
3572 return (watchlistResearch?.instruments ?? []).map((item) => ({
3573 value: item.globalInstrumentId,
3574 globalInstrumentId: item.globalInstrumentId,
3575 companyName: item.company.companyName,
3576 ticker: item.company.ticker,
3577 exchange: item.company.exchange,
3578 status: item.company.status,
3579 assetType: item.company.assetType,
3580 loadable: isResearchSummaryLoadable(item.company.status),
3581 held: false,
3582 quantity: 0,
3583 }));
3584 }
3585 const resolvedByHoldingKey = new Map<string, PortfolioResearchCompany>();
3586 const tickerCounts = new Map<string, number>();
3587 for (const company of portfolioResearchSummary?.companies ?? []) {
3588 const ticker = (company.ticker ?? "").toUpperCase();
3589 if (ticker) {
3590 tickerCounts.set(ticker, (tickerCounts.get(ticker) ?? 0) + 1);
3591 }
3592 }
3593 for (const company of portfolioResearchSummary?.companies ?? []) {
3594 const ticker = (company.ticker ?? "").toUpperCase();
3595 const keys = [
3596 company.provider && company.providerInstrumentId ? `${company.provider}:${company.providerInstrumentId}` : "",
3597 company.isin ? `isin:${company.isin}` : "",
3598 `${company.ticker ?? ""}:${company.exchange ?? ""}`,
3599 ticker && tickerCounts.get(ticker) === 1 ? `ticker:${ticker}` : ""
3600 ].filter(Boolean);
3601 for (const key of keys) {
3602 resolvedByHoldingKey.set(key.toUpperCase(), company);
3603 }
3604 }
3605
3606 const options = positions.map((position) => {
3607 const instrument = position.instrument;
3608 const globalInstrumentId = instrument.globalInstrumentId?.trim();
3609 const keys = [
3610 globalInstrumentId ? `global:${globalInstrumentId}` : "",
3611 instrument.provider && instrument.providerInstrumentId ? `${instrument.provider}:${instrument.providerInstrumentId}` : "",
3612 instrument.isin ? `isin:${instrument.isin}` : "",
3613 `${instrument.ticker}:${instrument.exchange}`,
3614 `ticker:${instrument.ticker}`
3615 ].filter(Boolean);
3616 const resolved = globalInstrumentId
3617 ? portfolioResearchSummary?.companies.find((company) => company.instrumentId === globalInstrumentId)
3618 : keys.map((key) => resolvedByHoldingKey.get(key.toUpperCase())).find(Boolean);
3619 const status = resolved?.status ?? (globalInstrumentId ? "GLOBAL_INSTRUMENT_RESOLVED" : "COMPANY_NOT_RESOLVED");
3620 const displayTicker = resolved?.ticker ?? instrument.ticker;
3621 const displayExchange = resolved?.exchange ?? instrument.exchange;
3622 return {
3623 // The selected value is always the stable global research identity when
3624 // one is available; display/provider aliases are presentation metadata.
3625 value: globalInstrumentId ?? resolved?.instrumentId ?? instrument.instrumentId,
3626 globalInstrumentId,
3627 companyName: researchCompanyName(position, resolved),
3628 ticker: displayTicker,
3629 exchange: displayExchange,
3630 status,
3631 assetType: resolved?.assetType ?? instrument.assetType,
3632 loadable: Boolean(resolved?.instrumentId) && isResearchSummaryLoadable(status),
3633 held: true,
3634 quantity: position.quantity
3635 };
3636 });
3637 return options;
3638 }, [portfolioResearchSummary, positions, researchContext.kind, watchlistResearch]);
3639 const selectedResearchOption = researchContext.kind === "SEARCH" && searchSelectedMatch
3640 ? { value: searchSelectedMatch.globalInstrumentId, globalInstrumentId: searchSelectedMatch.globalInstrumentId,
3641 companyName: searchSelectedMatch.companyName, status: searchPresentation?.status,
3642 assetType: searchSelectedMatch.assetType, ticker: searchSelectedMatch.canonicalSymbol,
3643 exchange: searchSelectedMatch.exchange, held: false, quantity: 0 }
3644 : researchOptions.find((option) => option.value === selectedResearchInstrumentId);
3645 const refreshEligible = canRefreshResearchIdentity(
3646 selectedResearchOption?.globalInstrumentId ?? "",
3647 selectedContextResearchCompany?.status
3648 ?? (selectedContextResearchCompany ? undefined : selectedResearchOption?.status),
3649 selectedContextResearchCompany?.assetType
3650 ?? (selectedContextResearchCompany ? undefined : selectedResearchOption?.assetType),
3651 );
3652
3653 function toggleSection(sectionId: ResearchSectionId) {
3654 setOpenSection((current) => (current === sectionId ? null : sectionId));
3655 }
3656
3657 function toggleEvent(eventId: string) {
3658 setOpenEventId((current) => (current === eventId ? null : eventId));
3659 }
3660
3661 return (
3662 <div className="research-layout">
3663 <Card className="wide-panel">
3664 <div className="panel-header">
3665 <div>
3666 <h2>Company research</h2>
3667 <p>{researchContext.kind === "PORTFOLIO"
3668 ? "This research context contains actual portfolio holdings only."
3669 : researchContext.kind === "WATCHLIST"
3670 ? `${researchContext.name} · ${researchContext.region} · non-held public research`
3671 : researchContext.kind === "SEARCH"
3672 ? `Opening ${searchSelectedMatch?.companyName ?? searchPresentation?.companyName ?? selectedResearchInstrumentId} without attaching it to a portfolio.`
3673 : `Opening ${researchContext.name} without attaching it to a portfolio.`}</p>
3674 </div>
3675 </div>
3676 {researchContext.kind === "SEARCH" && searchSelectedMatch ? <section>
3677 <h3>{searchSelectedMatch.companyName}</h3>
3678 <p>Public company research · Not held</p>
3679 <p>{[searchSelectedMatch.canonicalSymbol ?? searchSelectedMatch.symbol, searchSelectedMatch.exchange, searchSelectedMatch.isin].filter(Boolean).join(" · ")}</p>
3680 <Button variant="secondary" disabled={!searchPresentation} onClick={() => setResearchDetail({ researchInstrumentId: selectedResearchInstrumentId })}>Open company research</Button>
3681 </section> : null}
3682 {researchContext.kind === "PORTFOLIO" && portfolioResearchError ? <p className="research-refresh-notice" role="alert">{portfolioResearchError}</p> : null}
3683 {watchlistResearchError ? <p className="research-refresh-notice" role="alert">{watchlistResearchError}</p> : null}
3684 {researchContext.kind === "WATCHLIST_PENDING" || watchlistResearchLoading ? <Skeleton rows={3} /> : null}
3685 {(researchContext.kind === "PORTFOLIO" && portfolioResearchSummary)
3686 || (researchContext.kind === "WATCHLIST" && watchlistResearch?.instruments.length) ? (
3687 <div className="portfolio-research-table" role="table" aria-label="Company research summary">
3688 <div className="portfolio-research-row portfolio-research-head" role="row">
3689 <span role="columnheader">Company</span>
3690 <span role="columnheader">Price / P/E</span>
3691 <span role="columnheader">Valuation</span>
3692 <span role="columnheader">Latest result</span>
3693 <span role="columnheader">Ownership</span>
3694 <span role="columnheader">Catalyst</span>
3695 <span role="columnheader">Sources</span>
3696 <span role="columnheader">Status</span>
3697 </div>
3698 {researchContext.kind === "WATCHLIST" ? (watchlistResearch?.instruments ?? []).map((item) => {
3699 const company = item.company;
3700 const companyStatus = company.status ?? "";
3701
3702 const watchlistPrice =
3703 item.marketData?.quote?.last?.amount
3704 ?? item.marketData?.snapshot?.price
3705 ?? null;
3706
3707 const watchlistPe =
3708 item.marketData?.snapshot?.peRatio
3709 ?? null;
3710
3711 const watchlistCurrency =
3712 item.marketData?.quote?.currency
3713 ?? item.marketData?.snapshot?.currency
3714 ?? item.currency
3715 ?? undefined;
3716
3717 const valuationState = company.valuation?.state;
3718 const valuationReason = company.valuation?.reason;
3719
3720 const ownershipIncreases = company.ownershipIncreases ?? [];
3721 const shareholdingChanges = company.shareholdingChanges ?? [];
3722 const catalysts = company.currentQuarterCatalysts ?? [];
3723
3724 const sourceCount = company.sourceCount;
3725 const documentCount = company.documentCount;
3726
3727 const performanceTone = performanceRowTone(item.sourcePerformancePct);
3728 return (
3729 <button
3730 className={`portfolio-research-row portfolio-research-company market-intelligence-research-row market-intelligence-research-row-${performanceTone}`}
3731 key={`watchlist-${item.globalInstrumentId}`}
3732 role="row"
3733 type="button"
3734 data-held="false"
3735 data-region={researchContext.region}
3736 data-global-instrument-id={item.globalInstrumentId}
3737 aria-current={selectedResearchInstrumentId === item.globalInstrumentId ? "true" : undefined}
3738 aria-label={`${company.companyName}, public research${item.sourcePeriod ? `, market return ${formatSignedPerformancePct(item.sourcePerformancePct)}` : ""}`}
3739 onClick={() => {
3740 onSelectInstrument(item.globalInstrumentId);
3741 setResearchDetail({
3742 watchlistInstrumentId: item.globalInstrumentId,
3743 researchInstrumentId: company.instrumentId ?? item.globalInstrumentId,
3744 });
3745 }}
3746 >
3747 <span role="cell">
3748 <strong>{company.companyName} <span className={`research-status-dot research-status-${researchStatusTone(companyStatus)}`} role="img" aria-label={researchStatusDescription(companyStatus)} title={researchStatusDescription(companyStatus)} /></strong>
3749 <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small>
3750 <small>{researchContext.name} · Public company research · Not held</small>
3751 </span>
3752 <span role="cell">
3753 {watchlistPrice == null
3754 ? "N/A"
3755 : formatMoney(Number(watchlistPrice), watchlistCurrency)}
3756 <small>
3757 {watchlistPe == null
3758 ? "P/E N/A"
3759 : `P/E ${new Intl.NumberFormat("en-IN", {
3760 maximumFractionDigits: 2
3761 }).format(Number(watchlistPe))}`}
3762 </small>
3763 </span>
3764 <span role="cell">
3765 <Badge tone={valuationTone(valuationState)}>{valuationState ?? "N/A"}</Badge>
3766 <small>{valuationReason ?? ""}</small>
3767 </span>
3768 <span role="cell">Not publicly available</span>
3769 <span role="cell">
3770 {ownershipIncreases.length
3771 ? ownershipIncreases.map((value) => value === "FII_FPI" ? "FII/FPI" : value.replaceAll("_", " ")).join(" · ")
3772 : "Unavailable"}
3773 <small>{shareholdingChanges.length ? `${shareholdingChanges.length} comparable trends` : "Previous comparable period not found"}</small>
3774 </span>
3775 <span role="cell">
3776 {catalysts.length ? `${catalysts.length} current` : company.catalystScore ?? "None verified"}
3777 </span>
3778 <span role="cell">
3779 {sourceCount == null && documentCount == null
3780 ? "N/A"
3781 : `${sourceCount ?? 0} sources / ${documentCount ?? 0} docs`}
3782 </span>
3783 <span role="cell">
3784 <Badge tone={researchStatusTone(companyStatus)}>{companyStatus.replaceAll("_", " ")}</Badge>
3785 {item.sourcePeriod ? <strong className={`market-intelligence-table-return ${performanceTone}-text`}>Market return ({item.sourcePeriod}) {formatSignedPerformancePct(item.sourcePerformancePct)}</strong> : null}
3786 </span>
3787 </button>
3788 );
3789 }) : null}
3790 {researchContext.kind === "PORTFOLIO" ? (portfolioResearchSummary?.companies ?? []).map((company) => {
3791 const holding = positions.find((position) => position.instrument.globalInstrumentId === company.instrumentId
3792 || position.instrument.instrumentId === company.instrumentId
3793 || Boolean(position.instrument.isin && position.instrument.isin === company.isin)
3794 || (position.instrument.ticker === company.ticker && position.instrument.exchange === company.exchange));
3795 return (
3796 <button
3797 className="portfolio-research-row portfolio-research-company"
3798 key={`${company.instrumentId ?? company.companyName}-${company.status}`}
3799 role="row"
3800 type="button"
3801 onClick={() => {
3802 if (company.instrumentId) {
3803 onSelectInstrument(company.instrumentId);
3804 }
3805 if (holding && company.assetType === "EQUITY" && company.instrumentId) {
3806 setResearchDetail({ position: holding, researchInstrumentId: company.instrumentId });
3807 }
3808 }}
3809 >
3810 <span role="cell">
3811 <strong>{company.companyName} <span className={`research-status-dot research-status-${researchStatusTone(company.status)}`} role="img" aria-label={researchStatusDescription(company.status)} title={researchStatusDescription(company.status)} /></strong>
3812 <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small>
3813 {holding ? <small>Held · Qty {holding.quantity.toLocaleString("en")}</small> : null}
3814 </span>
3815 <span role="cell">{metricText(company.valuation.currentPe)}</span>
3816 <span role="cell"><Badge tone={valuationTone(company.valuation.state)}>{company.valuation.state}</Badge><small>{company.valuation.reason}</small></span>
3817 <span role="cell">{latestResultText(company)}</span>
3818 <span role="cell">{company.ownershipIncreases.length ? company.ownershipIncreases.map((value) => value === "FII_FPI" ? "FII/FPI" : value.replaceAll("_", " ")).join(" · ") : "Unavailable"}<small>{company.shareholdingChanges.length ? `${company.shareholdingChanges.length} comparable trends` : "Previous comparable period not found"}</small></span>
3819 <span role="cell">{company.currentQuarterCatalysts.length ? `${company.currentQuarterCatalysts.length} current` : company.catalystScore ?? "None verified"}</span>
3820 <span role="cell">
3821 {company.sourceCount} sources / {company.documentCount} docs
3822 </span>
3823 <span role="cell">
3824 <Badge tone={researchStatusTone(company.status)}>{company.status.replaceAll("_", " ")}</Badge>
3825 <small>{company.lastRefresh ? new Date(company.lastRefresh).toLocaleString() : company.mode}</small>
3826 </span>
3827 </button>
3828 );
3829 }) : null}
3830 </div>
3831 ) : summary || researchContext.kind === "SEARCH" ? null : (
3832 <EmptyState
3833 title={researchContext.kind === "PORTFOLIO" ? "No company research available" : "No watchlist instruments"}
3834 message={researchContext.kind === "PORTFOLIO"
3835 ? "Select a company and open Research readiness to find only the data it needs."
3836 : "Choose a Market Intelligence stock to add it to this regional watchlist."}
3837 />
3838 )}
3839 {researchDetail && researchDetail.watchlistInstrumentId
3840 ? watchlistDetailLoading
3841 ? <>
3842 <Skeleton rows={4} />
3843 <p>Loading public research...</p>
3844 </>
3845 : watchlistDetailError
3846 ? <ErrorState
3847 message={watchlistDetailError}
3848 action={<Button variant="secondary" onClick={() => setWatchlistDetailRetryKey((key) => key + 1)}>Retry</Button>}
3849 />
3850 : detailResearch
3851 ? <StockResearchDrawer position={researchDetail.position} watchlistItem={detailWatchlistItem} research={detailResearch} onClose={() => setResearchDetail(null)} />
3852 : null
3853 : researchDetail && detailResearch
3854 ? <StockResearchDrawer position={researchDetail.position} watchlistItem={detailWatchlistItem} research={detailResearch} onClose={() => setResearchDetail(null)} />
3855 : null}
3856 </Card>
3857
3858 <Card className="wide-panel research-sticky-panel">
3859 <div className="panel-header">
3860 <div>
3861 <h2>Research intelligence</h2>
3862 <p>Structured evidence, catalyst scoring, and source citations.</p>
3863 </div>
3864 <div className="research-actions">
3865 {summary?.demo ? <Badge tone="info">DEMO</Badge> : null}
3866 {summary && !summary.demo ? <Badge tone="positive">LIVE</Badge> : null}
3867 <Button
3868 variant="secondary"
3869 onClick={onRefresh}
3870 disabled={loading || !refreshEligible}
3871 >
3872 <RefreshCw size={16} />
3873 Research readiness
3874 </Button>
3875 </div>
3876 </div>
3877 <div className="research-controls">
3878 {researchContext.kind === "SEARCH" && searchSelectedMatch && selectedResearchInstrumentId ? (
3879 <small className="research-search-selected">
3880 <span className="research-search-identity" style={{ display: "flex", flexDirection: "column" }}>
3881 <span className="research-search-name">{searchPresentation?.companyName ?? searchSelectedMatch?.companyName ?? selectedResearchInstrumentId}</span>
3882 <span className="research-search-symbol">{[searchSelectedMatch?.canonicalSymbol ?? searchSelectedMatch?.symbol, searchSelectedMatch?.exchange, searchSelectedMatch?.isin].filter(Boolean).join(" · ") || selectedResearchInstrumentId}</span>
3883 <span className="research-search-status">Public company research · Not held</span>
3884 </span>
3885 <Button variant="secondary" disabled={searchWatchlistBusy || searchWatchlistSaved} onClick={() => onToggleSearchWatchlist()}>
3886 {searchWatchlistSaved ? `Saved to ${regionalWatchlistName(searchSelectedMatch.region)}` : `Add to ${regionalWatchlistName(searchSelectedMatch.region)}`}
3887 </Button>
3888 {searchWatchlistError ? <small role="alert">{searchWatchlistError}</small> : null}
3889 </small>
3890 ) : null}
3891 <div className="research-controls-filters" style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center", flexBasis: "100%" }}>
3892 {researchContext.kind !== "SEARCH" ? (
3893 <label className="sort-control research-company-control">
3894 <Search size={16} aria-hidden="true" />
3895 <span>Company</span>
3896 <select
3897 className="research-company-select"
3898 value={selectedResearchInstrumentId}
3899 title={selectedResearchOption?.companyName}
3900 onChange={(event) => onSelectInstrument(event.target.value)}
3901 >
3902 {researchOptions.map((option) => (
3903 <option key={`${option.value}-${option.companyName}`} value={option.value} title={option.companyName}>
3904 {option.companyName}
3905 </option>
3906 ))}
3907 </select>
3908 {selectedResearchOption ? (
3909 <small className="research-company-meta">
3910 {[selectedResearchOption.ticker, selectedResearchOption.exchange,
3911 selectedResearchOption.held
3912 ? `Held · quantity ${selectedResearchOption.quantity.toLocaleString("en")}`
3913 : "Public company research · Not held"].filter(Boolean).join(" · ")}
3914 </small>
3915 ) : null}
3916 </label>
3917 ) : null}
3918 <label className="sort-control">
3919 <span>Event</span>
3920 <select value={eventType} onChange={(event) => onEventType(event.target.value)}>
3921 <option value="">All events</option>
3922 {eventTypes.map((value) => (
3923 <option key={value} value={value}>
3924 {value.replaceAll("_", " ")}
3925 </option>
3926 ))}
3927 </select>
3928 </label>
3929 <label className="sort-control">
3930 <span>Impact</span>
3931 <select value={impact} onChange={(event) => onImpact(event.target.value)}>
3932 <option value="">All impacts</option>
3933 {impacts.map((value) => (
3934 <option key={value} value={value}>
3935 {value.replaceAll("_", " ")}
3936 </option>
3937 ))}
3938 </select>
3939 </label>
3940 </div>
3941 </div>
3942 </Card>
3943
3944 {loading ? <Skeleton rows={5} /> : null}
3945
3946 {!loading && summary ? (
3947 <>
3948 <section className="metrics-grid" aria-label="Research scores">
3949 <MetricCard label="Catalyst score" value={String(summary.catalystScore.overallScore)} meta="0-100 deterministic" />
3950 <MetricCard label="Research confidence" value={`${summary.catalystScore.researchConfidence}%`} />
3951 <MetricCard label="Recent events" value={String(summary.recentEvents.length)} />
3952 <MetricCard label="Documents" value={String(summary.documents.length)} />
3953 <MetricCard label="Freshness" value={summary.dataFreshness} tone={summary.demo ? "warning" : "neutral"} />
3954 <MetricCard label="Last refresh" value={summary.lastRefreshAt ? new Date(summary.lastRefreshAt).toLocaleDateString() : "Not refreshed"} />
3955 </section>
3956
3957 <Card className="wide-panel">
3958 <div className="panel-header">
3959 <div>
3960 <h2>{summary.profile.companyName}</h2>
3961 <p>
3962 {summary.profile.ticker} / {summary.profile.exchange} / {summary.profile.country} / {summary.profile.isin ?? "No ISIN"}
3963 </p>
3964 </div>
3965 </div>
3966 <section className="research-accordion" aria-label="Research detail sections">
3967 {researchSections.map((section) => {
3968 const isOpen = openSection === section.id;
3969 const panelId = `research-section-${section.id}`;
3970 const buttonId = `research-section-button-${section.id}`;
3971
3972 return (
3973 <article className="research-accordion-item" key={section.id}>
3974 <h3>
3975 <button
3976 id={buttonId}
3977 className="research-accordion-button"
3978 type="button"
3979 aria-expanded={isOpen}
3980 aria-controls={panelId}
3981 onClick={() => toggleSection(section.id)}
3982 >
3983 <span>{section.title}</span>
3984 <span className="research-section-meta">{section.metricLabel}</span>
3985 <ChevronDown className="accordion-chevron" size={18} aria-hidden="true" />
3986 </button>
3987 </h3>
3988 <div
3989 id={panelId}
3990 className="research-accordion-panel"
3991 role="region"
3992 aria-labelledby={buttonId}
3993 hidden={!isOpen}
3994 >
3995 {section.id === "overview" ? (
3996 <ResearchOverview summary={summary} />
3997 ) : section.id === "news" ? (
3998 <ResearchEventRows
3999 events={section.events}
4000 documentsById={documentsById}
4001 openEventId={openEventId}
4002 onToggleEvent={toggleEvent}
4003 />
4004 ) : section.id === "sources" ? (
4005 <ResearchSources documents={summary.documents} />
4006 ) : section.events.length > 0 ? (
4007 <div className="research-detail-list">
4008 {section.events.map((event) => (
4009 <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} key={event.eventId} />
4010 ))}
4011 </div>
4012 ) : (
4013 <EmptyState title="No evidence in this section" message="No filtered research events currently map to this topic." />
4014 )}
4015 </div>
4016 </article>
4017 );
4018 })}
4019 </section>
4020 </Card>
4021 </>
4022 ) : null}
4023
4024 {!loading && !summary ? (
4025 <Card className="wide-panel">
4026 <EmptyState
4027 title={researchEmptyTitle(selectedContextResearchCompany?.status)}
4028 message={researchContext.kind === "SEARCH" ? "Open Research readiness to check the data available for this company." : researchEmptyMessage(selectedContextResearchCompany?.status)}
4029 />
4030 </Card>
4031 ) : null}
4032 </div>
4033 );
4034 }
4035
4036 function ResearchOverview({ summary }: { summary: ResearchSummary }) {
4037 const categoryRows = summary.catalystScore.categoryEvidence
4038 ? Object.entries(summary.catalystScore.categoryEvidence).map(([label, evidence]) => [label, evidence.score, evidence.status] as const)
4039 : Object.entries(summary.catalystScore.buckets).map(([label, value]) => [label, value, value === null ? "NO_EVIDENCE" : "NEUTRAL_EVIDENCE"] as const);
4040
4041 return (
4042 <div className="research-overview-grid">
4043 {categoryRows.map(([label, value, status]) => (
4044 <div className="score-row compact" key={label}>
4045 <span>{researchCategoryLabel(label)}</span>
4046 <strong>{value ?? "N/A"}</strong>
4047 <div className="bar-track" aria-hidden="true">
4048 <span style={{ width: `${value ?? 0}%` }} />
4049 </div>
4050 <small>{status.replaceAll("_", " ")}</small>
4051 </div>
4052 ))}
4053 <div className="research-summary-note">
4054 <strong>{summary.demo ? "DEMO research data" : "Live research data"}</strong>
4055 <span>
4056 {summary.recentEvents.length} events / {summary.documents.length} documents / generated{" "}
4057 {new Date(summary.catalystScore.generatedAt).toLocaleDateString()}
4058 </span>
4059 </div>
4060 </div>
4061 );
4062 }
4063
4064 function researchCategoryLabel(category: string) {
4065 return ({
4066 GROWTH: "Growth",
4067 ORDERS_BACKLOG: "Orders & Backlog",
4068 CAPEX: "CAPEX & Capacity",
4069 CLIENTS: "Customers",
4070 GUIDANCE: "Guidance",
4071 } as Record<string, string>)[category] ?? category.replaceAll("_", " ");
4072 }
4073
4074 function ResearchEventRows({
4075 events,
4076 documentsById,
4077 openEventId,
4078 onToggleEvent
4079 }: {
4080 events: ResearchEvent[];
4081 documentsById: Map<string, ResearchDocument>;
4082 openEventId: string | null;
4083 onToggleEvent: (eventId: string) => void;
4084 }) {
4085 if (events.length === 0) {
4086 return <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />;
4087 }
4088
4089 return (
4090 <div className="research-event-rows">
4091 {events.map((event) => {
4092 const isOpen = openEventId === event.eventId;
4093 const panelId = `research-event-${event.eventId}`;
4094 const buttonId = `research-event-button-${event.eventId}`;
4095
4096 return (
4097 <article className="research-event-row" key={event.eventId}>
4098 <h4>
4099 <button
4100 id={buttonId}
4101 className="research-event-row-button"
4102 type="button"
4103 aria-expanded={isOpen}
4104 aria-controls={panelId}
4105 onClick={() => onToggleEvent(event.eventId)}
4106 >
4107 <span>
4108 <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
4109 <strong>{event.title}</strong>
4110 </span>
4111 <span>{event.eventType.replaceAll("_", " ")}</span>
4112 <ChevronDown className="accordion-chevron" size={16} aria-hidden="true" />
4113 </button>
4114 </h4>
4115 <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}>
4116 <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} />
4117 </div>
4118 </article>
4119 );
4120 })}
4121 </div>
4122 );
4123 }
4124
4125 function ResearchEventDetail({ event, document }: { event: ResearchEvent; document?: ResearchDocument }) {
4126 const supportingSources = event.supportingSources?.length
4127 ? event.supportingSources
4128 : [
4129 {
4130 publisher: document?.publisher ?? document?.sourceName ?? event.sourceType,
4131 url: event.sourceUrl,
4132 sourceType: event.sourceClassification ?? document?.sourceClassification ?? event.sourceType,
4133 publishedAt: event.publishedAt ?? event.eventDate ?? document?.publishedAt ?? null,
4134 retrievedAt: event.retrievedAt ?? document?.retrievedAt ?? event.detectedAt,
4135 reliability: event.reliability,
4136 sourceMode: event.sourceMode,
4137 documentId: event.sourceDocumentId,
4138 sourceName: document?.sourceName ?? event.sourceType,
4139 canonicalUrl: event.sourceUrl,
4140 independent: true
4141 }
4142 ];
4143 return (
4144 <div className="research-event-detail">
4145 <div className="panel-header compact">
4146 <div>
4147 <Badge tone="neutral">{event.eventType.replaceAll("_", " ")}</Badge>
4148 <h4>{event.title}</h4>
4149 </div>
4150 <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
4151 </div>
4152 <div className="event-value-line">
4153 <strong>{event.monetaryOriginal ?? (event.capacityValue ? `${event.capacityValue} ${event.capacityUnit}` : event.percentageOriginal ?? "Value undisclosed")}</strong>
4154 <span>{event.customer ?? event.counterparty ?? event.location ?? event.timeHorizon.replaceAll("_", " ")}</span>
4155 </div>
4156 <p>{event.summary}</p>
4157 <dl className="event-facts">
4158 <div>
4159 <dt>Confidence</dt>
4160 <dd>{Math.round(event.confidence * 100)}%</dd>
4161 </div>
4162 <div>
4163 <dt>Reliability</dt>
4164 <dd>{event.reliability}</dd>
4165 </div>
4166 <div>
4167 <dt>Published</dt>
4168 <dd>{event.eventDate ? new Date(event.eventDate).toLocaleDateString() : "Unknown"}</dd>
4169 </div>
4170 <div>
4171 <dt>Source</dt>
4172 <dd>{document?.sourceName ?? event.sourceClassification ?? event.sourceType}</dd>
4173 </div>
4174 <div>
4175 <dt>Horizon</dt>
4176 <dd>{event.timeHorizon.replaceAll("_", " ")}</dd>
4177 </div>
4178 </dl>
4179 <blockquote>{event.rawEvidenceReference}</blockquote>
4180 <div className="supporting-sources">
4181 <strong>Supporting sources</strong>
4182 {supportingSources.map((source) => (
4183 <a className="source-link" href={source.url} target="_blank" rel="noreferrer" key={`${event.eventId}-${source.documentId}`}>
4184 <span>{source.publisher ?? source.sourceName}</span>
4185 <small>
4186 {source.sourceMode} / {source.sourceType.replaceAll("_", " ")} / {source.reliability} /{" "}
4187 {source.publishedAt ? new Date(source.publishedAt).toLocaleDateString() : "No publication date"} /{" "}
4188 {source.independent ? "Independent" : "Duplicate"}
4189 </small>
4190 </a>
4191 ))}
4192 </div>
4193 </div>
4194 );
4195 }
4196
4197 function ResearchSources({ documents }: { documents: ResearchDocument[] }) {
4198 if (documents.length === 0) {
4199 return <EmptyState title="No sources" message="No source documents are available for this research profile." />;
4200 }
4201
4202 return (
4203 <div className="source-list">
4204 {documents.map((document) => (
4205 <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
4206 <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
4207 <span>
4208 {document.sourceMode} / {(document.sourceClassification ?? document.sourceType).replaceAll("_", " ")} / {document.reliabilityLevel} /{" "}
4209 {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}
4210 </span>
4211 <small>
4212 {document.sourceName} / {document.status} / retrieved {new Date(document.retrievedAt).toLocaleDateString()} / hash{" "}
4213 {document.contentHash.slice(0, 12)}
4214 </small>
4215 </a>
4216 ))}
4217 </div>
4218 );
4219 }
4220
4221 function getResearchSections(summary: ResearchSummary, filteredEvents: ResearchEvent[], eventType: string, impact: string) {
4222 const growth = categorySupportingEvents(summary, "GROWTH", filteredEvents, eventType, impact, ["GEOGRAPHIC_EXPANSION", "PARTNERSHIP", "PRODUCT_LAUNCH"]);
4223 const orders = categorySupportingEvents(summary, "ORDERS_BACKLOG", filteredEvents, eventType, impact, ["NEW_ORDER", "ORDER_BACKLOG_CHANGE", "MAJOR_CONTRACT", "GOVERNMENT_CONTRACT"]);
4224 const capex = categorySupportingEvents(summary, "CAPEX", filteredEvents, eventType, impact, ["CAPEX", "CAPACITY_EXPANSION", "NEW_FACILITY", "FACTORY_EXPANSION", "PROJECT_DELAY"]);
4225 const customers = categorySupportingEvents(summary, "CLIENTS", filteredEvents, eventType, impact, ["NEW_CUSTOMER", "CUSTOMER_EXPANSION", "MAJOR_CUSTOMER", "CUSTOMER_LOSS"]);
4226 const guidance = categorySupportingEvents(summary, "GUIDANCE", filteredEvents, eventType, impact, ["GUIDANCE_RAISED", "GUIDANCE_LOWERED", "GUIDANCE_CUT", "GUIDANCE_MAINTAINED", "REVENUE_GUIDANCE", "MARGIN_GUIDANCE"]);
4227
4228 return [
4229 { id: "overview" as const, title: "Overview", metricLabel: `Score ${summary.catalystScore.overallScore}`, events: filteredEvents },
4230 { id: "growth" as const, title: "Growth", metricLabel: categoryMetricLabel(summary, "GROWTH"), events: growth },
4231 { id: "orders" as const, title: "Orders & Backlog", metricLabel: categoryMetricLabel(summary, "ORDERS_BACKLOG"), events: orders },
4232 { id: "capex" as const, title: "CAPEX & Capacity", metricLabel: categoryMetricLabel(summary, "CAPEX"), events: capex },
4233 { id: "customers" as const, title: "Customers", metricLabel: categoryMetricLabel(summary, "CLIENTS"), events: customers },
4234 { id: "guidance" as const, title: "Guidance", metricLabel: categoryMetricLabel(summary, "GUIDANCE"), events: guidance },
4235 { id: "news" as const, title: "News / Events", metricLabel: `Events ${filteredEvents.length}`, events: filteredEvents },
4236 { id: "sources" as const, title: "Sources", metricLabel: `Documents ${summary.documents.length}`, events: [] }
4237 ];
4238 }
4239
4240 function categorySupportingEvents(summary: ResearchSummary, category: string, filteredRecentEvents: ResearchEvent[], eventType: string, impact: string, fallbackTypes: string[]) {
4241 const evidence = summary.catalystScore.categoryEvidence?.[category];
4242 if (evidence?.supportingEvents) {
4243 return evidence.supportingEvents.filter((event) =>
4244 (!eventType || event.eventType === eventType) && (!impact || event.impact === impact)
4245 );
4246 }
4247 return eventsMatching(filteredRecentEvents, fallbackTypes);
4248 }
4249
4250 function categoryMetricLabel(summary: ResearchSummary, category: string) {
4251 const evidence = summary.catalystScore.categoryEvidence?.[category];
4252 if (evidence?.status === "NO_EVIDENCE" || evidence?.score == null) {
4253 return "N/A / No evidence";
4254 }
4255 const count = evidence.independentSourceCount ?? evidence.sourceCount;
4256 const sources = count === 1 ? "1 source" : `${count} sources`;
4257 if (evidence.hasConflict || evidence.status === "MIXED_EVIDENCE") {
4258 return `Mixed evidence / Score ${evidence.score} / ${sources}`;
4259 }
4260 return `Score ${evidence.score} / ${sources}`;
4261 }
4262
4263 function eventsMatching(events: ResearchEvent[], eventTypes: string[]) {
4264 return events.filter((event) => eventTypes.includes(event.eventType));
4265 }
4266
4267 function impactTone(impact: string): "neutral" | "positive" | "negative" | "warning" | "info" {
4268 if (impact.includes("NEGATIVE")) {
4269 return "negative";
4270 }
4271 if (impact.includes("POSITIVE")) {
4272 return "positive";
4273 }
4274 if (impact === "UNCERTAIN") {
4275 return "warning";
4276 }
4277 return "neutral";
4278 }
4279
4280 function researchStatusTone(status: string): "neutral" | "positive" | "negative" | "warning" | "info" {
4281 if (status === "AVAILABLE" || status === "RESOLVED_RESEARCH_AVAILABLE") {
4282 return "positive";
4283 }
4284 if (status === "DEGRADED" || status === "RESOLVED_PARTIAL_DATA" || status === "RESOLVED_NO_SOURCES" || status === "RESEARCH_NOT_REFRESHED" || status === "NO_EVIDENCE") {
4285 return "warning";
4286 }
4287 if (["COMPANY_NOT_RESOLVED", "RESEARCH_PROVIDER_UNAVAILABLE", "SOURCE_DISCOVERY_UNAVAILABLE", "SEARCH_PROVIDER_UNAVAILABLE", "DOCUMENT_FETCH_FAILED"].includes(status)) {
4288 return "negative";
4289 }
4290 if (["SEARCH_RETURNED_ZERO_RESULTS", "RESULTS_REJECTED", "EXTRACTION_EMPTY"].includes(status)) return "warning";
4291 if (status === "RESEARCH_NOT_APPLICABLE" || status === "ETF_UNSUPPORTED") {
4292 return "info";
4293 }
4294 if (status?.startsWith("ETF_RESEARCH_")) {
4295 return status === "ETF_RESEARCH_AVAILABLE" ? "positive" : "info";
4296 }
4297 return "neutral";
4298 }
4299
4300 function researchStatusDescription(status: string): string {
4301 if (status === "RESOLVED_RESEARCH_AVAILABLE") return "Research available";
4302 if (status === "RESOLVED_PARTIAL_DATA") return "Partial research data — some research sections are unavailable.";
4303 if (status === "ETF_UNSUPPORTED" || status === "RESEARCH_NOT_APPLICABLE") return "ETF research unsupported";
4304 return status.replaceAll("_", " ");
4305 }
4306
4307 function isResearchSummaryLoadable(status?: string | null) {
4308 return ["AVAILABLE", "DEGRADED", "RESOLVED_RESEARCH_AVAILABLE", "RESOLVED_PARTIAL_DATA"].includes(status ?? "");
4309 }
4310
4311 function canRefreshResearch(company?: PortfolioResearchCompany | null) {
4312 const status = company?.status ?? "";
4313 return Boolean(company?.instrumentId)
4314 && status !== "ETF_UNSUPPORTED"
4315 && !status.startsWith("ETF_RESEARCH_")
4316 && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE"].includes(status);
4317 }
4318
4319 function canRefreshResearchIdentity(
4320 globalInstrumentId?: string | null,
4321 status?: string | null,
4322 assetType?: string | null,
4323 ) {
4324 const normalizedStatus = status ?? "";
4325 const normalizedAssetType = (assetType ?? "").toUpperCase();
4326 return Boolean(globalInstrumentId?.trim())
4327 && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE", "ETF_UNSUPPORTED"].includes(normalizedStatus)
4328 && !normalizedStatus.startsWith("ETF_RESEARCH_")
4329 && !["ETF", "FUND", "BOND", "CASH", "CRYPTO"].includes(normalizedAssetType);
4330 }
4331
4332 function researchEmptyTitle(status?: string | null) {
4333 if (status === "ETF_UNSUPPORTED") {
4334 return "ETF company research unsupported";
4335 }
4336 if (status === "ETF_RESEARCH_NOT_REFRESHED") {
4337 return "ETF research not refreshed";
4338 }
4339 if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
4340 return "ETF research unavailable";
4341 }
4342 if (status === "RESEARCH_NOT_APPLICABLE") {
4343 return "Research not applicable";
4344 }
4345 if (status === "COMPANY_NOT_RESOLVED") {
4346 return "Company not resolved";
4347 }
4348 if (status === "RESEARCH_NOT_REFRESHED") {
4349 return "Research not refreshed";
4350 }
4351 return "Research unavailable";
4352 }
4353
4354 function researchEmptyMessage(status?: string | null) {
4355 if (status === "ETF_RESEARCH_NOT_REFRESHED") {
4356 return "No shared public ETF research has been collected for this fund yet.";
4357 }
4358 if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
4359 return "No acceptable public ETF source was collected during the last refresh.";
4360 }
4361 if (status === "RESEARCH_NOT_APPLICABLE") {
4362 return "Company-level catalyst research is not applicable for this asset type.";
4363 }
4364 if (status === "COMPANY_NOT_RESOLVED") {
4365 return "This holding did not match a canonical research company.";
4366 }
4367 if (status === "RESEARCH_NOT_REFRESHED") {
4368 return "No shared public research has been collected for this company yet.";
4369 }
4370 return "Select a holding with available company research.";
4371 }
4372
4373 function SettingsView() {
4374 return (
4375 <Card className="wide-panel">
4376 <div className="panel-header">
4377 <div>
4378 <h2>Platform preferences</h2>
4379 <p>Light, dark, and system theme support is wired for frontend work.</p>
4380 </div>
4381 <ShieldCheck size={20} aria-hidden="true" />
4382 </div>
4383 <div className="settings-grid">
4384 <div>
4385 <h3>Data freshness vocabulary</h3>
4386 <p>REAL-TIME, DELAYED, EOD, STALE, DEMO, and UNAVAILABLE labels are supported. DEMO is never presented as live market data.</p>
4387 </div>
4388 <div>
4389 <h3>Error handling</h3>
4390 <p>API errors show a user-safe message and correlation ID without stack traces or internal secrets.</p>
4391 </div>
4392 <div>
4393 <h3>Risk indicators</h3>
4394 <p>Portfolio risk scoring is not yet available.</p>
4395 </div>
4396 <div>
4397 <h3>Recommendation states</h3>
4398 <p>BUY, ADD, HOLD, TRIM, and SELL badges are design-system ready for future recommendation logic.</p>
4399 </div>
4400 </div>
4401 </Card>
4402 );
4403 }
4404
4405 function brokerDisplayName(brokerType: string) {
4406 if (brokerType === "ICICI_DIRECT") {
4407 return "ICICI Direct";
4408 }
4409 if (brokerType === "IBKR") {
4410 return "Interactive Brokers";
4411 }
4412 if (brokerType === "MOCK") {
4413 return "Demo Broker";
4414 }
4415 return brokerType;
4416 }
4417
4418 function StockSearchField({
4419 selectedGlobalInstrumentId,
4420 onSelect,
4421 }: {
4422 selectedGlobalInstrumentId?: string;
4423 onSelect: (match: ResearchInstrumentMatch) => void;
4424 }) {
4425 const DEBOUNCE_MS = 300;
4426 const [region, setRegion] = useState<SectorPerformance["region"]>("INDIA");
4427 const [query, setQuery] = useState("");
4428 const [results, setResults] = useState<ResearchInstrumentMatch[]>([]);
4429 const [open, setOpen] = useState(false);
4430 const [loading, setLoading] = useState(false);
4431 const [error, setError] = useState<string | null>(null);
4432 const [highlight, setHighlight] = useState(0);
4433 const inputRef = useRef<HTMLInputElement>(null);
4434
4435 const cache = useRef(new Map<string, ResearchInstrumentMatch[]>());
4436 const trimmed = query.trim();
4437 useEffect(() => {
4438 let cancelled = false;
4439 setResults([]);
4440 setOpen(false);
4441 setLoading(false);
4442 setError(null);
4443 if (trimmed.length < 3) return;
4444 const key = `${region}:${trimmed.toLowerCase()}`;
4445 const show = (items: ResearchInstrumentMatch[]) => {
4446 if (cancelled) return;
4447 setResults(items);
4448 setOpen(true);
4449 setHighlight(0);
4450 };
4451 const cached = cache.current.get(key);
4452 if (cached) { show(cached); return; }
4453 const active = setTimeout(() => {
4454 setLoading(true);
4455 void researchApi.searchInstruments(region, trimmed, 20)
4456 .then((items) => {
4457 if (cancelled) return;
4458 if (cache.current.size >= 50) cache.current.delete(cache.current.keys().next().value!);
4459 cache.current.set(key, items);
4460 show(items);
4461 })
4462 .catch(() => {
4463 if (!cancelled) setError("Stock search unavailable. Please try again.");
4464 })
4465 .finally(() => { if (!cancelled) setLoading(false); });
4466 }, DEBOUNCE_MS);
4467 return () => { cancelled = true; clearTimeout(active); };
4468 }, [trimmed, region]);
4469
4470 useEffect(() => {
4471 if (open) document.getElementById(`research-option-${highlight}`)?.scrollIntoView({ block: "nearest" });
4472 }, [highlight, open]);
4473
4474 function commit(highlighted: number) {
4475 const item = results[highlighted];
4476 if (!item) return;
4477 onSelect(item);
4478 setQuery("");
4479 setResults([]);
4480 setOpen(false);
4481 }
4482
4483 const rows = results.slice(0, 20);
4484 const rowCount = Math.max(1, rows.length);
4485
4486 return (
4487 <div className="research-search-control">
4488 <div className="research-region-control" role="group" aria-label="Region">
4489 <span>Region</span>
4490 {(["INDIA", "USA", "EUROPE"] as const).map((r) => (
4491 <button
4492 key={r}
4493 type="button"
4494 aria-pressed={r === region}
4495 className={r === region ? "active" : ""}
4496 onClick={() => { setRegion(r); setQuery(""); setResults([]); setOpen(false); }}
4497 >
4498 {r}
4499 </button>
4500 ))}
4501 </div>
4502 <label htmlFor="research-stock-query">Search stocks</label>
4503 <input
4504 id="research-stock-query"
4505 role="combobox"
4506 aria-activedescendant={open && rows.length ? `research-option-${highlight}` : undefined}
4507 ref={inputRef}
4508 type="search"
4509 className="research-search-input"
4510 placeholder="Search by company name, symbol or ISIN..."
4511 value={query}
4512 autoComplete="off"
4513 aria-autocomplete="list"
4514 aria-expanded={open}
4515 aria-controls={open ? "research-search-listbox" : undefined}
4516 onBlur={() => setOpen(false)}
4517 onFocus={() => { if (trimmed.length >= 3 && results.length) setOpen(true); }}
4518 onChange={(event) => setQuery(event.target.value)}
4519 onKeyDown={(event) => {
4520 if (!open) return;
4521 if (event.key === "ArrowDown") {
4522 event.preventDefault();
4523 setHighlight((current) => (current + 1) % rowCount);
4524 } else if (event.key === "ArrowUp") {
4525 event.preventDefault();
4526 setHighlight((current) => (current - 1 + rowCount) % rowCount);
4527 } else if (event.key === "Enter") {
4528 event.preventDefault();
4529 commit(highlight);
4530 } else if (event.key === "Escape") {
4531 event.preventDefault();
4532 setOpen(false);
4533 }
4534 }}
4535 />
4536 {open && !loading && rows.length === 0 ? <small role="status">No stocks found.</small> : null}
4537 {loading ? <small role="status">Searching…</small> : null}
4538 {error ? <small role="alert">{error}</small> : null}
4539 <ul
4540 id="research-search-listbox"
4541 className="research-search-listbox"
4542 role="listbox"
4543 hidden={!open || rows.length === 0}
4544 style={{ position: "absolute", background: "var(--surface-raised)", border: "1px solid var(--border)", maxHeight: "240px", overflowY: "auto", zIndex: 1000 }}
4545 >
4546 {rows.map((item, index) => (
4547 <li
4548 id={`research-option-${index}`}
4549 key={item.globalInstrumentId}
4550 role="option"
4551 aria-selected={index === highlight}
4552 className={index === highlight ? "highlighted" : ""}
4553 onMouseDown={(event) => {
4554 event.preventDefault();
4555 commit(index);
4556 }}
4557 >
4558 <strong>{item.companyName}</strong>
4559 <small>{[item.canonicalSymbol ?? item.symbol, item.exchange, item.isin].filter(Boolean).join(" · ")}</small>
4560 {item.sector ? <small>{item.sector}</small> : null}
4561 </li>
4562 ))}
4563 </ul>
4564 </div>
4565 );
4566 }