diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx index 38be142..50aa524 100644 --- a/app/watchlist/page.tsx +++ b/app/watchlist/page.tsx @@ -35,6 +35,14 @@ type FormState = { tags: string; }; +type PostCreateNotice = { + ticker: string; + category: string | null; + tags: string[]; + syncState: 'idle' | 'pending' | 'queued'; + error: string | null; +}; + const STATUS_OPTIONS: Array<{ value: CoverageStatus; label: string }> = [ { value: 'backlog', label: 'Backlog' }, { value: 'active', label: 'Active' }, @@ -117,6 +125,7 @@ export default function WatchlistPage() { const [error, setError] = useState(null); const [editingItemId, setEditingItemId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); + const [postCreateNotice, setPostCreateNotice] = useState(null); const loadCoverage = useCallback(async () => { const options = watchlistQueryOptions(); @@ -170,6 +179,7 @@ export default function WatchlistPage() { }, []); const beginEdit = useCallback((item: WatchlistItem) => { + setPostCreateNotice(null); setEditingItemId(item.id); setForm({ ticker: item.ticker, @@ -191,12 +201,34 @@ export default function WatchlistPage() { } }, [queryClient]); + const queueCoverageSync = useCallback(async (input: { + ticker: string; + category?: string | null; + tags?: string[]; + }) => { + const ticker = input.ticker.trim().toUpperCase(); + + await queueFilingSync({ + ticker, + limit: 20, + category: input.category ?? undefined, + tags: input.tags && input.tags.length > 0 ? input.tags : undefined + }); + + void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); + void queryClient.invalidateQueries({ queryKey: ['filings', ticker] }); + void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker) }); + }, [queryClient]); + const saveCoverage = async (event: React.FormEvent) => { event.preventDefault(); setSaving(true); setError(null); try { + const isCreate = editingItemId === null; + const ticker = form.ticker.trim().toUpperCase(); + const wasExistingItem = items.some((item) => item.ticker === ticker); const payload = { companyName: form.companyName.trim(), sector: form.sector.trim() || undefined, @@ -206,16 +238,29 @@ export default function WatchlistPage() { tags: parseTagsInput(form.tags) }; - if (editingItemId === null) { - await upsertWatchlistItem({ - ticker: form.ticker.trim().toUpperCase(), + if (isCreate) { + const response = await upsertWatchlistItem({ + ticker, ...payload }); + + if (!wasExistingItem) { + setPostCreateNotice({ + ticker: response.item.ticker, + category: response.item.category, + tags: response.item.tags, + syncState: 'idle', + error: null + }); + } else { + setPostCreateNotice(null); + } } else { await updateWatchlistItem(editingItemId, payload); + setPostCreateNotice(null); } - invalidateCoverageQueries(form.ticker); + invalidateCoverageQueries(ticker); await loadCoverage(); resetForm(); } catch (err) { @@ -248,19 +293,51 @@ export default function WatchlistPage() { const queueSync = async (item: WatchlistItem) => { try { - await queueFilingSync({ + setError(null); + await queueCoverageSync({ ticker: item.ticker, - limit: 20, - category: item.category ?? undefined, - tags: item.tags.length > 0 ? item.tags : undefined + category: item.category, + tags: item.tags }); - void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.filings(item.ticker, 120) }); } catch (err) { setError(err instanceof Error ? err.message : `Failed to queue filing sync for ${item.ticker}`); } }; + const queuePostCreateSync = async () => { + if (!postCreateNotice || postCreateNotice.syncState === 'queued') { + return; + } + + setPostCreateNotice((current) => current + ? { + ...current, + syncState: 'pending', + error: null + } + : null); + + try { + await queueCoverageSync(postCreateNotice); + setPostCreateNotice((current) => current + ? { + ...current, + syncState: 'queued', + error: null + } + : null); + } catch (err) { + const message = err instanceof Error ? err.message : `Failed to queue filing sync for ${postCreateNotice.ticker}`; + setPostCreateNotice((current) => current + ? { + ...current, + syncState: 'idle', + error: message + } + : null); + } + }; + if (isPending || !isAuthenticated) { return
Loading coverage terminal...
; } @@ -407,7 +484,7 @@ export default function WatchlistPage() {
+ +
+ + ) : null}
{ + const response = await fetch('/api/tasks?limit=20', { + credentials: 'include', + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Unable to load tasks: ${response.status}`); + } + + const payload = await response.json() as { + tasks?: Array<{ + task_type?: string; + payload?: { + ticker?: string; + }; + }>; + }; + + return (payload.tasks ?? []).filter((task) => ( + task.task_type === 'sync_filings' + && task.payload?.ticker === requestedTicker + )).length; + }, ticker); +} + +test('coverage save stays metadata-only until sync filings is clicked', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-watchlist-sync')); + await expectStableDashboard(page); + + await page.goto('/watchlist', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Coverage', exact: true })).toBeVisible({ timeout: 30_000 }); + + await page.getByLabel('Coverage ticker').fill('NVDA'); + await page.getByLabel('Coverage company name').fill('NVIDIA Corporation'); + await page.getByLabel('Coverage sector').fill('Technology'); + await page.getByLabel('Coverage category').fill('core'); + await page.getByLabel('Coverage tags').fill('semis, ai'); + await page.getByRole('button', { name: 'Save coverage' }).click(); + + const coverageRow = page.locator('tr').filter({ hasText: 'NVDA' }); + await expect(coverageRow).toContainText('NVIDIA Corporation'); + + const notice = page.getByTestId('watchlist-post-create-notice'); + await expect(notice).toContainText('NVDA added to coverage. Filing sync has not started yet.'); + await expect(notice.getByRole('button', { name: 'Sync filings' })).toBeVisible(); + await expect(coverageRow.getByRole('button', { name: 'Sync filings' })).toBeVisible(); + + await page.waitForTimeout(1_000); + expect(await countSyncTasks(page, 'NVDA')).toBe(0); + + await notice.getByRole('button', { name: 'Sync filings' }).click(); + + await expect(notice).toContainText('NVDA added to coverage. Filing sync is queued.'); + await expect.poll(async () => await countSyncTasks(page, 'NVDA')).toBe(1); +}); diff --git a/lib/api.ts b/lib/api.ts index 440b10f..008e669 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -168,7 +168,7 @@ export async function upsertWatchlistItem(input: { lastReviewedAt?: string; }) { const result = await client.api.watchlist.post(input); - return await unwrapData<{ item: WatchlistItem }>(result, 'Unable to save watchlist item'); + return await unwrapData<{ item: WatchlistItem; autoFilingSyncQueued: boolean }>(result, 'Unable to save watchlist item'); } export async function updateWatchlistItem(id: number, input: { diff --git a/lib/server/api/app.ts b/lib/server/api/app.ts index c50fe65..77cbe9e 100644 --- a/lib/server/api/app.ts +++ b/lib/server/api/app.ts @@ -500,7 +500,7 @@ export const app = new Elysia({ prefix: '/api' }) } try { - const { item, created } = await upsertWatchlistItemRecord({ + const { item } = await upsertWatchlistItemRecord({ userId: session.user.id, ticker, companyName, @@ -512,14 +512,10 @@ export const app = new Elysia({ prefix: '/api' }) lastReviewedAt }); - const autoFilingSyncQueued = created - ? await queueAutoFilingSync(session.user.id, ticker, { - category: item.category, - tags: item.tags - }) - : false; - - return Response.json({ item, autoFilingSyncQueued }); + return Response.json({ + item, + autoFilingSyncQueued: false + }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to create watchlist item')); } diff --git a/lib/server/api/task-workflow-hybrid.e2e.test.ts b/lib/server/api/task-workflow-hybrid.e2e.test.ts index 61c0f04..d5e080e 100644 --- a/lib/server/api/task-workflow-hybrid.e2e.test.ts +++ b/lib/server/api/task-workflow-hybrid.e2e.test.ts @@ -393,7 +393,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { expect(tasks.every((task) => typeof task.workflow_run_id === 'string' && task.workflow_run_id.length > 0)).toBe(true); }); - it('persists watchlist category and tags and forwards them to auto sync task payload', async () => { + it('persists watchlist category and tags without auto queueing a filing sync task', async () => { const created = await jsonRequest('POST', '/api/watchlist', { ticker: 'shop', companyName: 'Shopify Inc.', @@ -415,12 +415,12 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { expect(createdBody.item.ticker).toBe('SHOP'); expect(createdBody.item.category).toBe('core'); expect(createdBody.item.tags).toEqual(['growth', 'ecommerce']); - expect(createdBody.autoFilingSyncQueued).toBe(true); + expect(createdBody.autoFilingSyncQueued).toBe(false); const tasksResponse = await jsonRequest('GET', '/api/tasks?limit=5'); expect(tasksResponse.response.status).toBe(200); - const task = (tasksResponse.json as { + const syncTasks = (tasksResponse.json as { tasks: Array<{ task_type: string; payload: { @@ -430,13 +430,86 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { limit?: number; }; }>; - }).tasks.find((entry) => entry.task_type === 'sync_filings'); + }).tasks.filter((entry) => entry.task_type === 'sync_filings'); - expect(task).toBeTruthy(); - expect(task?.payload.ticker).toBe('SHOP'); - expect(task?.payload.limit).toBe(20); - expect(task?.payload.category).toBe('core'); - expect(task?.payload.tags).toEqual(['growth', 'ecommerce']); + expect(syncTasks).toHaveLength(0); + }); + + it('does not queue a filing sync task when coverage metadata is edited', async () => { + const created = await jsonRequest('POST', '/api/watchlist', { + ticker: 'amd', + companyName: 'Advanced Micro Devices, Inc.', + sector: 'Technology', + category: 'watch', + tags: ['semis'] + }); + + expect(created.response.status).toBe(200); + const item = (created.json as { + item: { id: number }; + }).item; + + const updated = await jsonRequest('PATCH', `/api/watchlist/${item.id}`, { + category: 'core', + tags: ['semis', 'ai'] + }); + + expect(updated.response.status).toBe(200); + + const tasksResponse = await jsonRequest('GET', '/api/tasks?limit=5'); + expect(tasksResponse.response.status).toBe(200); + + const syncTasks = (tasksResponse.json as { + tasks: Array<{ task_type: string }>; + }).tasks.filter((entry) => entry.task_type === 'sync_filings'); + + expect(syncTasks).toHaveLength(0); + }); + + it('forwards watchlist metadata when filing sync is started explicitly', async () => { + const created = await jsonRequest('POST', '/api/watchlist', { + ticker: 'shop', + companyName: 'Shopify Inc.', + sector: 'Technology', + category: 'core', + tags: ['growth', 'ecommerce', 'growth', ' '] + }); + + expect(created.response.status).toBe(200); + const createdBody = created.json as { + item: { + ticker: string; + category: string | null; + tags: string[]; + }; + }; + + const sync = await jsonRequest('POST', '/api/filings/sync', { + ticker: createdBody.item.ticker, + limit: 20, + category: createdBody.item.category, + tags: createdBody.item.tags + }); + + expect(sync.response.status).toBe(200); + + const task = (sync.json as { + task: { + task_type: string; + payload: { + ticker: string; + limit: number; + category?: string; + tags?: string[]; + }; + }; + }).task; + + expect(task.task_type).toBe('sync_filings'); + expect(task.payload.ticker).toBe('SHOP'); + expect(task.payload.limit).toBe(20); + expect(task.payload.category).toBe('core'); + expect(task.payload.tags).toEqual(['growth', 'ecommerce']); }); it('accepts category and comma-separated tags on manual filings sync payload', async () => {