Add search and RAG workspace flows

This commit is contained in:
2026-03-07 20:34:00 -05:00
parent db01f207a5
commit e20aba998b
35 changed files with 3417 additions and 372 deletions

View File

@@ -2,7 +2,7 @@
import { useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { format } from 'date-fns';
import {
CartesianGrid,
@@ -169,10 +169,12 @@ function AnalysisPageContent() {
const [journalLoading, setJournalLoading] = useState(true);
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
const [highlightedJournalId, setHighlightedJournalId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
const journalEntryRefs = useRef(new Map<number, HTMLElement | null>());
useEffect(() => {
const normalized = normalizeTickerInput(searchParams.get('ticker'));
@@ -182,6 +184,8 @@ function AnalysisPageContent() {
setTickerInput(normalized);
setTicker(normalized);
const journalId = Number(searchParams.get('journalId'));
setHighlightedJournalId(Number.isInteger(journalId) && journalId > 0 ? journalId : null);
}, [searchParams]);
const loadAnalysis = useCallback(async (symbol: string) => {
@@ -231,6 +235,26 @@ function AnalysisPageContent() {
}
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
useEffect(() => {
if (!highlightedJournalId) {
return;
}
const node = journalEntryRefs.current.get(highlightedJournalId);
if (!node) {
return;
}
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
const timeoutId = window.setTimeout(() => {
setHighlightedJournalId((current) => (current === highlightedJournalId ? null : current));
}, 2200);
return () => {
window.clearTimeout(timeoutId);
};
}, [highlightedJournalId, journalEntries]);
const priceSeries = useMemo(() => {
return (analysis?.priceHistory ?? []).map((point) => ({
...point,
@@ -349,26 +373,35 @@ function AnalysisPageContent() {
subtitle="Research a single ticker across pricing, 10-K/10-Q financials, qualitative filings, and generated AI reports."
activeTicker={analysis?.company.ticker ?? ticker}
actions={(
<Button
variant="secondary"
onClick={() => {
const normalizedTicker = activeTicker.trim().toUpperCase();
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
void Promise.all([
loadAnalysis(normalizedTicker),
loadJournal(normalizedTicker)
]);
}}
>
<RefreshCcw className="size-4" />
Refresh
</Button>
<>
<Link
href={`/search?ticker=${encodeURIComponent(activeTicker.trim().toUpperCase())}`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
<Search className="size-4" />
Ask with RAG
</Link>
<Button
variant="secondary"
onClick={() => {
const normalizedTicker = activeTicker.trim().toUpperCase();
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
void Promise.all([
loadAnalysis(normalizedTicker),
loadJournal(normalizedTicker)
]);
}}
>
<RefreshCcw className="size-4" />
Refresh
</Button>
</>
)}
>
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
<form
className="flex flex-wrap items-center gap-3"
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
onSubmit={(event) => {
event.preventDefault();
const normalized = tickerInput.trim().toUpperCase();
@@ -383,9 +416,9 @@ function AnalysisPageContent() {
aria-label="Analysis ticker"
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker (AAPL)"
className="max-w-xs"
className="w-full sm:max-w-xs"
/>
<Button type="submit">
<Button type="submit" className="w-full sm:w-auto">
<Search className="size-4" />
Analyze
</Button>
@@ -462,7 +495,7 @@ function AnalysisPageContent() {
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
{analysis?.coverage ? (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</p>
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.status}</p>
@@ -560,7 +593,7 @@ function AnalysisPageContent() {
) : priceSeries.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
) : (
<div className="h-[320px]">
<div className="h-[260px] sm:h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={priceSeries}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
@@ -638,35 +671,67 @@ function AnalysisPageContent() {
) : filteredFinancialSeries.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No financial rows match the selected period filter.</p>
) : (
<div className="overflow-x-auto">
<table className="data-table min-w-[820px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Form</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Net Margin</th>
</tr>
</thead>
<tbody>
{filteredFinancialSeries.map((point, index) => (
<tr key={`${point.filingDate}-${point.filingType}-${index}`}>
<td>{formatLongDate(point.filingDate)}</td>
<td>{point.periodLabel}</td>
<td>{point.filingType}</td>
<td>{asScaledFinancialCurrency(point.revenue, financialValueScale)}</td>
<td className={(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}>
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{filteredFinancialSeries.map((point, index) => (
<article key={`${point.filingDate}-${point.filingType}-${index}`} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{formatLongDate(point.filingDate)}</p>
<p className="text-xs text-[color:var(--terminal-muted)]">{point.filingType} · {point.periodLabel}</p>
</div>
<p className={`text-sm font-medium ${(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
</td>
<td>{asScaledFinancialCurrency(point.assets, financialValueScale)}</td>
<td>{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</td>
</p>
</div>
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.revenue, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.assets, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Net Margin</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</dd>
</div>
</dl>
</article>
))}
</div>
<div className="hidden overflow-x-auto lg:block">
<table className="data-table min-w-[820px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Form</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Net Margin</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filteredFinancialSeries.map((point, index) => (
<tr key={`${point.filingDate}-${point.filingType}-${index}`}>
<td>{formatLongDate(point.filingDate)}</td>
<td>{point.periodLabel}</td>
<td>{point.filingType}</td>
<td>{asScaledFinancialCurrency(point.revenue, financialValueScale)}</td>
<td className={(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}>
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
</td>
<td>{asScaledFinancialCurrency(point.assets, financialValueScale)}</td>
<td>{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
@@ -681,41 +746,75 @@ function AnalysisPageContent() {
) : periodEndFilings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No 10-K or 10-Q filings available for this ticker.</p>
) : (
<div className="overflow-x-auto">
<table className="data-table min-w-[860px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Type</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Document</th>
</tr>
</thead>
<tbody>
{periodEndFilings.map((filing) => (
<tr key={filing.accession_number}>
<td>{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</td>
<td>{filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</td>
<td>{filing.filing_type}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</td>
<td>
{filing.filing_url ? (
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
SEC filing
</a>
) : (
'n/a'
)}
</td>
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{periodEndFilings.map((filing) => (
<article key={filing.accession_number} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</p>
<p className="text-xs text-[color:var(--terminal-muted)]">{filing.filing_type} · {filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</p>
</div>
{filing.filing_url ? (
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
SEC filing
</a>
) : null}
</div>
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Net Income</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</dd>
</div>
</dl>
</article>
))}
</div>
<div className="hidden overflow-x-auto lg:block">
<table className="data-table min-w-[860px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Type</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Document</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{periodEndFilings.map((filing) => (
<tr key={filing.accession_number}>
<td>{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</td>
<td>{filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</td>
<td>{filing.filing_type}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</td>
<td>
{filing.filing_url ? (
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
SEC filing
</a>
) : (
'n/a'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
@@ -739,7 +838,7 @@ function AnalysisPageContent() {
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
</div>
<p className="mt-3 line-clamp-6 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{report.summary}</p>
<div className="mt-4 flex items-center justify-between gap-2">
<div className="mt-4 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<p className="text-xs text-[color:var(--terminal-muted)]">{report.accessionNumber}</p>
<Link
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
@@ -792,12 +891,12 @@ function AnalysisPageContent() {
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
<Button type="submit" className="w-full sm:w-auto">
<NotebookPen className="size-4" />
{editingJournalId === null ? 'Save note' : 'Update note'}
</Button>
{editingJournalId !== null ? (
<Button type="button" variant="ghost" onClick={resetJournalForm}>
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetJournalForm}>
Cancel edit
</Button>
) : null}
@@ -816,7 +915,17 @@ function AnalysisPageContent() {
const canEdit = entry.entry_type !== 'status_change';
return (
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<article
key={entry.id}
ref={(node) => {
journalEntryRefs.current.set(entry.id, node);
}}
className={`rounded-xl border bg-[color:var(--panel-soft)] p-4 transition ${
highlightedJournalId === entry.id
? 'border-[color:var(--line-strong)] shadow-[0_0_0_1px_rgba(0,255,180,0.14),0_0_28px_rgba(0,255,180,0.16)]'
: 'border-[color:var(--line-weak)]'
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">

View File

@@ -250,17 +250,26 @@ function FilingsPageContent() {
subtitle="Sync SEC submissions, keep 10-K/10-Q financial snapshots, and analyze qualitative signals from other forms."
activeTicker={searchTicker || null}
actions={(
<Button
variant="secondary"
className="w-full sm:w-auto"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(searchTicker || null, 120) });
void loadFilings(searchTicker || undefined);
}}
>
<TimerReset className="size-4" />
Refresh table
</Button>
<>
<Link
href={`/search${searchTicker ? `?ticker=${encodeURIComponent(searchTicker)}` : ''}`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
<Search className="size-4" />
Ask with RAG
</Link>
<Button
variant="secondary"
className="w-full sm:w-auto"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(searchTicker || null, 120) });
void loadFilings(searchTicker || undefined);
}}
>
<TimerReset className="size-4" />
Refresh table
</Button>
</>
)}
>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
@@ -334,7 +343,7 @@ function FilingsPageContent() {
title="Filing Ledger"
subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
actions={(
<div className="flex flex-wrap justify-end gap-2">
<div className="flex w-full flex-wrap justify-start gap-2 sm:justify-end">
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
<Button
key={option.value}

View File

@@ -609,7 +609,7 @@ function FinancialsPageContent() {
>
<Panel title="Company Selector" subtitle="Load one ticker across statements, ratios, and KPI time series.">
<form
className="flex flex-wrap items-center gap-3"
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
onSubmit={(event) => {
event.preventDefault();
const normalized = tickerInput.trim().toUpperCase();
@@ -623,9 +623,9 @@ function FinancialsPageContent() {
value={tickerInput}
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker (AAPL)"
className="max-w-xs"
className="w-full sm:max-w-xs"
/>
<Button type="submit">
<Button type="submit" className="w-full sm:w-auto">
<Search className="size-4" />
Load Financials
</Button>
@@ -656,12 +656,12 @@ function FinancialsPageContent() {
/>
<Panel title="Search & Filters" subtitle="Filter rows without mutating the source data.">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Input
value={rowSearch}
onChange={(event) => setRowSearch(event.target.value)}
placeholder="Search rows by label"
className="max-w-sm"
className="w-full sm:max-w-sm"
/>
<span className="text-sm text-[color:var(--terminal-muted)]">
{filteredRows.length} of {activeRows.length} rows

View File

@@ -29,6 +29,10 @@ body {
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
[data-sonner-toaster] {
--normal-bg: rgba(7, 22, 31, 0.96);
--normal-text: #e8fff8;
@@ -48,6 +52,7 @@ body {
body {
min-height: 100vh;
overflow-x: hidden;
font-family: var(--font-display), sans-serif;
color: var(--terminal-bright);
background:
@@ -92,6 +97,14 @@ body {
letter-spacing: 0.08em;
}
a,
button,
input,
select,
textarea {
touch-action: manipulation;
}
.data-table {
width: 100%;
border-collapse: collapse;
@@ -140,3 +153,18 @@ body {
background-size: 26px 26px;
}
}
@media (max-width: 640px) {
body {
background:
radial-gradient(circle at 24% -4%, rgba(126, 217, 255, 0.2), transparent 36%),
radial-gradient(circle at 82% 2%, rgba(104, 255, 213, 0.16), transparent 30%),
linear-gradient(155deg, var(--bg-0), var(--bg-1) 54%, var(--bg-2));
}
.data-table th,
.data-table td {
padding: 0.65rem 0.55rem;
font-size: 0.8125rem;
}
}

View File

@@ -1,5 +1,5 @@
import './globals.css';
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { QueryProvider } from '@/components/providers/query-provider';
export const metadata: Metadata = {
@@ -7,6 +7,13 @@ export const metadata: Metadata = {
description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#05080d'
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">

View File

@@ -179,16 +179,16 @@ export default function PortfolioPage() {
title="Portfolio"
subtitle="Position management, market valuation, and AI generated portfolio commentary."
actions={(
<>
<Button variant="secondary" onClick={() => void queueRefresh()}>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
<RefreshCcw className="size-4" />
Queue price refresh
</Button>
<Button onClick={() => void queueInsights()}>
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
<BrainCircuit className="size-4" />
Generate AI brief
</Button>
</>
</div>
)}
>
{error ? (
@@ -219,7 +219,7 @@ export default function PortfolioPage() {
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : allocationData.length > 0 ? (
<div className="h-[300px]">
<div className="h-[260px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
@@ -255,7 +255,7 @@ export default function PortfolioPage() {
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : performanceData.length > 0 ? (
<div className="h-[300px]">
<div className="h-[260px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={performanceData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
@@ -302,106 +302,214 @@ export default function PortfolioPage() {
) : holdings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
) : (
<div className="max-w-full overflow-x-auto">
<table className="data-table min-w-[1020px]">
<thead>
<tr>
<th>Ticker</th>
<th>Company</th>
<th>Shares</th>
<th>Avg Cost</th>
<th>Price</th>
<th>Value</th>
<th>Gain/Loss</th>
<th>Research</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{holdings.map((holding) => (
<tr key={holding.id}>
<td>{holding.ticker}</td>
<td>{holding.company_name ?? 'n/a'}</td>
<td>{asNumber(holding.shares).toLocaleString()}</td>
<td>{formatCurrency(holding.avg_cost)}</td>
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
<td>{formatCurrency(holding.market_value)}</td>
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
</td>
<td>
<div className="flex flex-wrap gap-2">
<Link
href={`/analysis?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Analysis
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Financials
</Link>
<Link
href={`/filings?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Filings
</Link>
</div>
</td>
<td>
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setEditingHoldingId(holding.id);
setForm({
ticker: holding.ticker,
companyName: holding.company_name ?? '',
shares: String(asNumber(holding.shares)),
avgCost: String(asNumber(holding.avg_cost)),
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
});
}}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
if (editingHoldingId === holding.id) {
resetHoldingForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
</td>
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{holdings.map((holding) => (
<article key={holding.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{holding.ticker}</p>
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{holding.company_name ?? 'Company name unavailable'}</p>
</div>
<p className={`text-sm font-medium ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
{formatCurrency(holding.gain_loss)}
</p>
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Shares</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asNumber(holding.shares).toLocaleString()}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Avg Cost</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.avg_cost)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Price</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Value</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.market_value)}</dd>
</div>
</dl>
<p className={`mt-3 text-xs ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
Return {formatPercent(holding.gain_loss_pct)}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={`/analysis?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
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)]"
>
Analysis
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
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)]"
>
Financials
</Link>
<Link
href={`/filings?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
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)]"
>
Filings
</Link>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setEditingHoldingId(holding.id);
setForm({
ticker: holding.ticker,
companyName: holding.company_name ?? '',
shares: String(asNumber(holding.shares)),
avgCost: String(asNumber(holding.avg_cost)),
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
});
}}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
if (editingHoldingId === holding.id) {
resetHoldingForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
</article>
))}
</div>
<div className="hidden max-w-full overflow-x-auto lg:block">
<table className="data-table min-w-[1020px]">
<thead>
<tr>
<th>Ticker</th>
<th>Company</th>
<th>Shares</th>
<th>Avg Cost</th>
<th>Price</th>
<th>Value</th>
<th>Gain/Loss</th>
<th>Research</th>
<th>Action</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{holdings.map((holding) => (
<tr key={holding.id}>
<td>{holding.ticker}</td>
<td>{holding.company_name ?? 'n/a'}</td>
<td>{asNumber(holding.shares).toLocaleString()}</td>
<td>{formatCurrency(holding.avg_cost)}</td>
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
<td>{formatCurrency(holding.market_value)}</td>
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
</td>
<td>
<div className="flex flex-wrap gap-2">
<Link
href={`/analysis?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Analysis
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Financials
</Link>
<Link
href={`/filings?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Filings
</Link>
</div>
</td>
<td>
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setEditingHoldingId(holding.id);
setForm({
ticker: holding.ticker,
companyName: holding.company_name ?? '',
shares: String(asNumber(holding.shares)),
avgCost: String(asNumber(holding.avg_cost)),
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
});
}}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
if (editingHoldingId === holding.id) {
resetHoldingForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
@@ -440,12 +548,12 @@ export default function PortfolioPage() {
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="flex-1">
<Button type="submit" className="w-full sm:flex-1">
<Plus className="size-4" />
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
</Button>
{editingHoldingId !== null ? (
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
Cancel
</Button>
) : null}

268
app/search/page.tsx Normal file
View File

@@ -0,0 +1,268 @@
'use client';
import Link from 'next/link';
import { Suspense, useEffect, useMemo, useState, useTransition } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { BrainCircuit, ExternalLink, Search as SearchIcon } from 'lucide-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 { getSearchAnswer } from '@/lib/api';
import { searchQueryOptions } from '@/lib/query/options';
import type { SearchAnswerResponse, SearchResult, SearchSource } from '@/lib/types';
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
{ value: 'documents', label: 'Documents' },
{ value: 'filings', label: 'Filing briefs' },
{ value: 'research', label: 'Research notes' }
];
function parseSourceParams(value: string | null) {
if (!value) {
return ['documents', 'filings', 'research'] as SearchSource[];
}
const normalized = value
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter((entry): entry is SearchSource => entry === 'documents' || entry === 'filings' || entry === 'research');
return normalized.length > 0 ? [...new Set(normalized)] : ['documents', 'filings', 'research'] as SearchSource[];
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>}>
<SearchPageContent />
</Suspense>
);
}
function SearchPageContent() {
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const initialQuery = searchParams.get('q')?.trim() ?? '';
const initialTicker = searchParams.get('ticker')?.trim().toUpperCase() ?? '';
const initialSources = useMemo(() => parseSourceParams(searchParams.get('sources')), [searchParams]);
const [queryInput, setQueryInput] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [tickerInput, setTickerInput] = useState(initialTicker);
const [ticker, setTicker] = useState(initialTicker);
const [sources, setSources] = useState<SearchSource[]>(initialSources);
const [results, setResults] = useState<SearchResult[]>([]);
const [answer, setAnswer] = useState<SearchAnswerResponse | null>(null);
const [loading, setLoading] = useState(false);
const [answerLoading, startAnswerTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setQueryInput(initialQuery);
setQuery(initialQuery);
setTickerInput(initialTicker);
setTicker(initialTicker);
setSources(initialSources);
}, [initialQuery, initialTicker, initialSources]);
useEffect(() => {
if (!query.trim() || !isAuthenticated) {
setResults([]);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
queryClient.fetchQuery(searchQueryOptions({
query,
ticker: ticker || undefined,
sources,
limit: 10
})).then((response) => {
if (!cancelled) {
setResults(response.results);
}
}).catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unable to search indexed sources');
setResults([]);
}
}).finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isAuthenticated, query, queryClient, sources, ticker]);
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>;
}
const runAnswer = () => {
if (!query.trim()) {
return;
}
startAnswerTransition(() => {
setError(null);
getSearchAnswer({
query,
ticker: ticker || undefined,
sources,
limit: 10
}).then((response) => {
setAnswer(response);
}).catch((err) => {
setError(err instanceof Error ? err.message : 'Unable to generate cited answer');
setAnswer(null);
});
});
};
return (
<AppShell
title="Search"
subtitle="Hybrid semantic + lexical retrieval across primary filings, filing briefs, and private research notes."
activeTicker={ticker || null}
>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Panel title="Search Query" subtitle="Run semantic search with an optional ticker filter and source selection.">
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
setQuery(queryInput.trim());
setTicker(tickerInput.trim().toUpperCase());
setAnswer(null);
}}
>
<Input
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder="Ask about margin drivers, segment commentary, risks, or your notes..."
/>
<div className="flex flex-col gap-3 sm:flex-row">
<Input
value={tickerInput}
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker filter (optional)"
className="sm:max-w-xs"
/>
<div className="flex flex-wrap gap-2">
{SOURCE_OPTIONS.map((option) => {
const selected = sources.includes(option.value);
return (
<Button
key={option.value}
type="button"
variant={selected ? 'primary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => {
setSources((current) => {
if (selected && current.length > 1) {
return current.filter((entry) => entry !== option.value);
}
return selected ? current : [...current, option.value];
});
}}
>
{option.label}
</Button>
);
})}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="w-full sm:w-auto">
<SearchIcon className="size-4" />
Search
</Button>
<Button type="button" variant="secondary" className="w-full sm:w-auto" onClick={runAnswer} disabled={!query.trim() || answerLoading}>
<BrainCircuit className="size-4" />
{answerLoading ? 'Answering...' : 'Cited answer'}
</Button>
</div>
</form>
</Panel>
<Panel title="Cited Answer" subtitle="Single-turn answer grounded only in retrieved evidence.">
{answer ? (
<div className="space-y-3">
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">{answer.answer}</p>
{answer.citations.length > 0 ? (
<div className="space-y-2">
{answer.citations.map((citation) => (
<Link
key={`${citation.chunkId}-${citation.index}`}
href={citation.href}
className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
>
<span>[{citation.index}] {citation.label}</span>
<ExternalLink className="size-3.5 text-[color:var(--accent)]" />
</Link>
))}
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No supporting citations were strong enough to answer.</p>
)}
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">Ask a question to synthesize the top retrieved passages into a cited answer.</p>
)}
</Panel>
</div>
<Panel
title="Semantic Search"
subtitle={query ? `${results.length} results${ticker ? ` for ${ticker}` : ''}.` : 'Search results will appear here.'}
>
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Searching indexed sources...</p>
) : !query ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Enter a question or topic to search the local RAG index.</p>
) : results.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No indexed evidence matched this query.</p>
) : (
<div className="space-y-3">
{results.map((result) => (
<article key={result.chunkId} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{result.source} {result.ticker ? `· ${result.ticker}` : ''} {result.filingDate ? `· ${result.filingDate}` : ''}
</p>
<h3 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{result.title ?? result.citationLabel}</h3>
<p className="mt-1 text-xs text-[color:var(--accent)]">{result.citationLabel}</p>
</div>
<Link
href={result.href}
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 source
<ExternalLink className="size-3" />
</Link>
</div>
{result.headingPath ? (
<p className="mt-3 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">{result.headingPath}</p>
) : null}
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{result.snippet}</p>
</article>
))}
</div>
)}
</Panel>
</AppShell>
);
}

View File

@@ -58,7 +58,7 @@ const EMPTY_FORM: FormState = {
tags: ''
};
const SELECT_CLASS_NAME = '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)]';
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<string>();
@@ -281,10 +281,11 @@ export default function WatchlistPage() {
aria-label="Search coverage"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search ticker, company, tag, sector..."
className="min-w-[18rem]"
className="w-full sm:min-w-[18rem]"
/>
<Button
variant="secondary"
className="w-full sm:w-auto"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
void loadCoverage();
@@ -302,31 +303,28 @@ export default function WatchlistPage() {
) : filteredItems.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No coverage items match the current search.</p>
) : (
<div className="overflow-x-auto">
<table className="data-table min-w-[1120px]">
<thead>
<tr>
<th>Company</th>
<th>Status</th>
<th>Priority</th>
<th>Tags</th>
<th>Last Filing</th>
<th>Last Reviewed</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => (
<tr key={item.id}>
<td>
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{filteredItems.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
<div className="text-xs text-[color:var(--terminal-muted)]">
{item.sector ?? 'Unclassified'}
{item.category ? ` · ${item.category}` : ''}
</div>
</td>
<td>
</div>
<div className="text-right text-xs text-[color:var(--terminal-muted)]">
<p>Last filing</p>
<p className="mt-1 text-[color:var(--terminal-bright)]">{formatDateOnly(item.latest_filing_date)}</p>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</label>
<select
aria-label={`${item.ticker} status`}
className={SELECT_CLASS_NAME}
@@ -343,8 +341,9 @@ export default function WatchlistPage() {
</option>
))}
</select>
</td>
<td>
</div>
<div>
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Priority</label>
<select
aria-label={`${item.ticker} priority`}
className={SELECT_CLASS_NAME}
@@ -361,100 +360,252 @@ export default function WatchlistPage() {
</option>
))}
</select>
</td>
<td>
{item.tags.length > 0 ? (
<div className="flex max-w-[18rem] flex-wrap gap-1">
{item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
>
{tag}
</span>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{item.tags.length > 0 ? item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
>
{tag}
</span>
)) : <span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>}
</div>
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">Last reviewed: {formatDateTime(item.last_reviewed_at)}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Link
href={`/analysis?ticker=${item.ticker}`}
onMouseEnter={() => 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
<ArrowRight className="size-3" />
</Link>
<Link
href={`/financials?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
<Link
href={`/filings?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
Sync
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void updateCoverageInline(item, {
lastReviewedAt: new Date().toISOString()
});
}}
>
<CalendarClock className="size-3" />
Review
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => beginEdit(item)}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteWatchlistItem(item.id);
invalidateCoverageQueries(item.ticker);
await loadCoverage();
if (editingItemId === item.id) {
resetForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
}
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</article>
))}
</div>
<div className="hidden overflow-x-auto lg:block">
<table className="data-table min-w-[1120px]">
<thead>
<tr>
<th>Company</th>
<th>Status</th>
<th>Priority</th>
<th>Tags</th>
<th>Last Filing</th>
<th>Last Reviewed</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => (
<tr key={item.id}>
<td>
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
<div className="text-xs text-[color:var(--terminal-muted)]">
{item.sector ?? 'Unclassified'}
{item.category ? ` · ${item.category}` : ''}
</div>
) : (
<span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>
)}
</td>
<td>{formatDateOnly(item.latest_filing_date)}</td>
<td>{formatDateTime(item.last_reviewed_at)}</td>
<td>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/analysis?ticker=${item.ticker}`}
onMouseEnter={() => 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
<ArrowRight className="size-3" />
</Link>
<Link
href={`/financials?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
<Link
href={`/filings?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
Sync
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
</td>
<td>
<select
aria-label={`${item.ticker} status`}
className={SELECT_CLASS_NAME}
value={item.status}
onChange={(event) => {
void updateCoverageInline(item, {
lastReviewedAt: new Date().toISOString()
status: event.target.value as CoverageStatus
});
}}
>
<CalendarClock className="size-3" />
Review
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => beginEdit(item)}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteWatchlistItem(item.id);
invalidateCoverageQueries(item.ticker);
await loadCoverage();
if (editingItemId === item.id) {
resetForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
}
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
<td>
<select
aria-label={`${item.ticker} priority`}
className={SELECT_CLASS_NAME}
value={item.priority}
onChange={(event) => {
void updateCoverageInline(item, {
priority: event.target.value as CoveragePriority
});
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
<td>
{item.tags.length > 0 ? (
<div className="flex max-w-[18rem] flex-wrap gap-1">
{item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>
)}
</td>
<td>{formatDateOnly(item.latest_filing_date)}</td>
<td>{formatDateTime(item.last_reviewed_at)}</td>
<td>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/analysis?ticker=${item.ticker}`}
onMouseEnter={() => 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
<ArrowRight className="size-3" />
</Link>
<Link
href={`/financials?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
<Link
href={`/filings?ticker=${item.ticker}`}
onMouseEnter={() => 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
</Link>
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
Sync
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void updateCoverageInline(item, {
lastReviewedAt: new Date().toISOString()
});
}}
>
<CalendarClock className="size-3" />
Review
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => beginEdit(item)}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteWatchlistItem(item.id);
invalidateCoverageQueries(item.ticker);
await loadCoverage();
if (editingItemId === item.id) {
resetForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
}
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
@@ -542,12 +693,12 @@ export default function WatchlistPage() {
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="flex-1" disabled={saving}>
<Button type="submit" className="w-full sm:flex-1" disabled={saving}>
<Plus className="size-4" />
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
</Button>
{editingItemId !== null ? (
<Button type="button" variant="ghost" onClick={resetForm}>
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetForm}>
Clear
</Button>
) : null}