diff --git a/app/filings/page.tsx b/app/filings/page.tsx index 782e155..09118ef 100644 --- a/app/filings/page.tsx +++ b/app/filings/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; import { format } from 'date-fns'; import { Bot, Download, ExternalLink, NotebookPen, Search, TimerReset } from 'lucide-react'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { AppShell } from '@/components/shell/app-shell'; import { Panel } from '@/components/ui/panel'; import { Button } from '@/components/ui/button'; @@ -28,6 +28,7 @@ const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: stri { value: 'millions', label: 'Millions (M)' }, { value: 'billions', label: 'Billions (B)' } ]; +const FILINGS_QUERY_LIMIT = 120; export default function FilingsPage() { return ( @@ -66,6 +67,15 @@ function parseTagsInput(input: string) { return [...unique]; } +function normalizeTickerParam(value: string | null) { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toUpperCase(); + return normalized.length > 0 ? normalized : null; +} + function asScaledFinancialSnapshot( value: number | null | undefined, scale: NumberScaleUnit @@ -123,54 +133,39 @@ function FilingExternalLink({ href, label }: FilingExternalLinkProps) { function FilingsPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); + const router = useRouter(); const queryClient = useQueryClient(); const { prefetchReport } = useLinkPrefetch(); + const activeTickerFilter = useMemo(() => normalizeTickerParam(searchParams.get('ticker')), [searchParams]); + const activeFilingsQueryKey = useMemo( + () => queryKeys.filings(activeTickerFilter, FILINGS_QUERY_LIMIT), + [activeTickerFilter] + ); + const filingsQuery = useQuery({ + ...filingsQueryOptions({ ticker: activeTickerFilter ?? undefined, limit: FILINGS_QUERY_LIMIT }), + enabled: !isPending && isAuthenticated + }); - const [filings, setFilings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [syncTickerInput, setSyncTickerInput] = useState(''); + const [syncTickerInput, setSyncTickerInput] = useState(() => activeTickerFilter ?? ''); const [syncCategoryInput, setSyncCategoryInput] = useState(''); const [syncTagsInput, setSyncTagsInput] = useState(''); - const [filterTickerInput, setFilterTickerInput] = useState(''); - const [searchTicker, setSearchTicker] = useState(''); + const [filterTickerInput, setFilterTickerInput] = useState(() => activeTickerFilter ?? ''); const [financialValueScale, setFinancialValueScale] = useState('millions'); const [actionNotice, setActionNotice] = useState(null); + const [actionError, setActionError] = useState(null); useEffect(() => { - const ticker = searchParams.get('ticker'); - if (ticker) { - const normalized = ticker.toUpperCase(); - setSyncTickerInput(normalized); - setFilterTickerInput(normalized); - setSearchTicker(normalized); - } - }, [searchParams]); + setSyncTickerInput(activeTickerFilter ?? ''); + setFilterTickerInput(activeTickerFilter ?? ''); + }, [activeTickerFilter]); - const loadFilings = useCallback(async (ticker?: string) => { - const options = filingsQueryOptions({ ticker, limit: 120 }); - - if (!queryClient.getQueryData(options.queryKey)) { - setLoading(true); - } - - setError(null); - - try { - const response = await queryClient.fetchQuery(options); - setFilings(response.filings); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to fetch filings'); - } finally { - setLoading(false); - } - }, [queryClient]); - - useEffect(() => { - if (!isPending && isAuthenticated) { - void loadFilings(searchTicker || undefined); - } - }, [isPending, isAuthenticated, searchTicker, loadFilings]); + const filings = filingsQuery.data?.filings ?? []; + const loading = filingsQuery.isPending; + const filingsError = filingsQuery.error instanceof Error + ? filingsQuery.error.message + : filingsQuery.error + ? 'Unable to fetch filings' + : null; const triggerSync = async () => { if (!syncTickerInput.trim()) { @@ -178,6 +173,7 @@ function FilingsPageContent() { } try { + setActionError(null); await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20, @@ -185,25 +181,26 @@ function FilingsPageContent() { tags: parseTagsInput(syncTagsInput) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); - void queryClient.invalidateQueries({ queryKey: ['filings'] }); - await loadFilings(searchTicker || undefined); + void queryClient.invalidateQueries({ queryKey: activeFilingsQueryKey }); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to queue filing sync'); + setActionError(err instanceof Error ? err.message : 'Failed to queue filing sync'); } }; const triggerAnalysis = async (accessionNumber: string) => { try { + setActionError(null); await queueFilingAnalysis(accessionNumber); 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'); + setActionError(err instanceof Error ? err.message : 'Failed to queue filing analysis'); } }; const saveToLibrary = async (filing: Filing) => { try { + setActionError(null); await createResearchArtifact({ ticker: filing.ticker, kind: 'filing', @@ -234,10 +231,23 @@ function FilingsPageContent() { void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} research library.`); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save filing to library'); + setActionError(err instanceof Error ? err.message : 'Failed to save filing to library'); } }; + const replaceTickerFilter = (ticker: string | null) => { + const nextParams = new URLSearchParams(searchParams.toString()); + + if (ticker) { + nextParams.set('ticker', ticker); + } else { + nextParams.delete('ticker'); + } + + const nextQuery = nextParams.toString(); + router.replace(nextQuery ? `/filings?${nextQuery}` : '/filings', { scroll: false }); + }; + const groupedByTicker = useMemo(() => { const counts = new Map(); @@ -260,11 +270,11 @@ function FilingsPageContent() { @@ -274,8 +284,7 @@ function FilingsPageContent() { variant="secondary" className="w-full sm:w-auto" onClick={() => { - void queryClient.invalidateQueries({ queryKey: queryKeys.filings(searchTicker || null, 120) }); - void loadFilings(searchTicker || undefined); + void queryClient.invalidateQueries({ queryKey: activeFilingsQueryKey }); }} > @@ -323,7 +332,9 @@ function FilingsPageContent() { className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center" onSubmit={(event) => { event.preventDefault(); - setSearchTicker(filterTickerInput.trim().toUpperCase()); + const nextTicker = normalizeTickerParam(filterTickerInput); + setFilterTickerInput(nextTicker ?? ''); + replaceTickerFilter(nextTicker); }} > { setFilterTickerInput(''); - setSearchTicker(''); + replaceTickerFilter(null); }} > Clear @@ -353,7 +364,7 @@ function FilingsPageContent() { @@ -371,7 +382,8 @@ function FilingsPageContent() { )} > - {error ?

{error}

: null} + {actionError ?

{actionError}

: null} + {filingsError ?

{filingsError}

: null} {actionNotice ?

{actionNotice}

: null} {loading ? (

Fetching filings...

diff --git a/e2e/filings.spec.ts b/e2e/filings.spec.ts new file mode 100644 index 0000000..070c387 --- /dev/null +++ b/e2e/filings.spec.ts @@ -0,0 +1,202 @@ +import { expect, test, type Page } from '@playwright/test'; + +const PASSWORD = 'Sup3rSecure!123'; + +test.describe.configure({ mode: 'serial' }); + +type FilingFixture = { + id: number; + ticker: string; + filing_type: '10-K' | '10-Q' | '8-K'; + filing_date: string; + accession_number: string; + cik: string; + company_name: string; + filing_url: string | null; + submission_url: string | null; + primary_document: string | null; + metrics: { + revenue: number | null; + netIncome: number | null; + totalAssets: number | null; + cash: number | null; + debt: number | null; + } | null; + analysis: null; + created_at: string; + updated_at: string; +}; + +function uniqueEmail(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +function createFiling(input: { + id: number; + ticker: string; + accessionNumber: string; + filingType: '10-K' | '10-Q' | '8-K'; + filingDate: string; + companyName: string; + revenue?: number | null; +}): FilingFixture { + return { + id: input.id, + ticker: input.ticker, + filing_type: input.filingType, + filing_date: input.filingDate, + accession_number: input.accessionNumber, + cik: '0001045810', + company_name: input.companyName, + filing_url: `https://www.sec.gov/Archives/${input.accessionNumber}.htm`, + submission_url: `https://www.sec.gov/submissions/${input.accessionNumber}.json`, + primary_document: `${input.accessionNumber}.htm`, + metrics: input.revenue === undefined + ? null + : { + revenue: input.revenue, + netIncome: null, + totalAssets: null, + cash: null, + debt: null + }, + analysis: null, + created_at: '2026-03-14T12:00:00.000Z', + updated_at: '2026-03-14T12:00:00.000Z' + }; +} + +async function signUp(page: Page, email: string) { + await page.goto('/auth/signup'); + await page.locator('input[autocomplete="name"]').fill('Playwright Filings User'); + await page.locator('input[autocomplete="email"]').fill(email); + await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); + await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); +} + +async function installFilingsRouteStub( + page: Page, + options?: { + unscopedDelayMs?: number; + scopedDelayMs?: number; + } +) { + const nvdaFilings = [ + createFiling({ + id: 1, + ticker: 'NVDA', + accessionNumber: '0001045810-26-000001', + filingType: '10-K', + filingDate: '2026-03-13', + companyName: 'NVIDIA Corporation', + revenue: 130_500_000_000 + }) + ]; + const msftFilings = [ + createFiling({ + id: 2, + ticker: 'MSFT', + accessionNumber: '0000789019-26-000002', + filingType: '10-Q', + filingDate: '2026-03-12', + companyName: 'Microsoft Corporation', + revenue: 71_000_000_000 + }) + ]; + const mixedFilings = [...nvdaFilings, ...msftFilings]; + + await page.route(/\/api\/filings(\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const ticker = url.searchParams.get('ticker')?.trim().toUpperCase() ?? null; + const delay = ticker === 'NVDA' + ? options?.scopedDelayMs ?? 0 + : options?.unscopedDelayMs ?? 0; + + if (delay > 0) { + await page.waitForTimeout(delay); + } + + const filings = ticker === 'NVDA' ? nvdaFilings : mixedFilings; + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ filings }) + }); + }); +} + +async function filingsLedger(page: Page) { + return page.locator('section').filter({ + has: page.getByRole('heading', { name: 'Filing Ledger' }) + }).first(); +} + +test('direct URL entry keeps the filings ledger scoped to the URL ticker', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-filings-direct')); + await installFilingsRouteStub(page); + + await page.goto('/filings?ticker=NVDA', { waitUntil: 'domcontentloaded' }); + + const ledger = await filingsLedger(page); + + await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/); + await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0); +}); + +test('apply and clear keep the URL and visible filings rows aligned', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-filings-apply-clear')); + await installFilingsRouteStub(page); + + await page.goto('/filings', { waitUntil: 'domcontentloaded' }); + + const ledger = await filingsLedger(page); + + await expect(page).toHaveURL(/\/filings$/); + await expect(ledger.getByText('2 records loaded. Values shown in Millions (M).')).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toBeVisible(); + + await page.getByPlaceholder('Ticker filter').fill('nvda'); + await page.getByRole('button', { name: 'Apply' }).click(); + + await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/); + await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Clear' }).click(); + + await expect(page).toHaveURL(/\/filings$/); + await expect(ledger.getByText('2 records loaded. Values shown in Millions (M).')).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toBeVisible(); +}); + +test('a stale global filings response cannot overwrite a newer scoped ledger', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-filings-stale')); + await installFilingsRouteStub(page, { + unscopedDelayMs: 900, + scopedDelayMs: 50 + }); + + await page.goto('/filings', { waitUntil: 'domcontentloaded' }); + + const ledger = await filingsLedger(page); + + await page.getByPlaceholder('Ticker filter').fill('NVDA'); + await page.getByRole('button', { name: 'Apply' }).click(); + + await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/); + await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible(); + + await page.waitForTimeout(1_100); + + await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); + await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0); +}); diff --git a/lib/server/api/task-workflow-hybrid.e2e.test.ts b/lib/server/api/task-workflow-hybrid.e2e.test.ts index 72a2b4f..f8eeab2 100644 --- a/lib/server/api/task-workflow-hybrid.e2e.test.ts +++ b/lib/server/api/task-workflow-hybrid.e2e.test.ts @@ -467,6 +467,47 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { expect(task.payload.tags).toEqual(['semis', 'ai']); }); + it('scopes the filings endpoint by ticker while leaving the global endpoint mixed', async () => { + if (!sqliteClient) { + throw new Error('sqlite client not initialized'); + } + + seedFilingRecord(sqliteClient, { + ticker: 'NVDA', + accessionNumber: '0000000000-26-000110', + filingType: '10-Q', + filingDate: '2026-03-12', + companyName: 'NVIDIA Corporation' + }); + seedFilingRecord(sqliteClient, { + ticker: 'MSFT', + accessionNumber: '0000000000-26-000111', + filingType: '10-K', + filingDate: '2026-03-11', + companyName: 'Microsoft Corporation' + }); + + const scoped = await jsonRequest('GET', '/api/filings?ticker=NVDA&limit=120'); + expect(scoped.response.status).toBe(200); + + const scopedFilings = (scoped.json as { + filings: Array<{ ticker: string }>; + }).filings; + + expect(scopedFilings.length).toBeGreaterThan(0); + expect(scopedFilings.every((filing) => filing.ticker === 'NVDA')).toBe(true); + + const global = await jsonRequest('GET', '/api/filings?limit=120'); + expect(global.response.status).toBe(200); + + const globalTickers = new Set((global.json as { + filings: Array<{ ticker: string }>; + }).filings.map((filing) => filing.ticker)); + + expect(globalTickers.has('NVDA')).toBe(true); + expect(globalTickers.has('MSFT')).toBe(true); + }); + it('updates coverage status and archives while appending status-change journal history', async () => { const created = await jsonRequest('POST', '/api/watchlist', { ticker: 'amd',