Files
Neon-Desk/components/shell/app-shell.tsx
francy51 17de3dd72d Add history window controls and expand taxonomy pack support
- add 3Y/5Y/10Y financial history filtering and reorganize normalization details UI
- add new fiscal taxonomy surface/income bridge/KPI packs and update Rust taxonomy loading
- auto-detect Homebrew SQLite for native `sqlite-vec` in local dev/e2e with docs and env guidance
2026-03-18 23:40:28 -04:00

838 lines
27 KiB
TypeScript

"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<NavGroup, string> = {
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 (
<div className="app-surface">
<div
className={cn(
"relative z-10 mx-auto flex min-h-screen w-full max-w-[1440px] gap-6 px-4 pb-10 pt-4 sm:px-5 sm:pb-12 sm:pt-6 md:px-8 lg:gap-8",
isSidebarCollapsed ? "lg:pl-3" : "lg:pl-6",
)}
>
<aside
className={cn(
"hidden shrink-0 flex-col gap-4 border-r border-[color:var(--line-weak)] lg:flex",
hasMounted ? "transition-[width,padding] duration-200" : "",
isSidebarCollapsed ? "w-16 pr-1" : "w-56 pr-4",
)}
>
<div
className={cn(
"flex items-start gap-3",
isSidebarCollapsed ? "justify-center" : "justify-between",
)}
>
{!isSidebarCollapsed ? (
<div className="min-w-0">
<h1 className="text-lg font-semibold text-[color:var(--terminal-bright)]">
Neon Desk
</h1>
</div>
) : null}
<Button
type="button"
variant="ghost"
onClick={() => setIsSidebarCollapsed((prev) => !prev)}
className={cn(
"shrink-0",
isSidebarCollapsed
? "min-h-10 w-10 px-0"
: "min-h-11 w-11 px-0",
)}
aria-label={
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
aria-pressed={isSidebarCollapsed}
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isSidebarCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
</div>
<nav className="space-y-3" aria-label="Primary">
{groupedNav.map(({ group, items }) => (
<div key={group} className="space-y-1.5">
{isSidebarCollapsed ? (
<div
className="mx-auto h-px w-8 bg-[color:var(--line-weak)]"
aria-hidden="true"
/>
) : (
<p className="px-2 text-[11px] text-[color:var(--terminal-muted)]">
{GROUP_LABELS[group]}
</p>
)}
{items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
aria-current={item.active ? "page" : undefined}
aria-label={isSidebarCollapsed ? item.label : undefined}
onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)}
title={item.label}
className={cn(
"flex items-center rounded-lg border border-transparent text-sm transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
isSidebarCollapsed
? "justify-center px-0 py-2.5"
: "gap-3 px-2.5 py-1.5",
item.active
? "border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
: "text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
)}
>
<Icon className="size-4" />
{!isSidebarCollapsed ? <span>{item.label}</span> : null}
</Link>
);
})}
</div>
))}
</nav>
{!isSidebarCollapsed ? (
<>
<Button
className="w-full"
variant="ghost"
onClick={() => void signOut()}
disabled={isSigningOut}
>
<LogOut className="size-4" />
{isSigningOut ? "Signing out..." : "Sign out"}
</Button>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
<p className="text-xs text-[color:var(--terminal-muted)]">
{displayName}
</p>
{role ? (
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
{role}
</p>
) : null}
</div>
</>
) : null}
</aside>
<div className="min-w-0 flex-1 pb-24 lg:pb-0">
<header className="relative mb-3 border-b border-[color:var(--line-weak)] pb-3 pr-16 sm:pb-4 sm:pr-20">
<div className="absolute right-4 top-4 z-10 sm:right-5 sm:top-5">
<TaskNotificationsTrigger
unreadCount={notifications.unreadCount}
isPopoverOpen={notifications.isPopoverOpen}
setIsPopoverOpen={notifications.setIsPopoverOpen}
isLoading={notifications.isLoading}
activeEntries={notifications.activeEntries}
visibleFinishedEntries={notifications.visibleFinishedEntries}
awaitingReviewEntries={notifications.awaitingReviewEntries}
showReadFinished={notifications.showReadFinished}
setShowReadFinished={notifications.setShowReadFinished}
openTaskDetails={notifications.openTaskDetails}
openTaskAction={notifications.openTaskAction}
silenceEntry={notifications.silenceEntry}
markEntryRead={notifications.markEntryRead}
/>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="min-w-0 pr-6 sm:pr-0">
<h2 className="text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl">
{title}
</h2>
{subtitle ? (
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
{subtitle}
</p>
) : null}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
{actions}
<Button
variant="ghost"
className="max-sm:hidden sm:inline-flex lg:hidden"
onClick={() => void signOut()}
disabled={isSigningOut}
>
<LogOut className="size-4" />
{isSigningOut ? "Signing out..." : "Sign out"}
</Button>
</div>
</div>
</header>
<nav
aria-label="Breadcrumb"
className="mb-4 overflow-x-auto border-b border-[color:var(--line-weak)] pb-2"
>
<ol className="flex min-w-max items-center gap-2 text-xs text-[color:var(--terminal-muted)] sm:min-w-0 sm:flex-wrap">
{breadcrumbItems.map((item, index) => {
const isLast = index === breadcrumbItems.length - 1;
return (
<li
key={`${item.label}-${index}`}
className="flex items-center gap-2"
>
{item.href && !isLast ? (
<Link
href={item.href}
onMouseEnter={() =>
prefetchForHref(item.href as string)
}
onFocus={() => prefetchForHref(item.href as string)}
className="rounded px-1 py-0.5 text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
>
{item.label}
</Link>
) : (
<span
className={cn(
isLast ? "text-[color:var(--terminal-bright)]" : "",
)}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
)}
{!isLast ? <span aria-hidden="true">/</span> : null}
</li>
);
})}
</ol>
</nav>
<main className="min-w-0 space-y-4">{children}</main>
</div>
</div>
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:var(--panel)] px-2 py-2 lg:hidden"
aria-label="Mobile primary"
>
<div
className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-1 px-1"
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) * 0.5)" }}
>
{mobilePrimaryEntries.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
aria-current={item.active ? "page" : undefined}
onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)}
className={cn(
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
item.active
? "bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
: "text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
)}
>
<Icon className="size-4" />
<span className="truncate">{item.label}</span>
</Link>
);
})}
<button
type="button"
aria-haspopup="dialog"
aria-expanded={isMoreOpen}
onClick={() => setIsMoreOpen((prev) => !prev)}
className="flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] text-[color:var(--terminal-muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
>
<Menu className="size-4" />
<span>More</span>
</button>
</div>
</nav>
{isMoreOpen ? (
<div
className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden"
onClick={() => setIsMoreOpen(false)}
>
<div
role="dialog"
aria-modal="true"
className="absolute inset-x-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3"
style={{ bottom: "calc(4.7rem + env(safe-area-inset-bottom))" }}
onClick={(event) => event.stopPropagation()}
>
<p className="mb-2 px-1 text-[11px] text-[color:var(--terminal-muted)]">
More destinations
</p>
<div className="grid grid-cols-2 gap-2">
{mobileMoreEntries.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
aria-current={item.active ? "page" : undefined}
onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)}
onClick={() => setIsMoreOpen(false)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
item.active
? "border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
: "border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
)}
>
<Icon className="size-4" />
<span>{item.label}</span>
</Link>
);
})}
<button
type="button"
onClick={() => {
setIsMoreOpen(false);
void signOut();
}}
className="col-span-2 flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
>
<LogOut className="size-4" />
{isSigningOut ? "Signing out..." : "Sign out"}
</button>
</div>
</div>
</div>
) : null}
<TaskDetailModal
isOpen={notifications.isDetailOpen}
taskId={notifications.detailTaskId}
onClose={() => notifications.setIsDetailOpen(false)}
/>
</div>
);
}