diff --git a/app/analysis/page.tsx b/app/analysis/page.tsx index f30a64f..8e0e8db 100644 --- a/app/analysis/page.tsx +++ b/app/analysis/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { format } from 'date-fns'; @@ -19,7 +20,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Panel } from '@/components/ui/panel'; import { useAuthGuard } from '@/hooks/use-auth-guard'; -import { getCompanyAnalysis } from '@/lib/api'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { asNumber, formatCurrency, @@ -27,6 +28,8 @@ import { formatPercent, type NumberScaleUnit } from '@/lib/format'; +import { queryKeys } from '@/lib/query/keys'; +import { companyAnalysisQueryOptions } from '@/lib/query/options'; import type { CompanyAnalysis } from '@/lib/types'; type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly'; @@ -53,6 +56,12 @@ const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: stri { value: 'billions', label: 'Billions (B)' } ]; +const CHART_TEXT = '#e8fff8'; +const CHART_MUTED = '#b4ced9'; +const CHART_GRID = 'rgba(126, 217, 255, 0.24)'; +const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)'; +const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)'; + function formatShortDate(value: string) { return format(new Date(value), 'MMM yyyy'); } @@ -109,6 +118,8 @@ export default function AnalysisPage() { function AnalysisPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch(); const [tickerInput, setTickerInput] = useState('MSFT'); const [ticker, setTicker] = useState('MSFT'); @@ -134,11 +145,16 @@ function AnalysisPageContent() { }, [searchParams]); const loadAnalysis = useCallback(async (symbol: string) => { - setLoading(true); + const options = companyAnalysisQueryOptions(symbol); + + if (!queryClient.getQueryData(options.queryKey)) { + setLoading(true); + } + setError(null); try { - const response = await getCompanyAnalysis(symbol); + const response = await queryClient.ensureQueryData(options); setAnalysis(response.analysis); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to load company analysis'); @@ -146,7 +162,7 @@ function AnalysisPageContent() { } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -199,8 +215,15 @@ function AnalysisPageContent() { void loadAnalysis(ticker)}> + @@ -229,7 +252,12 @@ function AnalysisPageContent() { Analyze {analysis ? ( - + prefetchResearchTicker(analysis.company.ticker)} + onFocus={() => prefetchResearchTicker(analysis.company.ticker)} + className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" + > Open filing stream ) : null} @@ -277,10 +305,35 @@ function AnalysisPageContent() {
- - - `$${value.toFixed(0)}`} /> - formatCurrency(value)} /> + + + `$${value.toFixed(0)}`} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ stroke: 'rgba(104, 255, 213, 0.35)', strokeWidth: 1 }} + /> @@ -432,6 +485,8 @@ function AnalysisPageContent() {

{report.accessionNumber}

prefetchReport(analysis.company.ticker, report.accessionNumber)} + onFocus={() => prefetchReport(analysis.company.ticker, report.accessionNumber)} className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > Open summary diff --git a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx index 7b8b0b8..d304b61 100644 --- a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx +++ b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { format } from 'date-fns'; @@ -7,7 +8,9 @@ import { ArrowLeft, BrainCircuit, RefreshCcw } from 'lucide-react'; import { useParams } from 'next/navigation'; import { AppShell } from '@/components/shell/app-shell'; import { useAuthGuard } from '@/hooks/use-auth-guard'; -import { getCompanyAiReport } from '@/lib/api'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; +import { queryKeys } from '@/lib/query/keys'; +import { aiReportQueryOptions } from '@/lib/query/options'; import type { CompanyAiReportDetail } from '@/lib/types'; import { Button } from '@/components/ui/button'; import { Panel } from '@/components/ui/panel'; @@ -25,6 +28,8 @@ function formatFilingDate(value: string) { export default function AnalysisReportPage() { const { isPending, isAuthenticated } = useAuthGuard(); const params = useParams<{ ticker: string; accessionNumber: string }>(); + const queryClient = useQueryClient(); + const { prefetchResearchTicker } = useLinkPrefetch(); const tickerFromRoute = useMemo(() => { const value = typeof params.ticker === 'string' ? params.ticker : ''; @@ -48,11 +53,16 @@ export default function AnalysisReportPage() { return; } - setLoading(true); + const options = aiReportQueryOptions(accessionNumber); + + if (!queryClient.getQueryData(options.queryKey)) { + setLoading(true); + } + setError(null); try { - const response = await getCompanyAiReport(accessionNumber); + const response = await queryClient.ensureQueryData(options); setReport(response.report); } catch (err) { setReport(null); @@ -60,7 +70,7 @@ export default function AnalysisReportPage() { } finally { setLoading(false); } - }, [accessionNumber]); + }, [accessionNumber, queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -73,13 +83,30 @@ export default function AnalysisReportPage() { } const resolvedTicker = report?.ticker ?? tickerFromRoute; + const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis'; + const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings'; return ( void loadReport()} disabled={loading}> + @@ -88,14 +115,18 @@ export default function AnalysisReportPage() {
prefetchResearchTicker(resolvedTicker)} + onFocus={() => prefetchResearchTicker(resolvedTicker)} className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > Back to analysis prefetchResearchTicker(resolvedTicker)} + onFocus={() => prefetchResearchTicker(resolvedTicker)} className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > diff --git a/app/filings/page.tsx b/app/filings/page.tsx index 831b76f..5be0d26 100644 --- a/app/filings/page.tsx +++ b/app/filings/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; @@ -12,10 +13,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { StatusPill } from '@/components/ui/status-pill'; import { useAuthGuard } from '@/hooks/use-auth-guard'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useTaskPoller } from '@/hooks/use-task-poller'; -import { getTask, listFilings, queueFilingAnalysis, queueFilingSync } from '@/lib/api'; +import { queueFilingAnalysis, queueFilingSync } from '@/lib/api'; import type { Filing, Task } from '@/lib/types'; import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format'; +import { queryKeys } from '@/lib/query/keys'; +import { filingsQueryOptions, taskQueryOptions } from '@/lib/query/options'; const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [ { value: 'thousands', label: 'Thousands (K)' }, @@ -102,6 +106,8 @@ function FilingExternalLink({ href, label }: FilingExternalLinkProps) { function FilingsPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const { prefetchReport } = useLinkPrefetch(); const [filings, setFilings] = useState([]); const [loading, setLoading] = useState(true); @@ -123,18 +129,23 @@ function FilingsPageContent() { }, [searchParams]); const loadFilings = useCallback(async (ticker?: string) => { - setLoading(true); + const options = filingsQueryOptions({ ticker, limit: 120 }); + + if (!queryClient.getQueryData(options.queryKey)) { + setLoading(true); + } + setError(null); try { - const response = await listFilings({ ticker, limit: 120 }); + const response = await queryClient.ensureQueryData(options); setFilings(response.filings); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to fetch filings'); } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -146,6 +157,8 @@ function FilingsPageContent() { taskId: activeTask?.id ?? null, onTerminalState: async () => { setActiveTask(null); + void queryClient.invalidateQueries({ queryKey: ['filings'] }); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); await loadFilings(searchTicker || undefined); } }); @@ -159,8 +172,10 @@ function FilingsPageContent() { try { const { task } = await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 }); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setActiveTask(latest.task); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: ['filings'] }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to queue filing sync'); } @@ -169,8 +184,10 @@ function FilingsPageContent() { const triggerAnalysis = async (accessionNumber: string) => { try { const { task } = await queueFilingAnalysis(accessionNumber); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setActiveTask(latest.task); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: ['report'] }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to queue filing analysis'); } @@ -196,10 +213,18 @@ function FilingsPageContent() { return ( void loadFilings(searchTicker || undefined)}> + @@ -351,6 +376,8 @@ function FilingsPageContent() { {hasAnalysis ? ( prefetchReport(filing.ticker, filing.accession_number)} + onFocus={() => prefetchReport(filing.ticker, filing.accession_number)} className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > Open summary @@ -419,6 +446,8 @@ function FilingsPageContent() { {hasAnalysis ? ( prefetchReport(filing.ticker, filing.accession_number)} + onFocus={() => prefetchReport(filing.ticker, filing.accession_number)} className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > Summary diff --git a/app/financials/page.tsx b/app/financials/page.tsx index 7f88963..50eafa0 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { format } from 'date-fns'; @@ -25,12 +26,14 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Panel } from '@/components/ui/panel'; import { useAuthGuard } from '@/hooks/use-auth-guard'; -import { getCompanyAnalysis } from '@/lib/api'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { formatCurrencyByScale, formatPercent, type NumberScaleUnit } from '@/lib/format'; +import { queryKeys } from '@/lib/query/keys'; +import { companyAnalysisQueryOptions } from '@/lib/query/options'; import type { CompanyAnalysis } from '@/lib/types'; type StatementPeriodPoint = { @@ -76,6 +79,16 @@ const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: stri { value: 'billions', label: 'Billions (B)' } ]; +const CHART_TEXT = '#e8fff8'; +const CHART_MUTED = '#b4ced9'; +const CHART_GRID = 'rgba(126, 217, 255, 0.24)'; +const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)'; +const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)'; + +function renderLegendLabel(value: string) { + return {value}; +} + function formatShortDate(value: string) { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { @@ -380,6 +393,8 @@ export default function FinancialsPage() { function FinancialsPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const { prefetchResearchTicker } = useLinkPrefetch(); const [tickerInput, setTickerInput] = useState('MSFT'); const [ticker, setTicker] = useState('MSFT'); @@ -405,11 +420,16 @@ function FinancialsPageContent() { }, [searchParams]); const loadFinancials = useCallback(async (symbol: string) => { - setLoading(true); + const options = companyAnalysisQueryOptions(symbol); + + if (!queryClient.getQueryData(options.queryKey)) { + setLoading(true); + } + setError(null); try { - const response = await getCompanyAnalysis(symbol); + const response = await queryClient.ensureQueryData(options); setAnalysis(response.analysis); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to load financial history'); @@ -417,7 +437,7 @@ function FinancialsPageContent() { } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -535,8 +555,15 @@ function FinancialsPageContent() { void loadFinancials(ticker)}> + @@ -566,10 +593,20 @@ function FinancialsPageContent() { {analysis ? ( <> - + prefetchResearchTicker(analysis.company.ticker)} + onFocus={() => prefetchResearchTicker(analysis.company.ticker)} + className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" + > Open full analysis - + prefetchResearchTicker(analysis.company.ticker)} + onFocus={() => prefetchResearchTicker(analysis.company.ticker)} + className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" + > Open filings stream @@ -648,11 +685,36 @@ function FinancialsPageContent() {
- - - asAxisCurrencyTick(value, financialValueScale)} /> - asTooltipCurrency(value, financialValueScale)} /> - + + + asAxisCurrencyTick(value, financialValueScale)} + /> + asTooltipCurrency(value, financialValueScale)} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }} + /> + @@ -676,11 +738,36 @@ function FinancialsPageContent() { - - - asAxisCurrencyTick(value, financialValueScale)} /> - asTooltipCurrency(value, financialValueScale)} /> - + + + asAxisCurrencyTick(value, financialValueScale)} + /> + asTooltipCurrency(value, financialValueScale)} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }} + /> + @@ -701,9 +788,24 @@ function FinancialsPageContent() {
- - - `${value.toFixed(0)}%`} /> + + + `${value.toFixed(0)}%`} + /> { const normalized = normalizeTooltipValue(value); @@ -714,8 +816,16 @@ function FinancialsPageContent() { const numeric = Number(normalized); return Number.isFinite(numeric) ? `${numeric.toFixed(2)}%` : 'n/a'; }} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ stroke: 'rgba(104, 255, 213, 0.35)', strokeWidth: 1 }} /> - + diff --git a/app/layout.tsx b/app/layout.tsx index 6c1b5fc..6d1f9a1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import './globals.css'; import type { Metadata } from 'next'; +import { QueryProvider } from '@/components/providers/query-provider'; export const metadata: Metadata = { title: 'Fiscal Clone', @@ -9,7 +10,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/app/page.tsx b/app/page.tsx index 474c017..b082b85 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Activity, Bot, RefreshCw, Sparkles } from 'lucide-react'; @@ -10,19 +11,23 @@ import { MetricCard } from '@/components/dashboard/metric-card'; import { TaskFeed } from '@/components/dashboard/task-feed'; import { StatusPill } from '@/components/ui/status-pill'; import { useAuthGuard } from '@/hooks/use-auth-guard'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useTaskPoller } from '@/hooks/use-task-poller'; import { - getLatestPortfolioInsight, - getPortfolioSummary, - getTask, - listFilings, - listRecentTasks, - listWatchlist, queuePortfolioInsights, queuePriceRefresh } from '@/lib/api'; import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types'; import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format'; +import { queryKeys } from '@/lib/query/keys'; +import { + filingsQueryOptions, + latestPortfolioInsightQueryOptions, + portfolioSummaryQueryOptions, + recentTasksQueryOptions, + taskQueryOptions, + watchlistQueryOptions +} from '@/lib/query/options'; type DashboardState = { summary: PortfolioSummary; @@ -48,22 +53,33 @@ const EMPTY_STATE: DashboardState = { export default function CommandCenterPage() { const { isPending, isAuthenticated, session } = useAuthGuard(); + const queryClient = useQueryClient(); + const { prefetchPortfolioSurfaces } = useLinkPrefetch(); const [state, setState] = useState(EMPTY_STATE); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTaskId, setActiveTaskId] = useState(null); const loadData = useCallback(async () => { - setLoading(true); + const summaryOptions = portfolioSummaryQueryOptions(); + const filingsOptions = filingsQueryOptions({ limit: 200 }); + const watchlistOptions = watchlistQueryOptions(); + const tasksOptions = recentTasksQueryOptions(20); + const insightOptions = latestPortfolioInsightQueryOptions(); + + if (!queryClient.getQueryData(summaryOptions.queryKey)) { + setLoading(true); + } + setError(null); try { const [summaryRes, filingsRes, watchlistRes, tasksRes, insightRes] = await Promise.all([ - getPortfolioSummary(), - listFilings({ limit: 200 }), - listWatchlist(), - listRecentTasks(20), - getLatestPortfolioInsight() + queryClient.ensureQueryData(summaryOptions), + queryClient.ensureQueryData(filingsOptions), + queryClient.ensureQueryData(watchlistOptions), + queryClient.ensureQueryData(tasksOptions), + queryClient.ensureQueryData(insightOptions) ]); setState({ @@ -78,7 +94,7 @@ export default function CommandCenterPage() { } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -90,6 +106,10 @@ export default function CommandCenterPage() { taskId: activeTaskId, onTerminalState: () => { setActiveTaskId(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: ['filings'] }); void loadData(); } }); @@ -102,8 +122,10 @@ export default function CommandCenterPage() { try { const { task } = await queuePriceRefresh(); setActiveTaskId(task.id); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] })); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to queue price refresh'); } @@ -117,8 +139,10 @@ export default function CommandCenterPage() { try { const { task } = await queuePortfolioInsights(); setActiveTaskId(task.id); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] })); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to queue AI insight'); } @@ -211,15 +235,34 @@ export default function CommandCenterPage() {

Financials

Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.

- + { + void queryClient.prefetchQuery(filingsQueryOptions({ limit: 120 })); + }} + onFocus={() => { + void queryClient.prefetchQuery(filingsQueryOptions({ limit: 120 })); + }} + >

Filings

Sync SEC filings and trigger AI memo analysis.

- + prefetchPortfolioSurfaces()} + onFocus={() => prefetchPortfolioSurfaces()} + >

Portfolio

Manage holdings and mark to market in real time.

- + prefetchPortfolioSurfaces()} + onFocus={() => prefetchPortfolioSurfaces()} + >

Watchlist

Track priority tickers for monitoring and ingestion.

diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx index d83522c..1397a75 100644 --- a/app/portfolio/page.tsx +++ b/app/portfolio/page.tsx @@ -1,7 +1,8 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts'; +import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts'; import { BrainCircuit, Plus, RefreshCcw, Trash2 } from 'lucide-react'; import { AppShell } from '@/components/shell/app-shell'; import { Panel } from '@/components/ui/panel'; @@ -12,16 +13,19 @@ import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useTaskPoller } from '@/hooks/use-task-poller'; import { deleteHolding, - getLatestPortfolioInsight, - getTask, - getPortfolioSummary, - listHoldings, queuePortfolioInsights, queuePriceRefresh, upsertHolding } from '@/lib/api'; import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types'; import { asNumber, formatCurrency, formatPercent } from '@/lib/format'; +import { queryKeys } from '@/lib/query/keys'; +import { + holdingsQueryOptions, + latestPortfolioInsightQueryOptions, + portfolioSummaryQueryOptions, + taskQueryOptions +} from '@/lib/query/options'; type FormState = { ticker: string; @@ -31,6 +35,11 @@ type FormState = { }; const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c']; +const CHART_TEXT = '#e8fff8'; +const CHART_MUTED = '#b4ced9'; +const CHART_GRID = 'rgba(126, 217, 255, 0.24)'; +const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)'; +const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)'; const EMPTY_SUMMARY: PortfolioSummary = { positions: 0, @@ -42,6 +51,7 @@ const EMPTY_SUMMARY: PortfolioSummary = { export default function PortfolioPage() { const { isPending, isAuthenticated } = useAuthGuard(); + const queryClient = useQueryClient(); const [holdings, setHoldings] = useState([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); @@ -52,14 +62,21 @@ export default function PortfolioPage() { const [form, setForm] = useState({ ticker: '', shares: '', avgCost: '', currentPrice: '' }); const loadPortfolio = useCallback(async () => { - setLoading(true); + const holdingsOptions = holdingsQueryOptions(); + const summaryOptions = portfolioSummaryQueryOptions(); + const insightOptions = latestPortfolioInsightQueryOptions(); + + if (!queryClient.getQueryData(summaryOptions.queryKey)) { + setLoading(true); + } + setError(null); try { const [holdingsRes, summaryRes, insightRes] = await Promise.all([ - listHoldings(), - getPortfolioSummary(), - getLatestPortfolioInsight() + queryClient.ensureQueryData(holdingsOptions), + queryClient.ensureQueryData(summaryOptions), + queryClient.ensureQueryData(insightOptions) ]); setHoldings(holdingsRes.holdings); @@ -70,7 +87,7 @@ export default function PortfolioPage() { } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -82,6 +99,10 @@ export default function PortfolioPage() { taskId: activeTask?.id ?? null, onTerminalState: async () => { setActiveTask(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); await loadPortfolio(); } }); @@ -116,6 +137,8 @@ export default function PortfolioPage() { }); setForm({ ticker: '', shares: '', avgCost: '', currentPrice: '' }); + void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); await loadPortfolio(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save holding'); @@ -125,8 +148,10 @@ export default function PortfolioPage() { const queueRefresh = async () => { try { const { task } = await queuePriceRefresh(); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setActiveTask(latest.task); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to queue price refresh'); } @@ -135,8 +160,10 @@ export default function PortfolioPage() { const queueInsights = async () => { try { const { task } = await queuePortfolioInsights(); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setActiveTask(latest.task); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights'); } @@ -148,7 +175,7 @@ export default function PortfolioPage() { return ( @@ -209,7 +236,22 @@ export default function PortfolioPage() { ))} - formatCurrency(value)} /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + /> + {value}} + />
@@ -225,10 +267,33 @@ export default function PortfolioPage() {
- - - - `${asNumber(value).toFixed(2)}%`} /> + + + + `${asNumber(value).toFixed(2)}%`} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }} + /> @@ -277,6 +342,8 @@ export default function PortfolioPage() { onClick={async () => { try { await deleteHolding(holding.id); + void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); await loadPortfolio(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete holding'); diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx index 29313bd..215ceb4 100644 --- a/app/watchlist/page.tsx +++ b/app/watchlist/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; import { ArrowRight, Eye, Plus, Trash2 } from 'lucide-react'; import Link from 'next/link'; @@ -8,10 +9,13 @@ import { Panel } from '@/components/ui/panel'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { StatusPill } from '@/components/ui/status-pill'; +import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useTaskPoller } from '@/hooks/use-task-poller'; -import { deleteWatchlistItem, getTask, listWatchlist, queueFilingSync, upsertWatchlistItem } from '@/lib/api'; +import { deleteWatchlistItem, queueFilingSync, upsertWatchlistItem } from '@/lib/api'; import type { Task, WatchlistItem } from '@/lib/types'; +import { queryKeys } from '@/lib/query/keys'; +import { taskQueryOptions, watchlistQueryOptions } from '@/lib/query/options'; type FormState = { ticker: string; @@ -21,6 +25,8 @@ type FormState = { export default function WatchlistPage() { const { isPending, isAuthenticated } = useAuthGuard(); + const queryClient = useQueryClient(); + const { prefetchResearchTicker } = useLinkPrefetch(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -29,18 +35,23 @@ export default function WatchlistPage() { const [form, setForm] = useState({ ticker: '', companyName: '', sector: '' }); const loadWatchlist = useCallback(async () => { - setLoading(true); + const options = watchlistQueryOptions(); + + if (!queryClient.getQueryData(options.queryKey)) { + setLoading(true); + } + setError(null); try { - const response = await listWatchlist(); + const response = await queryClient.ensureQueryData(options); setItems(response.items); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load watchlist'); } finally { setLoading(false); } - }, []); + }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { @@ -52,6 +63,8 @@ export default function WatchlistPage() { taskId: activeTask?.id ?? null, onTerminalState: () => { setActiveTask(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: ['filings'] }); } }); @@ -68,6 +81,7 @@ export default function WatchlistPage() { }); setForm({ ticker: '', companyName: '', sector: '' }); + void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); await loadWatchlist(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save watchlist item'); @@ -77,8 +91,9 @@ export default function WatchlistPage() { const queueSync = async (ticker: string) => { try { const { task } = await queueFilingSync({ ticker, limit: 20 }); - const latest = await getTask(task.id); + const latest = await queryClient.fetchQuery(taskQueryOptions(task.id)); setActiveTask(latest.task); + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); } catch (err) { setError(err instanceof Error ? err.message : `Failed to queue sync for ${ticker}`); } @@ -128,6 +143,8 @@ export default function WatchlistPage() { prefetchResearchTicker(item.ticker)} + onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > Open stream @@ -135,6 +152,8 @@ export default function WatchlistPage() { prefetchResearchTicker(item.ticker)} + onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > Analyze @@ -146,6 +165,7 @@ export default function WatchlistPage() { onClick={async () => { try { await deleteWatchlistItem(item.id); + void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); await loadWatchlist(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove symbol'); diff --git a/bun.lock b/bun.lock index a823711..ea61037 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@elysiajs/eden": "^1.4.8", "@libsql/client": "^0.17.0", "@tailwindcss/postcss": "^4.2.1", + "@tanstack/react-query": "^5.90.21", "ai": "^6.0.104", "better-auth": "^1.4.19", "clsx": "^2.1.1", @@ -515,6 +516,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], diff --git a/components/providers/query-provider.tsx b/components/providers/query-provider.tsx new file mode 100644 index 0000000..4a13d19 --- /dev/null +++ b/components/providers/query-provider.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; + +type QueryProviderProps = { + children: React.ReactNode; +}; + +export function QueryProvider({ children }: QueryProviderProps) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false + } + } + })); + + return ( + + {children} + + ); +} diff --git a/components/shell/app-shell.tsx b/components/shell/app-shell.tsx index ff8ecb6..5ffc902 100644 --- a/components/shell/app-shell.tsx +++ b/components/shell/app-shell.tsx @@ -1,10 +1,22 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; +import type { LucideIcon } from 'lucide-react'; +import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut } from 'lucide-react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import { authClient } from '@/lib/auth-client'; +import { + companyAnalysisQueryOptions, + filingsQueryOptions, + holdingsQueryOptions, + latestPortfolioInsightQueryOptions, + portfolioSummaryQueryOptions, + recentTasksQueryOptions, + watchlistQueryOptions +} from '@/lib/query/options'; +import type { ActiveContext, NavGroup, NavItem } from '@/lib/types'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -12,22 +24,168 @@ type AppShellProps = { title: string; subtitle?: string; actions?: React.ReactNode; + activeTicker?: string | null; + breadcrumbs?: Array<{ label: string; href?: string }>; children: React.ReactNode; }; -const NAV_ITEMS = [ - { href: '/', label: 'Command Center', icon: Activity }, - { href: '/analysis', label: 'Company Analysis', icon: LineChart }, - { href: '/financials', label: 'Financials', icon: Landmark }, - { href: '/filings', label: 'Filings Stream', icon: BookOpenText }, - { href: '/portfolio', label: 'Portfolio Matrix', icon: ChartCandlestick }, - { href: '/watchlist', label: 'Watchlist', icon: Eye } +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: 'Analysis', + icon: LineChart, + group: 'research', + matchMode: 'prefix', + preserveTicker: true, + mobilePrimary: true + }, + { + 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: 'portfolio', + href: '/portfolio', + label: 'Portfolio', + icon: ChartCandlestick, + group: 'portfolio', + matchMode: 'exact', + mobilePrimary: true + }, + { + id: 'watchlist', + href: '/watchlist', + label: 'Watchlist', + icon: Eye, + group: 'portfolio', + matchMode: 'exact', + mobilePrimary: true + } ]; -export function AppShell({ title, subtitle, actions, children }: AppShellProps) { +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.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 financialsHref = toTickerHref('/financials', activeTicker); + const filingsHref = toTickerHref('/filings', activeTicker); + + if (pathname === '/') { + return [{ label: 'Home' }]; + } + + if (pathname.startsWith('/analysis/reports/')) { + return [ + { label: 'Analysis', href: analysisHref }, + { label: 'Reports', href: analysisHref }, + { label: activeTicker ?? 'Summary' } + ]; + } + + if (pathname.startsWith('/analysis')) { + return [{ label: 'Analysis' }]; + } + + if (pathname.startsWith('/financials')) { + return [ + { label: 'Analysis', href: analysisHref }, + { label: 'Financials' } + ]; + } + + if (pathname.startsWith('/filings')) { + return [ + { label: 'Analysis', href: analysisHref }, + { label: 'Filings' } + ]; + } + + if (pathname.startsWith('/portfolio')) { + return [{ label: 'Portfolio' }]; + } + + if (pathname.startsWith('/watchlist')) { + return [ + { label: 'Portfolio', href: '/portfolio' }, + { label: 'Watchlist' } + ]; + } + + 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 [isSigningOut, setIsSigningOut] = useState(false); + const [isMoreOpen, setIsMoreOpen] = useState(false); const { data: session } = authClient.useSession(); const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null; @@ -36,8 +194,150 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps) : 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('/filings')) { + void queryClient.prefetchQuery(filingsQueryOptions({ + ticker: context.activeTicker ?? undefined, + limit: 120 + })); + 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 browserWindow = window as Window & { + requestIdleCallback?: (callback: IdleRequestCallback) => number; + cancelIdleCallback?: (handle: number) => void; + }; + + const runPrefetch = () => { + const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || 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]); + const signOut = async () => { if (isSigningOut) { return; @@ -67,27 +367,34 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)

-
); } diff --git a/hooks/use-api-queries.ts b/hooks/use-api-queries.ts new file mode 100644 index 0000000..3ad9687 --- /dev/null +++ b/hooks/use-api-queries.ts @@ -0,0 +1,77 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { + aiReportQueryOptions, + companyAnalysisQueryOptions, + filingsQueryOptions, + holdingsQueryOptions, + latestPortfolioInsightQueryOptions, + portfolioSummaryQueryOptions, + recentTasksQueryOptions, + taskQueryOptions, + watchlistQueryOptions +} from '@/lib/query/options'; + +export function useCompanyAnalysisQuery(ticker: string, enabled = true) { + return useQuery({ + ...companyAnalysisQueryOptions(ticker), + enabled: enabled && ticker.trim().length > 0 + }); +} + +export function useFilingsQuery(input: { ticker?: string; limit?: number }, enabled = true) { + return useQuery({ + ...filingsQueryOptions(input), + enabled + }); +} + +export function useAiReportQuery(accessionNumber: string, enabled = true) { + return useQuery({ + ...aiReportQueryOptions(accessionNumber), + enabled: enabled && accessionNumber.trim().length > 0 + }); +} + +export function useWatchlistQuery(enabled = true) { + return useQuery({ + ...watchlistQueryOptions(), + enabled + }); +} + +export function useHoldingsQuery(enabled = true) { + return useQuery({ + ...holdingsQueryOptions(), + enabled + }); +} + +export function usePortfolioSummaryQuery(enabled = true) { + return useQuery({ + ...portfolioSummaryQueryOptions(), + enabled + }); +} + +export function useLatestPortfolioInsightQuery(enabled = true) { + return useQuery({ + ...latestPortfolioInsightQueryOptions(), + enabled + }); +} + +export function useTaskQuery(taskId: string, enabled = true) { + return useQuery({ + ...taskQueryOptions(taskId), + enabled: enabled && taskId.length > 0 + }); +} + +export function useRecentTasksQuery(limit = 20, enabled = true) { + return useQuery({ + ...recentTasksQueryOptions(limit), + enabled + }); +} diff --git a/hooks/use-link-prefetch.ts b/hooks/use-link-prefetch.ts new file mode 100644 index 0000000..6980b20 --- /dev/null +++ b/hooks/use-link-prefetch.ts @@ -0,0 +1,74 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; +import { + aiReportQueryOptions, + companyAnalysisQueryOptions, + filingsQueryOptions, + holdingsQueryOptions, + latestPortfolioInsightQueryOptions, + portfolioSummaryQueryOptions, + recentTasksQueryOptions, + watchlistQueryOptions +} from '@/lib/query/options'; + +function normalizeTicker(ticker: string) { + return ticker.trim().toUpperCase(); +} + +export function useLinkPrefetch() { + const queryClient = useQueryClient(); + const router = useRouter(); + + const prefetchResearchTicker = useCallback((ticker: string) => { + const normalizedTicker = normalizeTicker(ticker); + if (!normalizedTicker) { + return; + } + + const analysisHref = `/analysis?ticker=${encodeURIComponent(normalizedTicker)}`; + const filingsHref = `/filings?ticker=${encodeURIComponent(normalizedTicker)}`; + const financialsHref = `/financials?ticker=${encodeURIComponent(normalizedTicker)}`; + + router.prefetch(analysisHref); + router.prefetch(filingsHref); + router.prefetch(financialsHref); + + void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker)); + void queryClient.prefetchQuery(filingsQueryOptions({ ticker: normalizedTicker, limit: 120 })); + }, [queryClient, router]); + + const prefetchReport = useCallback((ticker: string, accessionNumber: string) => { + const normalizedTicker = normalizeTicker(ticker); + const normalizedAccession = accessionNumber.trim(); + + if (!normalizedTicker || !normalizedAccession) { + return; + } + + const reportHref = `/analysis/reports/${encodeURIComponent(normalizedTicker)}/${encodeURIComponent(normalizedAccession)}`; + router.prefetch(reportHref); + + void queryClient.prefetchQuery(aiReportQueryOptions(normalizedAccession)); + void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker)); + }, [queryClient, router]); + + const prefetchPortfolioSurfaces = useCallback(() => { + router.prefetch('/portfolio'); + router.prefetch('/watchlist'); + + void queryClient.prefetchQuery(holdingsQueryOptions()); + void queryClient.prefetchQuery(portfolioSummaryQueryOptions()); + void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions()); + void queryClient.prefetchQuery(watchlistQueryOptions()); + void queryClient.prefetchQuery(recentTasksQueryOptions(20)); + }, [queryClient, router]); + + return { + prefetchResearchTicker, + prefetchReport, + prefetchPortfolioSurfaces + }; +} diff --git a/lib/query/keys.ts b/lib/query/keys.ts new file mode 100644 index 0000000..b2e642e --- /dev/null +++ b/lib/query/keys.ts @@ -0,0 +1,11 @@ +export const queryKeys = { + companyAnalysis: (ticker: string) => ['analysis', ticker] as const, + filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const, + report: (accessionNumber: string) => ['report', accessionNumber] as const, + watchlist: () => ['watchlist'] as const, + holdings: () => ['portfolio', 'holdings'] as const, + portfolioSummary: () => ['portfolio', 'summary'] as const, + latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const, + task: (taskId: string) => ['tasks', 'detail', taskId] as const, + recentTasks: (limit: number) => ['tasks', 'recent', limit] as const +}; diff --git a/lib/query/options.ts b/lib/query/options.ts new file mode 100644 index 0000000..3cf3e61 --- /dev/null +++ b/lib/query/options.ts @@ -0,0 +1,92 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getCompanyAiReport, + getCompanyAnalysis, + getLatestPortfolioInsight, + getPortfolioSummary, + getTask, + listFilings, + listHoldings, + listRecentTasks, + listWatchlist +} from '@/lib/api'; +import { queryKeys } from '@/lib/query/keys'; + +export function companyAnalysisQueryOptions(ticker: string) { + const normalizedTicker = ticker.trim().toUpperCase(); + + return queryOptions({ + queryKey: queryKeys.companyAnalysis(normalizedTicker), + queryFn: () => getCompanyAnalysis(normalizedTicker), + staleTime: 120_000 + }); +} + +export function filingsQueryOptions(input: { ticker?: string; limit?: number } = {}) { + const normalizedTicker = input.ticker?.trim().toUpperCase() ?? null; + const limit = input.limit ?? 120; + + return queryOptions({ + queryKey: queryKeys.filings(normalizedTicker, limit), + queryFn: () => listFilings({ ticker: normalizedTicker ?? undefined, limit }), + staleTime: 60_000 + }); +} + +export function aiReportQueryOptions(accessionNumber: string) { + const normalizedAccession = accessionNumber.trim(); + + return queryOptions({ + queryKey: queryKeys.report(normalizedAccession), + queryFn: () => getCompanyAiReport(normalizedAccession), + staleTime: 300_000 + }); +} + +export function watchlistQueryOptions() { + return queryOptions({ + queryKey: queryKeys.watchlist(), + queryFn: () => listWatchlist(), + staleTime: 30_000 + }); +} + +export function holdingsQueryOptions() { + return queryOptions({ + queryKey: queryKeys.holdings(), + queryFn: () => listHoldings(), + staleTime: 30_000 + }); +} + +export function portfolioSummaryQueryOptions() { + return queryOptions({ + queryKey: queryKeys.portfolioSummary(), + queryFn: () => getPortfolioSummary(), + staleTime: 30_000 + }); +} + +export function latestPortfolioInsightQueryOptions() { + return queryOptions({ + queryKey: queryKeys.latestPortfolioInsight(), + queryFn: () => getLatestPortfolioInsight(), + staleTime: 30_000 + }); +} + +export function taskQueryOptions(taskId: string) { + return queryOptions({ + queryKey: queryKeys.task(taskId), + queryFn: () => getTask(taskId), + staleTime: 5_000 + }); +} + +export function recentTasksQueryOptions(limit = 20) { + return queryOptions({ + queryKey: queryKeys.recentTasks(limit), + queryFn: () => listRecentTasks(limit), + staleTime: 5_000 + }); +} diff --git a/lib/types.ts b/lib/types.ts index f32192b..d8ebb91 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -158,3 +158,21 @@ export type CompanyAnalysis = { filings: Filing[]; aiReports: CompanyAiReport[]; }; + +export type NavGroup = 'overview' | 'research' | 'portfolio'; +export type NavMatchMode = 'exact' | 'prefix'; + +export type NavItem = { + id: string; + href: string; + label: string; + group: NavGroup; + matchMode: NavMatchMode; + preserveTicker?: boolean; + mobilePrimary?: boolean; +}; + +export type ActiveContext = { + pathname: string; + activeTicker: string | null; +}; diff --git a/package.json b/package.json index b70e917..f6a5eef 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "db:migrate": "bun x drizzle-kit migrate" }, "dependencies": { - "@elysiajs/eden": "^1.4.8", "@ai-sdk/openai": "^2.0.62", + "@elysiajs/eden": "^1.4.8", "@libsql/client": "^0.17.0", "@tailwindcss/postcss": "^4.2.1", + "@tanstack/react-query": "^5.90.21", "ai": "^6.0.104", "better-auth": "^1.4.19", "clsx": "^2.1.1",