'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; }; 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_rgba(0,255,180,0.14)]'; 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 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) => { 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 saveCoverage = async (event: React.FormEvent) => { event.preventDefault(); setSaving(true); setError(null); try { 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 (editingItemId === null) { await upsertWatchlistItem({ ticker: form.ticker.trim().toUpperCase(), ...payload }); } else { await updateWatchlistItem(editingItemId, payload); } invalidateCoverageQueries(form.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 { await queueFilingSync({ ticker: item.ticker, limit: 20, category: item.category ?? undefined, tags: item.tags.length > 0 ? item.tags : undefined }); 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}`); } }; 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)]" > Analyze 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)]" > Analyze 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
)}
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}
); }