'use client'; import { useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; import { ArrowRight, CalendarClock, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AppShell } from '@/components/shell/app-shell'; 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 { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { deleteWatchlistItem, queueFilingSync, updateWatchlistItem, upsertWatchlistItem } from '@/lib/api'; import { queryKeys } from '@/lib/query/keys'; import { watchlistQueryOptions } from '@/lib/query/options'; import type { CoveragePriority, CoverageStatus, WatchlistItem } from '@/lib/types'; type FormState = { ticker: string; companyName: string; sector: string; category: string; status: CoverageStatus; priority: CoveragePriority; 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' }, { value: 'watch', label: 'Watch' }, { value: 'archive', label: 'Archive' } ]; const PRIORITY_OPTIONS: Array<{ value: CoveragePriority; label: string }> = [ { value: 'high', label: 'High' }, { value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' } ]; const EMPTY_FORM: FormState = { ticker: '', companyName: '', sector: '', category: '', status: 'backlog', priority: 'medium', tags: '' }; const SELECT_CLASS_NAME = 'min-h-11 w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]'; function parseTagsInput(input: string) { const unique = new Set(); for (const segment of input.split(',')) { const tag = segment.trim(); if (!tag) { continue; } unique.add(tag); } return [...unique]; } function formatDateTime(value: string | null) { if (!value) { return 'Not reviewed'; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } return format(parsed, 'MMM dd, yyyy · HH:mm'); } function formatDateOnly(value: string | null) { if (!value) { return 'No filings'; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } return format(parsed, 'MMM dd, yyyy'); } function normalizeSearchValue(value: string) { return value.trim().toLowerCase(); } export default function WatchlistPage() { const { isPending, isAuthenticated } = useAuthGuard(); const queryClient = useQueryClient(); const { prefetchResearchTicker } = useLinkPrefetch(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [search, setSearch] = useState(''); 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(); if (!queryClient.getQueryData(options.queryKey)) { setLoading(true); } setError(null); try { const response = await queryClient.fetchQuery(options); setItems(response.items); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load coverage'); } finally { setLoading(false); } }, [queryClient]); useEffect(() => { if (!isPending && isAuthenticated) { void loadCoverage(); } }, [isPending, isAuthenticated, loadCoverage]); const filteredItems = useMemo(() => { const normalizedSearch = normalizeSearchValue(search); if (!normalizedSearch) { return items; } return items.filter((item) => { const haystack = [ item.ticker, item.company_name, item.sector ?? '', item.category ?? '', item.status, item.priority, ...item.tags ].join(' ').toLowerCase(); return haystack.includes(normalizedSearch); }); }, [items, search]); const resetForm = useCallback(() => { setEditingItemId(null); setForm(EMPTY_FORM); }, []); const beginEdit = useCallback((item: WatchlistItem) => { setPostCreateNotice(null); setEditingItemId(item.id); setForm({ ticker: item.ticker, companyName: item.company_name, sector: item.sector ?? '', category: item.category ?? '', status: item.status, priority: item.priority, tags: item.tags.join(', ') }); }, []); const invalidateCoverageQueries = useCallback((ticker?: string) => { void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); if (ticker) { const normalizedTicker = ticker.trim().toUpperCase(); void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) }); void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) }); } }, [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, category: form.category.trim() || undefined, status: form.status, priority: form.priority, tags: parseTagsInput(form.tags) }; 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(ticker); await loadCoverage(); resetForm(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save coverage item'); } finally { setSaving(false); } }; const updateCoverageInline = async ( item: WatchlistItem, patch: { status?: CoverageStatus; priority?: CoveragePriority; lastReviewedAt?: string; } ) => { try { await updateWatchlistItem(item.id, { status: patch.status, priority: patch.priority, lastReviewedAt: patch.lastReviewedAt }); invalidateCoverageQueries(item.ticker); await loadCoverage(); } catch (err) { setError(err instanceof Error ? err.message : `Failed to update ${item.ticker}`); } }; const queueSync = async (item: WatchlistItem) => { try { setError(null); await queueCoverageSync({ ticker: item.ticker, category: item.category, tags: item.tags }); } 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...
; } return (
setSearch(event.target.value)} placeholder="Search ticker, company, tag, sector..." className="w-full sm:min-w-[18rem]" />
)} > {error ?

{error}

: null} {loading ? (

Loading coverage...

) : filteredItems.length === 0 ? (

No coverage items match the current search.

) : (
{filteredItems.map((item) => (
{item.ticker}
{item.company_name}
{item.sector ?? 'Unclassified'} {item.category ? ` · ${item.category}` : ''}

Last filing

{formatDateOnly(item.latest_filing_date)}

{item.tags.length > 0 ? item.tags.map((tag) => ( {tag} )) : No tags}

Last reviewed: {formatDateTime(item.last_reviewed_at)}

prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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 overview prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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)]" > Financials prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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)]" > Filings
))}
{filteredItems.map((item) => ( ))}
Company Status Priority Tags Last Filing Last Reviewed Actions
{item.ticker}
{item.company_name}
{item.sector ?? 'Unclassified'} {item.category ? ` · ${item.category}` : ''}
{item.tags.length > 0 ? (
{item.tags.map((tag) => ( {tag} ))}
) : ( No tags )}
{formatDateOnly(item.latest_filing_date)} {formatDateTime(item.last_reviewed_at)}
prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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 overview prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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)]" > Research prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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)]" > Financials prefetchResearchTicker(item.ticker)} onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 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)]" > Filings
)}
{editingItemId === null ? (

Saving coverage adds the company to your board only. Filing sync starts when you choose Sync filings.

) : null} {postCreateNotice ? (

{postCreateNotice.syncState === 'queued' ? `${postCreateNotice.ticker} added to coverage. Filing sync is queued.` : `${postCreateNotice.ticker} added to coverage. Filing sync has not started yet.`}

{postCreateNotice.error ? (

{postCreateNotice.error}

) : null}
) : null}
setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))} required />
setForm((prev) => ({ ...prev, companyName: event.target.value }))} required />
setForm((prev) => ({ ...prev, sector: event.target.value }))} />
setForm((prev) => ({ ...prev, category: event.target.value }))} placeholder="Core, cyclical, event-driven..." />
setForm((prev) => ({ ...prev, tags: event.target.value }))} placeholder="Comma-separated tags" />
{editingItemId !== null ? ( ) : null}
); }