"use client"; import { useQueryClient } from "@tanstack/react-query"; import type { LucideIcon } from "lucide-react"; import { Activity, BarChart3, BookOpenText, ChartCandlestick, ChevronLeft, ChevronRight, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs, Search, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { authClient } from "@/lib/auth-client"; import { TaskDetailModal } from "@/components/notifications/task-detail-modal"; import { TaskNotificationsTrigger } from "@/components/notifications/task-notifications-trigger"; import { useSidebarPreference } from "@/components/providers/sidebar-preference-provider"; import { companyAnalysisQueryOptions, companyFinancialStatementsQueryOptions, filingsQueryOptions, holdingsQueryOptions, latestPortfolioInsightQueryOptions, portfolioSummaryQueryOptions, recentTasksQueryOptions, watchlistQueryOptions, } from "@/lib/query/options"; import { buildGraphingHref } from "@/lib/graphing/catalog"; import type { ActiveContext, NavGroup, NavItem } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { useTaskNotificationsCenter } from "@/hooks/use-task-notifications-center"; import { SIDEBAR_PREFERENCE_KEY } from "@/lib/sidebar-preference"; import { cn } from "@/lib/utils"; type AppShellProps = { title: string; subtitle?: string; actions?: React.ReactNode; activeTicker?: string | null; breadcrumbs?: Array<{ label: string; href?: string }>; children: React.ReactNode; }; type NavConfigItem = NavItem & { icon: LucideIcon; }; const NAV_ITEMS: NavConfigItem[] = [ { id: "home", href: "/", label: "Home", icon: Activity, group: "overview", matchMode: "exact", mobilePrimary: true, }, { id: "analysis", href: "/analysis", label: "Overview", icon: LineChart, group: "research", matchMode: "prefix", preserveTicker: true, mobilePrimary: true, }, { id: "research", href: "/research", label: "Research", icon: NotebookTabs, group: "research", matchMode: "exact", preserveTicker: true, mobilePrimary: true, }, { id: "graphing", href: "/graphing", label: "Graphing", icon: BarChart3, group: "research", matchMode: "exact", preserveTicker: true, mobilePrimary: false, }, { id: "financials", href: "/financials", label: "Financials", icon: Landmark, group: "research", matchMode: "exact", preserveTicker: true, mobilePrimary: false, }, { id: "filings", href: "/filings", label: "Filings", icon: BookOpenText, group: "research", matchMode: "exact", preserveTicker: true, mobilePrimary: true, }, { id: "search", href: "/search", label: "Search", icon: Search, group: "research", matchMode: "exact", preserveTicker: true, mobilePrimary: false, }, { id: "portfolio", href: "/portfolio", label: "Portfolio", icon: ChartCandlestick, group: "portfolio", matchMode: "exact", mobilePrimary: true, }, { id: "watchlist", href: "/watchlist", label: "Coverage", icon: Eye, group: "portfolio", matchMode: "exact", mobilePrimary: true, }, ]; const GROUP_LABELS: Record = { overview: "Overview", research: "Research", portfolio: "Portfolio", }; function normalizeTicker(value: string | null | undefined) { const normalized = value?.trim().toUpperCase() ?? ""; return normalized.length > 0 ? normalized : null; } function toTickerHref(baseHref: string, activeTicker: string | null) { if (!activeTicker) { return baseHref; } const separator = baseHref.includes("?") ? "&" : "?"; return `${baseHref}${separator}ticker=${encodeURIComponent(activeTicker)}`; } function resolveNavHref(item: NavItem, context: ActiveContext) { if (item.href === "/graphing") { return buildGraphingHref(context.activeTicker); } if (!item.preserveTicker) { return item.href; } return toTickerHref(item.href, context.activeTicker); } function isItemActive(item: NavItem, pathname: string) { if (item.matchMode === "prefix") { return pathname === item.href || pathname.startsWith(`${item.href}/`); } return pathname === item.href; } function buildDefaultBreadcrumbs( pathname: string, activeTicker: string | null, ) { const analysisHref = toTickerHref("/analysis", activeTicker); const researchHref = toTickerHref("/research", activeTicker); const graphingHref = buildGraphingHref(activeTicker); const financialsHref = toTickerHref("/financials", activeTicker); const filingsHref = toTickerHref("/filings", activeTicker); if (pathname === "/") { return [{ label: "Home" }]; } if (pathname.startsWith("/analysis/reports/")) { return [ { label: "Overview", href: analysisHref }, { label: "Reports", href: analysisHref }, { label: activeTicker ?? "Summary" }, ]; } if (pathname.startsWith("/analysis")) { return [{ label: "Overview" }]; } if (pathname.startsWith("/research")) { return [ { label: "Overview", href: analysisHref }, { label: "Research", href: researchHref }, ]; } if (pathname.startsWith("/financials")) { return [{ label: "Overview", href: analysisHref }, { label: "Financials" }]; } if (pathname.startsWith("/graphing")) { return [ { label: "Overview", href: analysisHref }, { label: "Graphing", href: graphingHref }, { label: activeTicker ?? "Compare Set" }, ]; } if (pathname.startsWith("/filings")) { return [{ label: "Overview", href: analysisHref }, { label: "Filings" }]; } if (pathname.startsWith("/search")) { return [{ label: "Overview", href: analysisHref }, { label: "Search" }]; } if (pathname.startsWith("/portfolio")) { return [{ label: "Portfolio" }]; } if (pathname.startsWith("/watchlist")) { return [{ label: "Portfolio", href: "/portfolio" }, { label: "Coverage" }]; } return [{ label: "Home", href: "/" }, { label: pathname }]; } export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs, children, }: AppShellProps) { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const { initialSidebarCollapsed } = useSidebarPreference(); const [isSigningOut, setIsSigningOut] = useState(false); const [isMoreOpen, setIsMoreOpen] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState( initialSidebarCollapsed, ); const [hasResolvedSidebarPreference, setHasResolvedSidebarPreference] = useState(false); const [hasMounted, setHasMounted] = useState(false); const notifications = useTaskNotificationsCenter(); const { data: session } = authClient.useSession(); const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown; } | null; const role = typeof sessionUser?.role === "string" ? sessionUser.role : Array.isArray(sessionUser?.role) ? sessionUser.role .filter((entry): entry is string => typeof entry === "string") .join(", ") : null; const displayName = sessionUser?.name || sessionUser?.email || "Authenticated user"; const derivedTickerFromPath = useMemo(() => { if (!pathname.startsWith("/analysis/reports/")) { return null; } const segments = pathname.split("/").filter(Boolean); const tickerSegment = segments[2]; return tickerSegment ? normalizeTicker(decodeURIComponent(tickerSegment)) : null; }, [pathname]); const context: ActiveContext = useMemo(() => { const queryTicker = normalizeTicker(searchParams.get("ticker")); return { pathname, activeTicker: normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath, }; }, [activeTicker, derivedTickerFromPath, pathname, searchParams]); const navEntries = useMemo(() => { return NAV_ITEMS.map((item) => { const href = resolveNavHref(item, context); return { ...item, href, active: isItemActive(item, pathname), }; }); }, [context, pathname]); const groupedNav = useMemo(() => { const groups: Array<{ group: NavGroup; items: typeof navEntries }> = [ { group: "overview", items: [] }, { group: "research", items: [] }, { group: "portfolio", items: [] }, ]; for (const entry of navEntries) { const group = groups.find((candidate) => candidate.group === entry.group); if (group) { group.items.push(entry); } } return groups; }, [navEntries]); const mobilePrimaryEntries = useMemo(() => { return navEntries.filter((entry) => entry.mobilePrimary); }, [navEntries]); const mobileMoreEntries = useMemo(() => { return navEntries.filter((entry) => !entry.mobilePrimary); }, [navEntries]); const breadcrumbItems = useMemo(() => { if (breadcrumbs && breadcrumbs.length > 0) { return breadcrumbs; } return buildDefaultBreadcrumbs(pathname, context.activeTicker); }, [breadcrumbs, context.activeTicker, pathname]); const prefetchForHref = (href: string) => { router.prefetch(href); if (href.startsWith("/analysis")) { if (context.activeTicker) { void queryClient.prefetchQuery( companyAnalysisQueryOptions(context.activeTicker), ); } return; } if (href.startsWith("/financials")) { if (context.activeTicker) { void queryClient.prefetchQuery( companyAnalysisQueryOptions(context.activeTicker), ); } return; } if (href.startsWith("/graphing")) { if (context.activeTicker) { void queryClient.prefetchQuery( companyFinancialStatementsQueryOptions({ ticker: context.activeTicker, surfaceKind: "income_statement", cadence: "annual", includeDimensions: false, includeFacts: false, limit: 16, }), ); } return; } if (href.startsWith("/filings")) { void queryClient.prefetchQuery( filingsQueryOptions({ ticker: context.activeTicker ?? undefined, limit: 120, }), ); return; } if (href.startsWith("/search")) { if (context.activeTicker) { void queryClient.prefetchQuery( companyAnalysisQueryOptions(context.activeTicker), ); } return; } if (href.startsWith("/portfolio")) { void queryClient.prefetchQuery(holdingsQueryOptions()); void queryClient.prefetchQuery(portfolioSummaryQueryOptions()); void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions()); return; } if (href.startsWith("/watchlist")) { void queryClient.prefetchQuery(watchlistQueryOptions()); return; } if (href === "/") { void queryClient.prefetchQuery(portfolioSummaryQueryOptions()); void queryClient.prefetchQuery(filingsQueryOptions({ limit: 200 })); void queryClient.prefetchQuery(watchlistQueryOptions()); void queryClient.prefetchQuery(recentTasksQueryOptions(20)); void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions()); } }; useEffect(() => { const storedPreference = window.localStorage.getItem( SIDEBAR_PREFERENCE_KEY, ); if (storedPreference === "true" || storedPreference === "false") { setIsSidebarCollapsed(storedPreference === "true"); } setHasResolvedSidebarPreference(true); }, []); useEffect(() => { if (!hasResolvedSidebarPreference) { return; } window.localStorage.setItem( SIDEBAR_PREFERENCE_KEY, String(isSidebarCollapsed), ); document.cookie = `${SIDEBAR_PREFERENCE_KEY}=${String(isSidebarCollapsed)}; path=/; max-age=31536000; samesite=lax`; }, [hasResolvedSidebarPreference, isSidebarCollapsed]); useEffect(() => { setHasMounted(true); }, []); useEffect(() => { const browserWindow = window as Window & { requestIdleCallback?: (callback: IdleRequestCallback) => number; cancelIdleCallback?: (handle: number) => void; }; const runPrefetch = () => { const prioritized = navEntries.filter( (entry) => entry.id === "analysis" || entry.id === "graphing" || entry.id === "filings" || entry.id === "portfolio", ); for (const entry of prioritized) { prefetchForHref(entry.href); } }; if (browserWindow.requestIdleCallback) { const idleId = browserWindow.requestIdleCallback(() => { runPrefetch(); }); return () => { browserWindow.cancelIdleCallback?.(idleId); }; } const timeoutId = window.setTimeout(() => { runPrefetch(); }, 320); return () => { window.clearTimeout(timeoutId); }; }, [navEntries]); useEffect(() => { notifications.setIsPopoverOpen(false); setIsMoreOpen(false); }, [pathname]); const signOut = async () => { if (isSigningOut) { return; } setIsSigningOut(true); try { await authClient.signOut(); router.replace("/auth/signin"); } finally { setIsSigningOut(false); } }; return (
); }