Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
This commit is contained in:
@@ -13,7 +13,15 @@ import {
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import { BrainCircuit, ChartNoAxesCombined, RefreshCcw, Search } from 'lucide-react';
|
||||
import {
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
NotebookPen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SquarePen,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +29,11 @@ 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 {
|
||||
createResearchJournalEntry,
|
||||
deleteResearchJournalEntry,
|
||||
updateResearchJournalEntry
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
asNumber,
|
||||
formatCurrency,
|
||||
@@ -29,8 +42,14 @@ import {
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import { companyAnalysisQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
researchJournalQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
CompanyAnalysis,
|
||||
ResearchJournalEntry
|
||||
} from '@/lib/types';
|
||||
|
||||
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
||||
|
||||
@@ -44,6 +63,18 @@ type FinancialSeriesPoint = {
|
||||
netMargin: number | null;
|
||||
};
|
||||
|
||||
type JournalFormState = {
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
accessionNumber: string;
|
||||
};
|
||||
|
||||
const EMPTY_JOURNAL_FORM: JournalFormState = {
|
||||
title: '',
|
||||
bodyMarkdown: '',
|
||||
accessionNumber: ''
|
||||
};
|
||||
|
||||
const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [
|
||||
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
||||
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
||||
@@ -70,6 +101,10 @@ function formatLongDate(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
if (numerator === null || denominator === null || denominator === 0) {
|
||||
return null;
|
||||
@@ -78,6 +113,11 @@ function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
return (numerator / denominator) * 100;
|
||||
}
|
||||
|
||||
function normalizeTickerInput(value: string | null) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isFinancialSnapshotForm(
|
||||
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
||||
): filingType is '10-K' | '10-Q' {
|
||||
@@ -120,22 +160,22 @@ function AnalysisPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
||||
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT';
|
||||
|
||||
const [tickerInput, setTickerInput] = useState('MSFT');
|
||||
const [ticker, setTicker] = useState('MSFT');
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [analysis, setAnalysis] = useState<CompanyAnalysis | null>(null);
|
||||
const [journalEntries, setJournalEntries] = useState<ResearchJournalEntry[]>([]);
|
||||
const [journalLoading, setJournalLoading] = useState(true);
|
||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||
const [editingJournalId, setEditingJournalId] = 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');
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('ticker');
|
||||
if (!fromQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = fromQuery.trim().toUpperCase();
|
||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +194,7 @@ function AnalysisPageContent() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setAnalysis(response.analysis);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
|
||||
@@ -164,11 +204,32 @@ function AnalysisPageContent() {
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const loadJournal = useCallback(async (symbol: string) => {
|
||||
const options = researchJournalQueryOptions(symbol);
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setJournalLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setJournalEntries(response.entries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research journal');
|
||||
setJournalEntries([]);
|
||||
} finally {
|
||||
setJournalLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadAnalysis(ticker);
|
||||
void Promise.all([
|
||||
loadAnalysis(ticker),
|
||||
loadJournal(ticker)
|
||||
]);
|
||||
}
|
||||
}, [isPending, isAuthenticated, ticker, loadAnalysis]);
|
||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||
@@ -207,6 +268,77 @@ function AnalysisPageContent() {
|
||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
||||
}, [financialValueScale]);
|
||||
|
||||
const resetJournalForm = useCallback(() => {
|
||||
setEditingJournalId(null);
|
||||
setJournalForm(EMPTY_JOURNAL_FORM);
|
||||
}, []);
|
||||
|
||||
const activeTicker = analysis?.company.ticker ?? ticker;
|
||||
|
||||
const saveJournalEntry = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingJournalId === null) {
|
||||
await createResearchJournalEntry({
|
||||
ticker: normalizedTicker,
|
||||
entryType: journalForm.accessionNumber.trim() ? 'filing_note' : 'note',
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown,
|
||||
accessionNumber: journalForm.accessionNumber.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
await updateResearchJournalEntry(editingJournalId, {
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
resetJournalForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
const beginEditJournalEntry = (entry: ResearchJournalEntry) => {
|
||||
setEditingJournalId(entry.id);
|
||||
setJournalForm({
|
||||
title: entry.title ?? '',
|
||||
bodyMarkdown: entry.body_markdown,
|
||||
accessionNumber: entry.accession_number ?? ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeJournalEntry = async (entry: ResearchJournalEntry) => {
|
||||
try {
|
||||
await deleteResearchJournalEntry(entry.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(entry.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(entry.ticker) });
|
||||
await Promise.all([
|
||||
loadAnalysis(entry.ticker),
|
||||
loadJournal(entry.ticker)
|
||||
]);
|
||||
if (editingJournalId === entry.id) {
|
||||
resetJournalForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>;
|
||||
}
|
||||
@@ -220,8 +352,13 @@ function AnalysisPageContent() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker.trim().toUpperCase()) });
|
||||
void loadAnalysis(ticker);
|
||||
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" />
|
||||
@@ -243,6 +380,7 @@ function AnalysisPageContent() {
|
||||
>
|
||||
<Input
|
||||
value={tickerInput}
|
||||
aria-label="Analysis ticker"
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
@@ -252,14 +390,24 @@ function AnalysisPageContent() {
|
||||
Analyze
|
||||
</Button>
|
||||
{analysis ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
href={`/financials?ticker=${analysis.company.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</Panel>
|
||||
@@ -310,6 +458,101 @@ function AnalysisPageContent() {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<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="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>
|
||||
</div>
|
||||
<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)]">Priority</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.priority}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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)]">Last Reviewed</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.last_reviewed_at ? formatDateTime(analysis.coverage.last_reviewed_at) : 'No research review recorded yet'}</p>
|
||||
</div>
|
||||
<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)]">Latest Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.latest_filing_date ? formatLongDate(analysis.coverage.latest_filing_date) : 'No filing history loaded yet'}</p>
|
||||
</div>
|
||||
<Link href="/watchlist" className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
Manage coverage
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">This company is not yet in your coverage list. Add it from Coverage to track workflow status and review cadence.</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Key Metrics" subtitle="Latest filing-level metrics used to anchor research.">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md: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)]">Revenue</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.revenue, financialValueScale)}</p>
|
||||
</div>
|
||||
<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)]">Net Income</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.netIncome, financialValueScale)}</p>
|
||||
</div>
|
||||
<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)]">Assets</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.totalAssets, financialValueScale)}</p>
|
||||
</div>
|
||||
<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)]">Cash / Debt</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis?.keyMetrics.cash != null && analysis?.keyMetrics.debt != null
|
||||
? `${asScaledFinancialCurrency(analysis.keyMetrics.cash, financialValueScale)} / ${asScaledFinancialCurrency(analysis.keyMetrics.debt, financialValueScale)}`
|
||||
: 'n/a'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">
|
||||
Reference date: {analysis?.keyMetrics.referenceDate ? formatLongDate(analysis.keyMetrics.referenceDate) : 'No financial filing selected'}.
|
||||
{' '}Net margin: {analysis && analysis.keyMetrics.netMargin !== null ? formatPercent(analysis.keyMetrics.netMargin) : 'n/a'}.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Latest Filing Snapshot" subtitle="Most recent filing and whether an AI memo already exists.">
|
||||
{analysis?.latestFilingSummary ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<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)]">Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.filingType} · {formatLongDate(analysis.latestFilingSummary.filingDate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.summary ?? 'No AI summary stored yet for the most recent filing.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analysis.latestFilingSummary.hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(analysis.latestFilingSummary.accessionNumber)}`}
|
||||
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 latest memo
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.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 filing stream
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No filing snapshot available yet for this company.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<Panel title="Price History" subtitle="Weekly close over the last year.">
|
||||
{loading ? (
|
||||
@@ -480,11 +723,11 @@ function AnalysisPageContent() {
|
||||
<Panel title="AI Reports" subtitle="Generated filing analyses for this company.">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading AI reports...</p>
|
||||
) : !analysis || analysis.aiReports.length === 0 ? (
|
||||
) : !analysis || analysis.recentAiReports.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No AI reports generated yet. Run filing analysis from the filings stream.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{analysis.aiReports.map((report) => (
|
||||
{analysis.recentAiReports.map((report) => (
|
||||
<article key={report.accessionNumber} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -513,10 +756,122 @@ function AnalysisPageContent() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_1.3fr]">
|
||||
<Panel
|
||||
title={editingJournalId === null ? 'Research Journal' : 'Edit Journal Entry'}
|
||||
subtitle="Private markdown notes for this company. Linked filing notes update the coverage review timestamp."
|
||||
>
|
||||
<form onSubmit={saveJournalEntry} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Title</label>
|
||||
<Input
|
||||
value={journalForm.title}
|
||||
aria-label="Journal title"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
placeholder="Investment thesis checkpoint, risk note, follow-up..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Linked Filing (optional)</label>
|
||||
<Input
|
||||
value={journalForm.accessionNumber}
|
||||
aria-label="Journal linked filing"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))}
|
||||
placeholder="0000000000-26-000001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Body</label>
|
||||
<textarea
|
||||
value={journalForm.bodyMarkdown}
|
||||
aria-label="Journal body"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write your thesis update, questions, risks, and next steps..."
|
||||
className="min-h-[220px] 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 placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<NotebookPen className="size-4" />
|
||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{editingJournalId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Journal Timeline" subtitle={`${journalEntries.length} stored entries for ${activeTicker}.`}>
|
||||
{journalLoading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading journal entries...</p>
|
||||
) : journalEntries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No journal notes yet. Start a thesis log or attach a filing note from the filings stream.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{journalEntries.map((entry) => {
|
||||
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">
|
||||
<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)]">
|
||||
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||
{entry.title ?? 'Untitled entry'}
|
||||
</h4>
|
||||
</div>
|
||||
{entry.accession_number ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${activeTicker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
|
||||
onFocus={() => prefetchResearchTicker(activeTicker)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
|
||||
{canEdit ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEditJournalEntry(entry)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void removeJournalEntry(entry);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||
<ChartNoAxesCombined className="size-4" />
|
||||
Analysis scope: price + filings + ai synthesis
|
||||
Analysis scope: price + filings + ai synthesis + research journal
|
||||
</div>
|
||||
</Panel>
|
||||
</AppShell>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, BrainCircuit, RefreshCcw } from 'lucide-react';
|
||||
import { ArrowLeft, BrainCircuit, NotebookPen, RefreshCcw } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
@@ -14,6 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAiReportDetail } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { createResearchJournalEntry } from '@/lib/api';
|
||||
|
||||
function formatFilingDate(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -44,6 +45,8 @@ export default function AnalysisReportPage() {
|
||||
const [report, setReport] = useState<CompanyAiReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingToJournal, setSavingToJournal] = useState(false);
|
||||
const [journalNotice, setJournalNotice] = useState<string | null>(null);
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
if (!accessionNumber) {
|
||||
@@ -174,6 +177,49 @@ export default function AnalysisReportPage() {
|
||||
<BrainCircuit className="size-3.5" />
|
||||
Full text view
|
||||
</div>
|
||||
{journalNotice ? (
|
||||
<p className="mb-3 text-xs text-[color:var(--accent)]">{journalNotice}</p>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={savingToJournal}
|
||||
onClick={async () => {
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingToJournal(true);
|
||||
setJournalNotice(null);
|
||||
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
ticker: report.ticker,
|
||||
accessionNumber: report.accessionNumber,
|
||||
entryType: 'filing_note',
|
||||
title: `${report.filingType} AI memo`,
|
||||
bodyMarkdown: [
|
||||
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
|
||||
`Accession: ${report.accessionNumber}`,
|
||||
'',
|
||||
report.summary
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setJournalNotice('Saved to the company research journal.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to journal');
|
||||
} finally {
|
||||
setSavingToJournal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
{savingToJournal ? 'Saving...' : 'Add to journal'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{report.summary}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Bot, Download, ExternalLink, Search, TimerReset } from 'lucide-react';
|
||||
import { Bot, Download, ExternalLink, NotebookPen, Search, TimerReset } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
@@ -13,7 +13,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import { queueFilingAnalysis, queueFilingSync } from '@/lib/api';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
queueFilingAnalysis,
|
||||
queueFilingSync
|
||||
} from '@/lib/api';
|
||||
import type { Filing } from '@/lib/types';
|
||||
import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
@@ -131,6 +135,7 @@ function FilingsPageContent() {
|
||||
const [filterTickerInput, setFilterTickerInput] = useState('');
|
||||
const [searchTicker, setSearchTicker] = useState('');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ticker = searchParams.get('ticker');
|
||||
@@ -152,7 +157,7 @@ function FilingsPageContent() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setFilings(response.filings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to fetch filings');
|
||||
@@ -197,6 +202,30 @@ function FilingsPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const addToJournal = async (filing: Filing) => {
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
ticker: filing.ticker,
|
||||
accessionNumber: filing.accession_number,
|
||||
entryType: 'filing_note',
|
||||
title: `${filing.filing_type} filing note`,
|
||||
bodyMarkdown: [
|
||||
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
|
||||
`Filed: ${formatFilingDate(filing.filing_date)}`,
|
||||
`Accession: ${filing.accession_number}`,
|
||||
'',
|
||||
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
|
||||
}
|
||||
};
|
||||
|
||||
const groupedByTicker = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -321,6 +350,7 @@ function FilingsPageContent() {
|
||||
)}
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{actionNotice ? <p className="mt-2 text-sm text-[color:var(--accent)]">{actionNotice}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
|
||||
) : filings.length === 0 ? (
|
||||
@@ -379,6 +409,14 @@ function FilingsPageContent() {
|
||||
<Bot className="size-3" />
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Add to journal
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
||||
@@ -449,6 +487,14 @@ function FilingsPageContent() {
|
||||
<Bot className="size-3" />
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Journal
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
||||
|
||||
@@ -178,6 +178,50 @@ function mergeSurfaceRows<T extends { key: string; values: Record<string, number
|
||||
return [...rowMap.values()];
|
||||
}
|
||||
|
||||
function mergeOverviewMetrics(
|
||||
base: CompanyFinancialStatementsResponse['overviewMetrics'],
|
||||
next: CompanyFinancialStatementsResponse['overviewMetrics']
|
||||
): CompanyFinancialStatementsResponse['overviewMetrics'] {
|
||||
const mergedSeriesMap = new Map<string, CompanyFinancialStatementsResponse['overviewMetrics']['series'][number]>();
|
||||
|
||||
for (const source of [base.series, next.series]) {
|
||||
for (const point of source) {
|
||||
const existing = mergedSeriesMap.get(point.periodId);
|
||||
if (!existing) {
|
||||
mergedSeriesMap.set(point.periodId, { ...point });
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.filingDate = existing.filingDate || point.filingDate;
|
||||
existing.periodEnd = existing.periodEnd ?? point.periodEnd;
|
||||
existing.label = existing.label || point.label;
|
||||
existing.revenue = existing.revenue ?? point.revenue;
|
||||
existing.netIncome = existing.netIncome ?? point.netIncome;
|
||||
existing.totalAssets = existing.totalAssets ?? point.totalAssets;
|
||||
existing.cash = existing.cash ?? point.cash;
|
||||
existing.debt = existing.debt ?? point.debt;
|
||||
}
|
||||
}
|
||||
|
||||
const series = [...mergedSeriesMap.values()].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
const latest = series[series.length - 1] ?? null;
|
||||
|
||||
return {
|
||||
referencePeriodId: next.referencePeriodId ?? base.referencePeriodId ?? latest?.periodId ?? null,
|
||||
referenceDate: next.referenceDate ?? base.referenceDate ?? latest?.filingDate ?? null,
|
||||
latest: {
|
||||
revenue: next.latest.revenue ?? base.latest.revenue ?? latest?.revenue ?? null,
|
||||
netIncome: next.latest.netIncome ?? base.latest.netIncome ?? latest?.netIncome ?? null,
|
||||
totalAssets: next.latest.totalAssets ?? base.latest.totalAssets ?? latest?.totalAssets ?? null,
|
||||
cash: next.latest.cash ?? base.latest.cash ?? latest?.cash ?? null,
|
||||
debt: next.latest.debt ?? base.latest.debt ?? latest?.debt ?? null
|
||||
},
|
||||
series
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFinancialPages(
|
||||
base: CompanyFinancialStatementsResponse | null,
|
||||
next: CompanyFinancialStatementsResponse
|
||||
@@ -317,6 +361,7 @@ function mergeFinancialPages(
|
||||
...next.dataSourceStatus,
|
||||
queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync
|
||||
},
|
||||
overviewMetrics: mergeOverviewMetrics(base.overviewMetrics, next.overviewMetrics),
|
||||
dimensionBreakdown
|
||||
};
|
||||
}
|
||||
@@ -342,6 +387,33 @@ function buildOverviewSeries(
|
||||
incomeData: CompanyFinancialStatementsResponse | null,
|
||||
balanceData: CompanyFinancialStatementsResponse | null
|
||||
): OverviewPoint[] {
|
||||
const overviewSeriesMap = new Map<string, OverviewPoint>();
|
||||
|
||||
for (const source of [incomeData?.overviewMetrics.series ?? [], balanceData?.overviewMetrics.series ?? []]) {
|
||||
for (const point of source) {
|
||||
const existing = overviewSeriesMap.get(point.periodId);
|
||||
if (!existing) {
|
||||
overviewSeriesMap.set(point.periodId, { ...point });
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.filingDate = existing.filingDate || point.filingDate;
|
||||
existing.periodEnd = existing.periodEnd ?? point.periodEnd;
|
||||
existing.label = existing.label || point.label;
|
||||
existing.revenue = existing.revenue ?? point.revenue;
|
||||
existing.netIncome = existing.netIncome ?? point.netIncome;
|
||||
existing.totalAssets = existing.totalAssets ?? point.totalAssets;
|
||||
existing.cash = existing.cash ?? point.cash;
|
||||
existing.debt = existing.debt ?? point.debt;
|
||||
}
|
||||
}
|
||||
|
||||
if (overviewSeriesMap.size > 0) {
|
||||
return [...overviewSeriesMap.values()].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
}
|
||||
|
||||
const periodMap = new Map<string, { filingDate: string; periodEnd: string | null }>();
|
||||
|
||||
for (const source of [incomeData, balanceData]) {
|
||||
@@ -609,27 +681,36 @@ function FinancialsPageContent() {
|
||||
|
||||
const latestStandardizedIncome = overviewIncome?.surfaces.standardized.rows ?? [];
|
||||
const latestStandardizedBalance = overviewBalance?.surfaces.standardized.rows ?? [];
|
||||
const latestRevenue = latestOverview?.revenue
|
||||
const latestRevenue = overviewIncome?.overviewMetrics.latest.revenue
|
||||
?? latestOverview?.revenue
|
||||
?? findStandardizedRow(latestStandardizedIncome, 'revenue')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.revenue
|
||||
?? null;
|
||||
const latestNetIncome = latestOverview?.netIncome
|
||||
const latestNetIncome = overviewIncome?.overviewMetrics.latest.netIncome
|
||||
?? latestOverview?.netIncome
|
||||
?? findStandardizedRow(latestStandardizedIncome, 'net-income')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.netIncome
|
||||
?? null;
|
||||
const latestTotalAssets = latestOverview?.totalAssets
|
||||
const latestTotalAssets = overviewBalance?.overviewMetrics.latest.totalAssets
|
||||
?? latestOverview?.totalAssets
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'total-assets')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.totalAssets
|
||||
?? null;
|
||||
const latestCash = latestOverview?.cash
|
||||
const latestCash = overviewBalance?.overviewMetrics.latest.cash
|
||||
?? latestOverview?.cash
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'cash-and-equivalents')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.cash
|
||||
?? null;
|
||||
const latestDebt = latestOverview?.debt
|
||||
const latestDebt = overviewBalance?.overviewMetrics.latest.debt
|
||||
?? latestOverview?.debt
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'total-debt')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.debt
|
||||
?? null;
|
||||
const latestReferenceDate = latestOverview?.filingDate ?? periods[periods.length - 1]?.filingDate ?? null;
|
||||
const latestReferenceDate = overviewIncome?.overviewMetrics.referenceDate
|
||||
?? overviewBalance?.overviewMetrics.referenceDate
|
||||
?? latestOverview?.filingDate
|
||||
?? periods[periods.length - 1]?.filingDate
|
||||
?? null;
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selectedRowKey) {
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function CommandCenterPage() {
|
||||
positive={Number(state.summary.total_gain_loss) >= 0}
|
||||
/>
|
||||
<MetricCard label="Tracked Filings" value={String(state.filingsCount)} delta="Last 200 records" />
|
||||
<MetricCard label="Watchlist Nodes" value={String(state.watchlistCount)} delta={`${state.summary.positions} positions active`} />
|
||||
<MetricCard label="Coverage Names" value={String(state.watchlistCount)} delta={`${state.summary.positions} positions active`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
@@ -223,7 +223,7 @@ export default function CommandCenterPage() {
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Portfolio</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage holdings and mark to market in real time.</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage the active private portfolio and mark positions to market.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
@@ -231,8 +231,8 @@ export default function CommandCenterPage() {
|
||||
onMouseEnter={() => prefetchPortfolioSurfaces()}
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Watchlist</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track priority tickers for monitoring and ingestion.</p>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Coverage</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track research status, review cadence, and filing freshness per company.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
|
||||
import { BrainCircuit, Plus, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { BrainCircuit, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
deleteHolding,
|
||||
queuePortfolioInsights,
|
||||
queuePriceRefresh,
|
||||
updateHolding,
|
||||
upsertHolding
|
||||
} from '@/lib/api';
|
||||
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
|
||||
type FormState = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
shares: string;
|
||||
avgCost: string;
|
||||
currentPrice: string;
|
||||
@@ -49,13 +53,15 @@ const EMPTY_SUMMARY: PortfolioSummary = {
|
||||
export default function PortfolioPage() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
|
||||
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
const [editingHoldingId, setEditingHoldingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
|
||||
const loadPortfolio = useCallback(async () => {
|
||||
const holdingsOptions = holdingsQueryOptions();
|
||||
@@ -70,9 +76,9 @@ export default function PortfolioPage() {
|
||||
|
||||
try {
|
||||
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
||||
queryClient.ensureQueryData(holdingsOptions),
|
||||
queryClient.ensureQueryData(summaryOptions),
|
||||
queryClient.ensureQueryData(insightOptions)
|
||||
queryClient.fetchQuery(holdingsOptions),
|
||||
queryClient.fetchQuery(summaryOptions),
|
||||
queryClient.fetchQuery(insightOptions)
|
||||
]);
|
||||
|
||||
setHoldings(holdingsRes.holdings);
|
||||
@@ -107,18 +113,33 @@ export default function PortfolioPage() {
|
||||
[holdings]
|
||||
);
|
||||
|
||||
const resetHoldingForm = useCallback(() => {
|
||||
setEditingHoldingId(null);
|
||||
setForm({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
}, []);
|
||||
|
||||
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await upsertHolding({
|
||||
ticker: form.ticker.toUpperCase(),
|
||||
shares: Number(form.shares),
|
||||
avgCost: Number(form.avgCost),
|
||||
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
||||
});
|
||||
if (editingHoldingId === null) {
|
||||
await upsertHolding({
|
||||
ticker: form.ticker.toUpperCase(),
|
||||
companyName: form.companyName.trim() || undefined,
|
||||
shares: Number(form.shares),
|
||||
avgCost: Number(form.avgCost),
|
||||
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
||||
});
|
||||
} else {
|
||||
await updateHolding(editingHoldingId, {
|
||||
companyName: form.companyName.trim() || undefined,
|
||||
shares: Number(form.shares),
|
||||
avgCost: Number(form.avgCost),
|
||||
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
setForm({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
resetHoldingForm();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
@@ -282,15 +303,17 @@ export default function PortfolioPage() {
|
||||
<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-[780px]">
|
||||
<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>
|
||||
@@ -298,6 +321,7 @@ export default function PortfolioPage() {
|
||||
{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>
|
||||
@@ -306,23 +330,73 @@ export default function PortfolioPage() {
|
||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||
</td>
|
||||
<td>
|
||||
<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();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
<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>
|
||||
))}
|
||||
@@ -332,28 +406,50 @@ export default function PortfolioPage() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Add / Update Holding">
|
||||
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'}>
|
||||
<form onSubmit={submitHolding} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
|
||||
<Input value={form.ticker} onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))} required />
|
||||
<Input
|
||||
value={form.ticker}
|
||||
aria-label="Holding ticker"
|
||||
disabled={editingHoldingId !== null}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Company Name (optional)</label>
|
||||
<Input
|
||||
value={form.companyName}
|
||||
aria-label="Holding company name"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))}
|
||||
placeholder="Resolved from coverage or filings if left blank"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Shares</label>
|
||||
<Input type="number" step="0.0001" min="0.0001" value={form.shares} onChange={(event) => setForm((prev) => ({ ...prev, shares: event.target.value }))} required />
|
||||
<Input aria-label="Holding shares" type="number" step="0.0001" min="0.0001" value={form.shares} onChange={(event) => setForm((prev) => ({ ...prev, shares: event.target.value }))} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Average Cost</label>
|
||||
<Input type="number" step="0.0001" min="0.0001" value={form.avgCost} onChange={(event) => setForm((prev) => ({ ...prev, avgCost: event.target.value }))} required />
|
||||
<Input aria-label="Holding average cost" type="number" step="0.0001" min="0.0001" value={form.avgCost} onChange={(event) => setForm((prev) => ({ ...prev, avgCost: event.target.value }))} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
|
||||
<Input type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||
<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">
|
||||
<Plus className="size-4" />
|
||||
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||
</Button>
|
||||
{editingHoldingId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
<Plus className="size-4" />
|
||||
Save holding
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowRight, Eye, Plus, Trash2 } from 'lucide-react';
|
||||
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 { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { deleteWatchlistItem, queueFilingSync, upsertWatchlistItem } from '@/lib/api';
|
||||
import type { WatchlistItem } from '@/lib/types';
|
||||
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 = '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>();
|
||||
|
||||
@@ -38,6 +75,36 @@ function parseTagsInput(input: string) {
|
||||
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();
|
||||
@@ -45,16 +112,13 @@ export default function WatchlistPage() {
|
||||
|
||||
const [items, setItems] = useState<WatchlistItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({
|
||||
ticker: '',
|
||||
companyName: '',
|
||||
sector: '',
|
||||
category: '',
|
||||
tags: ''
|
||||
});
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
const loadCoverage = useCallback(async () => {
|
||||
const options = watchlistQueryOptions();
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
@@ -64,10 +128,10 @@ export default function WatchlistPage() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setItems(response.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load watchlist');
|
||||
setError(err instanceof Error ? err.message : 'Failed to load coverage');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -75,33 +139,110 @@ export default function WatchlistPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadWatchlist();
|
||||
void loadCoverage();
|
||||
}
|
||||
}, [isPending, isAuthenticated, loadWatchlist]);
|
||||
}, [isPending, isAuthenticated, loadCoverage]);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await upsertWatchlistItem({
|
||||
ticker: form.ticker.toUpperCase(),
|
||||
companyName: form.companyName,
|
||||
sector: form.sector || undefined,
|
||||
category: form.category || undefined,
|
||||
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)
|
||||
});
|
||||
};
|
||||
|
||||
setForm({
|
||||
ticker: '',
|
||||
companyName: '',
|
||||
sector: '',
|
||||
category: '',
|
||||
tags: ''
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await loadWatchlist();
|
||||
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 watchlist item');
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,127 +255,303 @@ export default function WatchlistPage() {
|
||||
tags: item.tags.length > 0 ? item.tags : undefined
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||
void queryClient.invalidateQueries({ queryKey: ['filings'] });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(item.ticker, 120) });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to queue sync for ${item.ticker}`);
|
||||
setError(err instanceof Error ? err.message : `Failed to queue filing sync for ${item.ticker}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading watchlist terminal...</div>;
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading coverage terminal...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Watchlist"
|
||||
subtitle="Track symbols, company context, and trigger filing ingestion jobs from one surface."
|
||||
title="Coverage"
|
||||
subtitle="Track research status, priorities, filing recency, and handoff into company analysis."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
|
||||
<Panel title="Symbols" subtitle="Your monitored universe.">
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.8fr_1fr]">
|
||||
<Panel
|
||||
title="Coverage Board"
|
||||
subtitle={`${items.length} tracked companies across backlog, active work, and archive.`}
|
||||
actions={(
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Input
|
||||
value={search}
|
||||
aria-label="Search coverage"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search ticker, company, tag, sector..."
|
||||
className="min-w-[18rem]"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
void loadCoverage();
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading watchlist...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No symbols yet. Add one from the right panel.</p>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading coverage...</p>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No coverage items match the current search.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{items.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 items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
|
||||
{item.sector ?? 'Unclassified'}
|
||||
{item.category ? ` · ${item.category}` : ''}
|
||||
</p>
|
||||
<h3 className="mt-1 text-xl font-semibold text-[color:var(--terminal-bright)]">{item.ticker}</h3>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{item.company_name}</p>
|
||||
{item.tags.length > 0 ? (
|
||||
<div className="mt-2 flex 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 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="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>
|
||||
) : null}
|
||||
</div>
|
||||
<Eye className="size-4 text-[color:var(--accent)]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||
Sync filings
|
||||
</Button>
|
||||
<Link
|
||||
href={`/filings?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open stream
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/analysis?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analyze
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="ml-auto px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteWatchlistItem(item.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await loadWatchlist();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove symbol');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${item.ticker} status`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={item.status}
|
||||
onChange={(event) => {
|
||||
void updateCoverageInline(item, {
|
||||
status: event.target.value as CoverageStatus
|
||||
});
|
||||
}}
|
||||
>
|
||||
{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
|
||||
});
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Add Symbol" subtitle="Create or update a watchlist item.">
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<Panel
|
||||
title={editingItemId === null ? 'Add Coverage' : 'Edit Coverage'}
|
||||
subtitle={editingItemId === null ? 'Create a new company coverage record.' : 'Update metadata, priority, and workflow status.'}
|
||||
>
|
||||
<form onSubmit={saveCoverage} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Ticker</label>
|
||||
<Input value={form.ticker} onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))} required />
|
||||
<Input
|
||||
value={form.ticker}
|
||||
aria-label="Coverage ticker"
|
||||
disabled={editingItemId !== null}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Company Name</label>
|
||||
<Input value={form.companyName} onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))} required />
|
||||
<Input
|
||||
value={form.companyName}
|
||||
aria-label="Coverage company name"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Sector</label>
|
||||
<Input value={form.sector} onChange={(event) => setForm((prev) => ({ ...prev, sector: event.target.value }))} />
|
||||
<Input
|
||||
value={form.sector}
|
||||
aria-label="Coverage sector"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, sector: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Category</label>
|
||||
<Input value={form.category} onChange={(event) => setForm((prev) => ({ ...prev, category: event.target.value }))} placeholder="e.g. Core, Speculative, Watch only" />
|
||||
<Input
|
||||
value={form.category}
|
||||
aria-label="Coverage category"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category: event.target.value }))}
|
||||
placeholder="Core, cyclical, event-driven..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Status</label>
|
||||
<select
|
||||
aria-label="Coverage status"
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={form.status}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, status: event.target.value as CoverageStatus }))}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Priority</label>
|
||||
<select
|
||||
aria-label="Coverage priority"
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={form.priority}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, priority: event.target.value as CoveragePriority }))}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Tags</label>
|
||||
<Input value={form.tags} onChange={(event) => setForm((prev) => ({ ...prev, tags: event.target.value }))} placeholder="Comma-separated tags" />
|
||||
<Input
|
||||
value={form.tags}
|
||||
aria-label="Coverage tags"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, tags: event.target.value }))}
|
||||
placeholder="Comma-separated tags"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="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}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
<Plus className="size-4" />
|
||||
Save symbol
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user