Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled

This commit is contained in:
2026-03-07 09:51:18 -05:00
parent f69e5b671b
commit 52136271d3
26 changed files with 2719 additions and 243 deletions

View File

@@ -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,6 +390,15 @@ function AnalysisPageContent() {
Analyze
</Button>
{analysis ? (
<>
<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)}
@@ -260,6 +407,7 @@ function AnalysisPageContent() {
>
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>

View File

@@ -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>

View File

@@ -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)}`}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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 {
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,6 +330,52 @@ export default function PortfolioPage() {
{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"
@@ -315,6 +385,9 @@ export default function PortfolioPage() {
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');
}
@@ -323,6 +396,7 @@ export default function PortfolioPage() {
<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>
<Button type="submit" className="w-full">
<div className="flex flex-wrap gap-2">
<Button type="submit" className="flex-1">
<Plus className="size-4" />
Save holding
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
</Button>
{editingHoldingId !== null ? (
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
Cancel
</Button>
) : null}
</div>
</form>
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">

View File

@@ -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: ''
if (editingItemId === null) {
await upsertWatchlistItem({
ticker: form.ticker.trim().toUpperCase(),
...payload
});
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
await loadWatchlist();
} 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,42 +255,116 @@ 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.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>
)}
>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
<Panel title="Symbols" subtitle="Your monitored universe.">
{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)]">
<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}` : ''}
</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>
</div>
</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="mt-2 flex flex-wrap gap-1">
<div className="flex max-w-[18rem] flex-wrap gap-1">
{item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
@@ -159,43 +374,75 @@ export default function WatchlistPage() {
</span>
))}
</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>
) : (
<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 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
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="ml-auto px-2 py-1 text-xs"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteWatchlistItem(item.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
await loadWatchlist();
invalidateCoverageQueries(item.ticker);
await loadCoverage();
if (editingItemId === item.id) {
resetForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove symbol');
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
}
}}
>
@@ -203,38 +450,108 @@ export default function WatchlistPage() {
Remove
</Button>
</div>
</article>
</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>
<Button type="submit" className="w-full">
<div className="flex flex-wrap gap-2">
<Button type="submit" className="flex-1" disabled={saving}>
<Plus className="size-4" />
Save symbol
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
</Button>
{editingItemId !== null ? (
<Button type="button" variant="ghost" onClick={resetForm}>
Clear
</Button>
) : null}
</div>
</form>
</Panel>
</div>

View File

@@ -88,7 +88,7 @@ const NAV_ITEMS: NavConfigItem[] = [
{
id: 'watchlist',
href: '/watchlist',
label: 'Watchlist',
label: 'Coverage',
icon: Eye,
group: 'portfolio',
matchMode: 'exact',
@@ -174,7 +174,7 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
if (pathname.startsWith('/watchlist')) {
return [
{ label: 'Portfolio', href: '/portfolio' },
{ label: 'Watchlist' }
{ label: 'Coverage' }
];
}

View File

@@ -0,0 +1,41 @@
ALTER TABLE `watchlist_item` ADD `status` text NOT NULL DEFAULT 'backlog';
--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `priority` text NOT NULL DEFAULT 'medium';
--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `updated_at` text NOT NULL DEFAULT '';
--> statement-breakpoint
UPDATE `watchlist_item`
SET `status` = 'backlog'
WHERE `status` IS NULL OR TRIM(`status`) = '';
--> statement-breakpoint
UPDATE `watchlist_item`
SET `priority` = 'medium'
WHERE `priority` IS NULL OR TRIM(`priority`) = '';
--> statement-breakpoint
UPDATE `watchlist_item`
SET `updated_at` = COALESCE(NULLIF(`created_at`, ''), CURRENT_TIMESTAMP)
WHERE `updated_at` IS NULL OR TRIM(`updated_at`) = '';
--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `last_reviewed_at` text;
--> statement-breakpoint
ALTER TABLE `holding` ADD `company_name` text;
--> statement-breakpoint
CREATE INDEX `watchlist_user_updated_idx` ON `watchlist_item` (`user_id`, `updated_at`);
--> statement-breakpoint
CREATE TABLE `research_journal_entry` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`accession_number` text,
`entry_type` text NOT NULL,
`title` text,
`body_markdown` text NOT NULL,
`metadata` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);
--> statement-breakpoint
CREATE INDEX `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);

View File

@@ -43,6 +43,13 @@
"when": 1772668800000,
"tag": "0005_financial_taxonomy_v3",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772830800000,
"tag": "0006_coverage_journal_tracking",
"breakpoints": true
}
]
}

189
e2e/research-mvp.spec.ts Normal file
View File

@@ -0,0 +1,189 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
const PASSWORD = 'Sup3rSecure!123';
const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');
test.describe.configure({ mode: 'serial' });
function toSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Research User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/$/);
return email;
}
function seedFiling(input: {
ticker: string;
companyName: string;
accessionNumber: string;
filingType: '10-K' | '10-Q';
filingDate: string;
summary: string;
}) {
const now = new Date().toISOString();
execFileSync('python3', [
'-c',
`
import json
import sqlite3
import sys
db_path, ticker, filing_type, filing_date, accession, company_name, now, summary = sys.argv[1:]
connection = sqlite3.connect(db_path)
try:
connection.execute(
"""
INSERT INTO filing (
ticker,
filing_type,
filing_date,
accession_number,
cik,
company_name,
filing_url,
submission_url,
primary_document,
metrics,
analysis,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ticker,
filing_type,
filing_date,
accession,
"0001045810",
company_name,
f"https://www.sec.gov/Archives/{accession}.htm",
f"https://www.sec.gov/submissions/{accession}.json",
f"{accession}.htm",
json.dumps({
"revenue": 61000000000,
"netIncome": 29000000000,
"totalAssets": 98000000000,
"cash": 27000000000,
"debt": 11000000000,
}),
json.dumps({
"provider": "playwright",
"model": "fixture",
"text": summary,
}),
now,
now,
),
)
connection.commit()
finally:
connection.close()
`,
E2E_DATABASE_PATH,
input.ticker,
input.filingType,
input.filingDate,
input.accessionNumber,
input.companyName,
now,
input.summary
]);
}
test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => {
const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`;
await signUp(page, testInfo);
seedFiling({
ticker: 'NVDA',
companyName: 'NVIDIA Corporation',
accessionNumber,
filingType: '10-K',
filingDate: '2026-02-18',
summary: 'AI datacenter demand remained the central upside driver with expanding operating leverage.'
});
await page.goto('/watchlist');
await page.getByLabel('Coverage ticker').fill('NVDA');
await page.getByLabel('Coverage company name').fill('NVIDIA Corporation');
await page.getByLabel('Coverage sector').fill('Technology');
await page.getByLabel('Coverage category').fill('Core');
await page.getByLabel('Coverage tags').fill('AI, semis');
await page.getByRole('button', { name: 'Save coverage' }).click();
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
await page.getByLabel('NVDA status').selectOption('active');
await expect(page.getByLabel('NVDA status')).toHaveValue('active');
await page.getByLabel('NVDA priority').selectOption('high');
await expect(page.getByLabel('NVDA priority')).toHaveValue('high');
await page.getByRole('link', { name: /^Analyze/ }).first().click();
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Coverage Workflow')).toBeVisible();
await page.getByLabel('Journal title').fill('Own-the-stack moat check');
await page.getByLabel('Journal body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
await page.getByRole('button', { name: 'Save note' }).click();
await expect(page.getByText('Own-the-stack moat check')).toBeVisible();
await page.getByRole('link', { name: 'Open summary' }).first().click();
await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
await page.getByRole('button', { name: 'Add to journal' }).click();
await expect(page.getByText('Saved to the company research journal.')).toBeVisible();
await page.getByRole('link', { name: 'Back to analysis' }).click();
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('10-K AI memo')).toBeVisible();
await page.getByRole('link', { name: 'Open financials' }).click();
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);
await page.getByRole('link', { name: 'Filings' }).first().click();
await expect(page).toHaveURL(/\/filings\?ticker=NVDA/);
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
await expect(page.getByRole('button', { name: /journal/i }).first()).toBeVisible();
});
test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await page.goto('/portfolio');
await page.getByLabel('Holding ticker').fill('MSFT');
await page.getByLabel('Holding company name').fill('Microsoft Corporation');
await page.getByLabel('Holding shares').fill('10');
await page.getByLabel('Holding average cost').fill('100');
await page.getByLabel('Holding current price').fill('110');
await page.getByRole('button', { name: 'Save holding' }).click();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
await expect(page.getByRole('cell', { name: '$1,100.00' })).toBeVisible();
await page.getByRole('button', { name: /^Edit$/ }).first().click();
await page.getByLabel('Holding company name').fill('Microsoft Corp.');
await page.getByLabel('Holding current price').fill('120');
await page.getByRole('button', { name: 'Update holding' }).click();
await expect(page.getByText('Microsoft Corp.')).toBeVisible();
await expect(page.getByRole('cell', { name: '$1,200.00' })).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).first().click();
await expect(page.getByText('No holdings added yet.')).toBeVisible();
});

View File

@@ -4,12 +4,16 @@ import type {
CompanyAiReportDetail,
CompanyAnalysis,
CompanyFinancialStatementsResponse,
CoveragePriority,
CoverageStatus,
Filing,
Holding,
FinancialHistoryWindow,
FinancialStatementKind,
PortfolioInsight,
PortfolioSummary,
ResearchJournalEntry,
ResearchJournalEntryType,
Task,
TaskStatus,
TaskTimeline,
@@ -99,6 +103,36 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
return payload as T;
}
async function requestJson<T>(input: {
path: string;
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
}, fallback: string) {
const response = await fetch(`${API_BASE}${input.path}`, {
method: input.method ?? 'GET',
credentials: 'include',
cache: 'no-store',
headers: input.body === undefined ? undefined : {
'content-type': 'application/json'
},
body: input.body === undefined ? undefined : JSON.stringify(input.body)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new ApiError(
extractErrorMessage({ value: payload }, fallback),
response.status
);
}
if (payload === null || payload === undefined) {
throw new ApiError(fallback, response.status);
}
return payload as T;
}
export async function getMe() {
const result = await client.api.me.get();
return await unwrapData<{ user: User }>(result, 'Unable to fetch session');
@@ -115,16 +149,80 @@ export async function upsertWatchlistItem(input: {
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string;
}) {
const result = await client.api.watchlist.post(input);
return await unwrapData<{ item: WatchlistItem }>(result, 'Unable to save watchlist item');
}
export async function updateWatchlistItem(id: number, input: {
companyName?: string;
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string;
}) {
return await requestJson<{ item: WatchlistItem; statusChangeJournalCreated: boolean }>({
path: `/api/watchlist/${id}`,
method: 'PATCH',
body: input
}, 'Unable to update watchlist item');
}
export async function deleteWatchlistItem(id: number) {
const result = await client.api.watchlist[id].delete();
return await unwrapData<{ success: boolean }>(result, 'Unable to delete watchlist item');
}
export async function listResearchJournal(ticker: string) {
const result = await client.api.research.journal.get({
$query: {
ticker: ticker.trim().toUpperCase()
}
});
return await unwrapData<{ entries: ResearchJournalEntry[] }>(result, 'Unable to fetch research journal');
}
export async function createResearchJournalEntry(input: {
ticker: string;
accessionNumber?: string;
entryType: ResearchJournalEntryType;
title?: string;
bodyMarkdown: string;
metadata?: Record<string, unknown>;
}) {
return await requestJson<{ entry: ResearchJournalEntry }>({
path: '/api/research/journal',
method: 'POST',
body: {
...input,
ticker: input.ticker.trim().toUpperCase()
}
}, 'Unable to create journal entry');
}
export async function updateResearchJournalEntry(id: number, input: {
title?: string;
bodyMarkdown?: string;
metadata?: Record<string, unknown>;
}) {
return await requestJson<{ entry: ResearchJournalEntry }>({
path: `/api/research/journal/${id}`,
method: 'PATCH',
body: input
}, 'Unable to update journal entry');
}
export async function deleteResearchJournalEntry(id: number) {
const result = await client.api.research.journal[id].delete();
return await unwrapData<{ success: boolean }>(result, 'Unable to delete journal entry');
}
export async function listHoldings() {
const result = await client.api.portfolio.holdings.get();
return await unwrapData<{ holdings: Holding[] }>(result, 'Unable to fetch holdings');
@@ -140,11 +238,22 @@ export async function upsertHolding(input: {
shares: number;
avgCost: number;
currentPrice?: number;
companyName?: string;
}) {
const result = await client.api.portfolio.holdings.post(input);
return await unwrapData<{ holding: Holding }>(result, 'Unable to save holding');
}
export async function updateHolding(id: number, input: {
shares?: number;
avgCost?: number;
currentPrice?: number;
companyName?: string;
}) {
const result = await client.api.portfolio.holdings[id].patch(input);
return await unwrapData<{ holding: Holding }>(result, 'Unable to update holding');
}
export async function deleteHolding(id: number) {
const result = await client.api.portfolio.holdings[id].delete();
return await unwrapData<{ success: boolean }>(result, 'Unable to delete holding');

View File

@@ -14,6 +14,7 @@ export const queryKeys = {
filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const,
report: (accessionNumber: string) => ['report', accessionNumber] as const,
watchlist: () => ['watchlist'] as const,
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
holdings: () => ['portfolio', 'holdings'] as const,
portfolioSummary: () => ['portfolio', 'summary'] as const,
latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const,

View File

@@ -10,6 +10,7 @@ import {
listFilings,
listHoldings,
listRecentTasks,
listResearchJournal,
listWatchlist
} from '@/lib/api';
import { queryKeys } from '@/lib/query/keys';
@@ -103,6 +104,16 @@ export function watchlistQueryOptions() {
});
}
export function researchJournalQueryOptions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.researchJournal(normalizedTicker),
queryFn: () => listResearchJournal(normalizedTicker),
staleTime: 15_000
});
}
export function holdingsQueryOptions() {
return queryOptions({
queryKey: queryKeys.holdings(),

View File

@@ -1,9 +1,12 @@
import { Elysia, t } from 'elysia';
import { getWorld } from 'workflow/runtime';
import type {
CoveragePriority,
CoverageStatus,
Filing,
FinancialHistoryWindow,
FinancialStatementKind,
ResearchJournalEntryType,
TaskStatus
} from '@/lib/types';
import { auth } from '@/lib/auth';
@@ -15,18 +18,32 @@ import {
getCompanyFinancialTaxonomy
} from '@/lib/server/financial-taxonomy';
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
import {
getFilingByAccession,
listFilingsRecords,
listLatestFilingDatesByTickers
} from '@/lib/server/repos/filings';
import {
deleteHoldingByIdRecord,
getHoldingByTicker,
listUserHoldings,
updateHoldingByIdRecord,
upsertHoldingRecord
} from '@/lib/server/repos/holdings';
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
import {
createResearchJournalEntryRecord,
deleteResearchJournalEntryRecord,
listResearchJournalEntries,
updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-journal';
import {
deleteWatchlistItemRecord,
getWatchlistItemById,
getWatchlistItemByTicker,
listWatchlistItems,
updateWatchlistItemRecord,
updateWatchlistReviewByTicker,
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
@@ -52,6 +69,9 @@ const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [
'comprehensive_income'
];
const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all'];
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -94,6 +114,14 @@ function asOptionalString(value: unknown) {
return normalized.length > 0 ? normalized : null;
}
function asOptionalRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asTags(value: unknown) {
const source = Array.isArray(value)
? value
@@ -130,6 +158,31 @@ function asHistoryWindow(value: unknown): FinancialHistoryWindow {
: '10y';
}
function asCoverageStatus(value: unknown) {
return COVERAGE_STATUSES.includes(value as CoverageStatus)
? value as CoverageStatus
: undefined;
}
function asCoveragePriority(value: unknown) {
return COVERAGE_PRIORITIES.includes(value as CoveragePriority)
? value as CoveragePriority
: undefined;
}
function asJournalEntryType(value: unknown) {
return JOURNAL_ENTRY_TYPES.includes(value as ResearchJournalEntryType)
? value as ResearchJournalEntryType
: undefined;
}
function formatLabel(value: string) {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function withFinancialMetricsPolicy(filing: Filing): Filing {
if (FINANCIAL_FORMS.has(filing.filing_type)) {
return filing;
@@ -266,7 +319,14 @@ export const app = new Elysia({ prefix: '/api' })
}
const items = await listWatchlistItems(session.user.id);
return Response.json({ items });
const latestFilingDates = await listLatestFilingDatesByTickers(items.map((item) => item.ticker));
return Response.json({
items: items.map((item) => ({
...item,
latest_filing_date: latestFilingDates.get(item.ticker) ?? null
}))
});
})
.post('/watchlist', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
@@ -280,6 +340,9 @@ export const app = new Elysia({ prefix: '/api' })
const sector = asOptionalString(payload.sector) ?? '';
const category = asOptionalString(payload.category) ?? '';
const tags = asTags(payload.tags);
const status = asCoverageStatus(payload.status);
const priority = asCoveragePriority(payload.priority);
const lastReviewedAt = asOptionalString(payload.lastReviewedAt);
if (!ticker) {
return jsonError('ticker is required');
@@ -296,7 +359,10 @@ export const app = new Elysia({ prefix: '/api' })
companyName,
sector,
category,
tags
tags,
status,
priority,
lastReviewedAt
});
const autoFilingSyncQueued = created
@@ -316,9 +382,94 @@ export const app = new Elysia({ prefix: '/api' })
companyName: t.String({ minLength: 1 }),
sector: t.Optional(t.String()),
category: t.Optional(t.String()),
tags: t.Optional(t.Union([t.Array(t.String()), t.String()]))
tags: t.Optional(t.Union([t.Array(t.String()), t.String()])),
status: t.Optional(t.Union([
t.Literal('backlog'),
t.Literal('active'),
t.Literal('watch'),
t.Literal('archive')
])),
priority: t.Optional(t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
])),
lastReviewedAt: t.Optional(t.String())
})
})
.patch('/watchlist/:id', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid watchlist id', 400);
}
const existing = await getWatchlistItemById(session.user.id, numericId);
if (!existing) {
return jsonError('Watchlist item not found', 404);
}
const payload = asRecord(body);
const nextStatus = payload.status === undefined
? existing.status
: asCoverageStatus(payload.status);
const nextPriority = payload.priority === undefined
? existing.priority
: asCoveragePriority(payload.priority);
if (payload.status !== undefined && !nextStatus) {
return jsonError('Invalid coverage status', 400);
}
if (payload.priority !== undefined && !nextPriority) {
return jsonError('Invalid coverage priority', 400);
}
try {
const item = await updateWatchlistItemRecord({
userId: session.user.id,
id: numericId,
companyName: payload.companyName === undefined ? undefined : (typeof payload.companyName === 'string' ? payload.companyName : ''),
sector: payload.sector === undefined ? undefined : (typeof payload.sector === 'string' ? payload.sector : ''),
category: payload.category === undefined ? undefined : (typeof payload.category === 'string' ? payload.category : ''),
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
status: nextStatus,
priority: nextPriority,
lastReviewedAt: payload.lastReviewedAt === undefined ? undefined : asOptionalString(payload.lastReviewedAt)
});
if (!item) {
return jsonError('Watchlist item not found', 404);
}
const statusChanged = existing.status !== item.status;
if (statusChanged) {
await createResearchJournalEntryRecord({
userId: session.user.id,
ticker: item.ticker,
entryType: 'status_change',
title: `Coverage status changed to ${formatLabel(item.status)}`,
bodyMarkdown: `Coverage status changed from ${formatLabel(existing.status)} to ${formatLabel(item.status)}.`,
metadata: {
previousStatus: existing.status,
nextStatus: item.status,
priority: item.priority
}
});
}
return Response.json({
item,
statusChangeJournalCreated: statusChanged
});
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update coverage item'));
}
})
.delete('/watchlist/:id', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
@@ -377,13 +528,15 @@ export const app = new Elysia({ prefix: '/api' })
try {
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
const companyName = asOptionalString(payload.companyName) ?? undefined;
const { holding, created } = await upsertHoldingRecord({
userId: session.user.id,
ticker,
shares,
avgCost,
currentPrice
currentPrice,
companyName
});
const autoFilingSyncQueued = created
@@ -399,7 +552,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker: t.String({ minLength: 1 }),
shares: t.Number({ exclusiveMinimum: 0 }),
avgCost: t.Number({ exclusiveMinimum: 0 }),
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
companyName: t.Optional(t.String())
})
})
.patch('/portfolio/holdings/:id', async ({ params, body }) => {
@@ -420,7 +574,8 @@ export const app = new Elysia({ prefix: '/api' })
id: numericId,
shares: asPositiveNumber(payload.shares) ?? undefined,
avgCost: asPositiveNumber(payload.avgCost) ?? undefined,
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined,
companyName: payload.companyName === undefined ? undefined : (asOptionalString(payload.companyName) ?? '')
});
if (!updated) {
@@ -435,7 +590,8 @@ export const app = new Elysia({ prefix: '/api' })
body: t.Object({
shares: t.Optional(t.Number({ exclusiveMinimum: 0 })),
avgCost: t.Optional(t.Number({ exclusiveMinimum: 0 })),
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
companyName: t.Optional(t.String())
})
})
.delete('/portfolio/holdings/:id', async ({ params }) => {
@@ -522,6 +678,127 @@ export const app = new Elysia({ prefix: '/api' })
return Response.json({ insight });
})
.get('/research/journal', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
if (!ticker) {
return jsonError('ticker is required');
}
const entries = await listResearchJournalEntries(session.user.id, ticker);
return Response.json({ entries });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.post('/research/journal', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
const entryType = asJournalEntryType(payload.entryType);
const title = asOptionalString(payload.title);
const bodyMarkdown = typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown.trim() : '';
const accessionNumber = asOptionalString(payload.accessionNumber);
const metadata = asOptionalRecord(payload.metadata);
if (!ticker) {
return jsonError('ticker is required');
}
if (!entryType) {
return jsonError('entryType is required');
}
if (!bodyMarkdown) {
return jsonError('bodyMarkdown is required');
}
try {
const entry = await createResearchJournalEntryRecord({
userId: session.user.id,
ticker,
entryType,
title,
bodyMarkdown,
accessionNumber,
metadata
});
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
return Response.json({ entry });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to create journal entry'));
}
})
.patch('/research/journal/:id', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid journal id', 400);
}
const payload = asRecord(body);
const title = payload.title === undefined ? undefined : asOptionalString(payload.title);
const bodyMarkdown = payload.bodyMarkdown === undefined
? undefined
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : '');
try {
const entry = await updateResearchJournalEntryRecord({
userId: session.user.id,
id: numericId,
title,
bodyMarkdown,
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
});
if (!entry) {
return jsonError('Journal entry not found', 404);
}
await updateWatchlistReviewByTicker(session.user.id, entry.ticker, entry.updated_at);
return Response.json({ entry });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update journal entry'));
}
})
.delete('/research/journal/:id', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid journal id', 400);
}
const removed = await deleteResearchJournalEntryRecord(session.user.id, numericId);
if (!removed) {
return jsonError('Journal entry not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/analysis/company', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
@@ -533,22 +810,21 @@ export const app = new Elysia({ prefix: '/api' })
return jsonError('ticker is required');
}
const [filings, holdings, watchlist, liveQuote, priceHistory] = await Promise.all([
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
listUserHoldings(session.user.id),
listWatchlistItems(session.user.id),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker)
getPriceHistory(ticker),
listResearchJournalEntries(session.user.id, ticker, 6)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const latestFiling = redactedFilings[0] ?? null;
const holding = holdings.find((entry) => entry.ticker === ticker) ?? null;
const watchlistItem = watchlist.find((entry) => entry.ticker === ticker) ?? null;
const companyName = latestFiling?.company_name
?? holding?.company_name
?? watchlistItem?.company_name
?? ticker;
@@ -575,6 +851,30 @@ export const app = new Elysia({ prefix: '/api' })
model: entry.analysis?.model ?? 'unknown',
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
}));
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
const keyMetrics = {
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
revenue: referenceMetrics?.revenue ?? null,
netIncome: referenceMetrics?.netIncome ?? null,
totalAssets: referenceMetrics?.totalAssets ?? null,
cash: referenceMetrics?.cash ?? null,
debt: referenceMetrics?.debt ?? null,
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
filingDate: latestFiling.filing_date,
filingType: latestFiling.filing_type,
filingUrl: latestFiling.filing_url,
submissionUrl: latestFiling.submission_url ?? null,
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
return Response.json({
analysis: {
@@ -591,7 +891,17 @@ export const app = new Elysia({ prefix: '/api' })
priceHistory,
financials,
filings: redactedFilings.slice(0, 20),
aiReports
aiReports,
coverage: watchlistItem
? {
...watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics
}
});
}, {

View File

@@ -10,6 +10,7 @@ import {
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
import type { WorkflowRunStatus } from '@workflow/world';
const TEST_USER_ID = 'e2e-user';
@@ -21,7 +22,7 @@ let runCounter = 0;
let workflowBackendHealthy = true;
let tempDir: string | null = null;
let sqliteClient: { exec: (query: string) => void; close: () => void } | null = null;
let sqliteClient: Database | null = null;
let app: { handle: (request: Request) => Promise<Response> } | null = null;
mock.module('workflow/api', () => ({
@@ -87,7 +88,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
'0002_workflow_task_projection_metadata.sql',
'0003_task_stage_event_timeline.sql',
'0004_watchlist_company_taxonomy.sql',
'0005_financial_taxonomy_v3.sql'
'0005_financial_taxonomy_v3.sql',
'0006_coverage_journal_tracking.sql'
];
for (const file of migrationFiles) {
@@ -121,10 +123,72 @@ function ensureTestUser(client: { exec: (query: string) => void }) {
function clearProjectionTables(client: { exec: (query: string) => void }) {
client.exec('DELETE FROM task_stage_event;');
client.exec('DELETE FROM task_run;');
client.exec('DELETE FROM research_journal_entry;');
client.exec('DELETE FROM holding;');
client.exec('DELETE FROM watchlist_item;');
client.exec('DELETE FROM portfolio_insight;');
client.exec('DELETE FROM filing;');
}
function seedFilingRecord(client: Database, input: {
ticker: string;
accessionNumber: string;
filingType: '10-K' | '10-Q' | '8-K';
filingDate: string;
companyName: string;
cik?: string;
metrics?: {
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
} | null;
analysisText?: string | null;
}) {
const now = new Date().toISOString();
client.query(`
INSERT INTO filing (
ticker,
filing_type,
filing_date,
accession_number,
cik,
company_name,
filing_url,
submission_url,
primary_document,
metrics,
analysis,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
`).run(
input.ticker,
input.filingType,
input.filingDate,
input.accessionNumber,
input.cik ?? '0000000000',
input.companyName,
`https://www.sec.gov/Archives/${input.accessionNumber}.htm`,
`https://www.sec.gov/submissions/${input.accessionNumber}.json`,
`${input.accessionNumber}.htm`,
input.metrics ? JSON.stringify(input.metrics) : null,
input.analysisText
? JSON.stringify({
provider: 'test',
model: 'fixture',
text: input.analysisText
})
: null,
now,
now
);
}
async function jsonRequest(
method: 'GET' | 'POST' | 'PATCH',
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
body?: Record<string, unknown>
) {
@@ -154,8 +218,8 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
resetDbSingletons();
const dbModule = await import('@/lib/server/db');
sqliteClient = dbModule.getSqliteClient();
sqliteClient = new Database(join(tempDir, 'e2e.sqlite'), { create: true });
sqliteClient.exec('PRAGMA foreign_keys = ON;');
applySqlMigrations(sqliteClient);
ensureTestUser(sqliteClient);
@@ -164,6 +228,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
});
afterAll(() => {
sqliteClient?.close();
resetDbSingletons();
if (tempDir) {
@@ -291,6 +356,199 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
expect(task.payload.tags).toEqual(['semis', 'ai']);
});
it('updates coverage status and archives while appending status-change journal history', async () => {
const created = await jsonRequest('POST', '/api/watchlist', {
ticker: 'amd',
companyName: 'Advanced Micro Devices, Inc.',
sector: 'Technology',
status: 'backlog',
priority: 'medium',
tags: ['semis']
});
expect(created.response.status).toBe(200);
const createdItem = (created.json as {
item: { id: number; ticker: string; status: string; priority: string };
}).item;
expect(createdItem.status).toBe('backlog');
expect(createdItem.priority).toBe('medium');
const activated = await jsonRequest('PATCH', `/api/watchlist/${createdItem.id}`, {
status: 'active',
priority: 'high',
lastReviewedAt: '2026-03-01T15:30:00.000Z'
});
expect(activated.response.status).toBe(200);
const activatedBody = activated.json as {
item: { status: string; priority: string; last_reviewed_at: string | null };
statusChangeJournalCreated: boolean;
};
expect(activatedBody.item.status).toBe('active');
expect(activatedBody.item.priority).toBe('high');
expect(activatedBody.item.last_reviewed_at).toBe('2026-03-01T15:30:00.000Z');
expect(activatedBody.statusChangeJournalCreated).toBe(true);
const archived = await jsonRequest('PATCH', `/api/watchlist/${createdItem.id}`, {
status: 'archive'
});
expect(archived.response.status).toBe(200);
expect((archived.json as {
item: { status: string };
statusChangeJournalCreated: boolean;
}).item.status).toBe('archive');
const journal = await jsonRequest('GET', '/api/research/journal?ticker=AMD');
expect(journal.response.status).toBe(200);
const entries = (journal.json as {
entries: Array<{
entry_type: string;
title: string | null;
}>;
}).entries;
expect(entries.length).toBe(2);
expect(entries.every((entry) => entry.entry_type === 'status_change')).toBe(true);
expect(entries[0]?.title).toContain('Archive');
const coverage = await jsonRequest('GET', '/api/watchlist');
const saved = (coverage.json as {
items: Array<{
ticker: string;
status: string;
priority: string;
}>;
}).items.find((item) => item.ticker === 'AMD');
expect(saved?.status).toBe('archive');
expect(saved?.priority).toBe('high');
});
it('supports journal CRUD and includes coverage, preview, reports, and key metrics in analysis payload', async () => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
seedFilingRecord(sqliteClient, {
ticker: 'NFLX',
accessionNumber: '0000000000-26-000777',
filingType: '10-K',
filingDate: '2026-02-15',
companyName: 'Netflix, Inc.',
metrics: {
revenue: 41000000000,
netIncome: 8600000000,
totalAssets: 52000000000,
cash: 7800000000,
debt: 14000000000
},
analysisText: 'Subscriber growth reaccelerated with improved operating leverage.'
});
await jsonRequest('POST', '/api/watchlist', {
ticker: 'nflx',
companyName: 'Netflix, Inc.',
sector: 'Communication Services',
status: 'active',
priority: 'high',
tags: ['streaming', 'quality']
});
await jsonRequest('POST', '/api/portfolio/holdings', {
ticker: 'NFLX',
companyName: 'Netflix, Inc.',
shares: 12,
avgCost: 440,
currentPrice: 455
});
const createdEntry = await jsonRequest('POST', '/api/research/journal', {
ticker: 'NFLX',
entryType: 'note',
title: 'Thesis refresh',
bodyMarkdown: 'Monitor ad-tier margin progression and content amortization.'
});
expect(createdEntry.response.status).toBe(200);
const entryId = (createdEntry.json as {
entry: { id: number };
}).entry.id;
const analysis = await jsonRequest('GET', '/api/analysis/company?ticker=NFLX');
expect(analysis.response.status).toBe(200);
const payload = (analysis.json as {
analysis: {
coverage: { status: string; priority: string; tags: string[] } | null;
journalPreview: Array<{ title: string | null; body_markdown: string }>;
recentAiReports: Array<{ accessionNumber: string; summary: string }>;
latestFilingSummary: { accessionNumber: string; summary: string | null } | null;
keyMetrics: { revenue: number | null; netMargin: number | null };
position: { company_name: string | null } | null;
};
}).analysis;
expect(payload.coverage?.status).toBe('active');
expect(payload.coverage?.priority).toBe('high');
expect(payload.coverage?.tags).toEqual(['streaming', 'quality']);
expect(payload.journalPreview.length).toBe(1);
expect(payload.journalPreview[0]?.title).toBe('Thesis refresh');
expect(payload.recentAiReports.length).toBe(1);
expect(payload.latestFilingSummary?.accessionNumber).toBe('0000000000-26-000777');
expect(payload.latestFilingSummary?.summary).toContain('Subscriber growth reaccelerated');
expect(payload.keyMetrics.revenue).toBe(41000000000);
expect(payload.keyMetrics.netMargin).not.toBeNull();
expect(payload.position?.company_name).toBe('Netflix, Inc.');
const updatedEntry = await jsonRequest('PATCH', `/api/research/journal/${entryId}`, {
title: 'Thesis refresh v2',
bodyMarkdown: 'Monitor ad-tier margin progression, churn, and cash content spend.'
});
expect(updatedEntry.response.status).toBe(200);
expect((updatedEntry.json as {
entry: { title: string | null; body_markdown: string };
}).entry.title).toBe('Thesis refresh v2');
const journalAfterUpdate = await jsonRequest('GET', '/api/research/journal?ticker=NFLX');
expect(journalAfterUpdate.response.status).toBe(200);
expect((journalAfterUpdate.json as {
entries: Array<{ title: string | null; body_markdown: string }>;
}).entries[0]?.body_markdown).toContain('cash content spend');
const removed = await jsonRequest('DELETE', `/api/research/journal/${entryId}`);
expect(removed.response.status).toBe(200);
const journalAfterDelete = await jsonRequest('GET', '/api/research/journal?ticker=NFLX');
expect((journalAfterDelete.json as {
entries: unknown[];
}).entries).toHaveLength(0);
});
it('persists nullable holding company names and allows later enrichment', async () => {
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
ticker: 'ORCL',
shares: 5,
avgCost: 100,
currentPrice: 110
});
expect(created.response.status).toBe(200);
const holdings = await jsonRequest('GET', '/api/portfolio/holdings');
expect(holdings.response.status).toBe(200);
const saved = (holdings.json as {
holdings: Array<{
id: number;
ticker: string;
company_name: string | null;
}>;
}).holdings.find((entry) => entry.ticker === 'ORCL');
expect(saved?.company_name).toBeNull();
const updated = await jsonRequest('PATCH', `/api/portfolio/holdings/${saved?.id}`, {
companyName: 'Oracle Corporation'
});
expect(updated.response.status).toBe(200);
expect((updated.json as {
holding: { company_name: string | null };
}).holding.company_name).toBe('Oracle Corporation');
});
it('updates notification read and silenced state via patch endpoint', async () => {
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000010/analyze');
const taskId = (created.json as { task: { id: string } }).task.id;

View File

@@ -20,14 +20,23 @@ describe('sqlite schema compatibility bootstrap', () => {
applyMigration(client, '0003_task_stage_event_timeline.sql');
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(false);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'status')).toBe(false);
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(false);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
__dbInternals.ensureLocalSqliteSchema(client);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'tags')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'status')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'priority')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'updated_at')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'last_reviewed_at')).toBe(true);
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(true);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
client.close();
});

View File

@@ -78,7 +78,11 @@ function ensureLocalSqliteSchema(client: Database) {
if (hasTable(client, 'watchlist_item')) {
const missingWatchlistColumns: Array<{ name: string; sql: string }> = [
{ name: 'category', sql: 'ALTER TABLE `watchlist_item` ADD `category` text;' },
{ name: 'tags', sql: 'ALTER TABLE `watchlist_item` ADD `tags` text;' }
{ name: 'tags', sql: 'ALTER TABLE `watchlist_item` ADD `tags` text;' },
{ name: 'status', sql: "ALTER TABLE `watchlist_item` ADD `status` text NOT NULL DEFAULT 'backlog';" },
{ name: 'priority', sql: "ALTER TABLE `watchlist_item` ADD `priority` text NOT NULL DEFAULT 'medium';" },
{ name: 'updated_at', sql: "ALTER TABLE `watchlist_item` ADD `updated_at` text NOT NULL DEFAULT '';" },
{ name: 'last_reviewed_at', sql: 'ALTER TABLE `watchlist_item` ADD `last_reviewed_at` text;' }
];
for (const column of missingWatchlistColumns) {
@@ -86,11 +90,54 @@ function ensureLocalSqliteSchema(client: Database) {
client.exec(column.sql);
}
}
client.exec(`
UPDATE \`watchlist_item\`
SET
\`status\` = CASE
WHEN \`status\` IS NULL OR TRIM(\`status\`) = '' THEN 'backlog'
ELSE \`status\`
END,
\`priority\` = CASE
WHEN \`priority\` IS NULL OR TRIM(\`priority\`) = '' THEN 'medium'
ELSE \`priority\`
END,
\`updated_at\` = CASE
WHEN \`updated_at\` IS NULL OR TRIM(\`updated_at\`) = '' THEN COALESCE(NULLIF(\`created_at\`, ''), CURRENT_TIMESTAMP)
ELSE \`updated_at\`
END;
`);
client.exec('CREATE INDEX IF NOT EXISTS `watchlist_user_updated_idx` ON `watchlist_item` (`user_id`, `updated_at`);');
}
if (hasTable(client, 'holding') && !hasColumn(client, 'holding', 'company_name')) {
client.exec('ALTER TABLE `holding` ADD `company_name` text;');
}
if (!hasTable(client, 'filing_taxonomy_snapshot')) {
applySqlFile(client, '0005_financial_taxonomy_v3.sql');
}
if (!hasTable(client, 'research_journal_entry')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`ticker\` text NOT NULL,
\`accession_number\` text,
\`entry_type\` text NOT NULL,
\`title\` text,
\`body_markdown\` text NOT NULL,
\`metadata\` text,
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
}
}
export function getSqliteClient() {

View File

@@ -27,6 +27,9 @@ type TaxonomyAssetType =
type TaxonomyParseStatus = 'ready' | 'partial' | 'failed';
type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'error';
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
type CoveragePriority = 'low' | 'medium' | 'high';
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
type FilingAnalysis = {
provider?: string;
@@ -269,16 +272,22 @@ export const watchlistItem = sqliteTable('watchlist_item', {
sector: text('sector'),
category: text('category'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
created_at: text('created_at').notNull()
status: text('status').$type<CoverageStatus>().notNull().default('backlog'),
priority: text('priority').$type<CoveragePriority>().notNull().default('medium'),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull(),
last_reviewed_at: text('last_reviewed_at')
}, (table) => ({
watchlistUserTickerUnique: uniqueIndex('watchlist_user_ticker_uidx').on(table.user_id, table.ticker),
watchlistUserCreatedIndex: index('watchlist_user_created_idx').on(table.user_id, table.created_at)
watchlistUserCreatedIndex: index('watchlist_user_created_idx').on(table.user_id, table.created_at),
watchlistUserUpdatedIndex: index('watchlist_user_updated_idx').on(table.user_id, table.updated_at)
}));
export const holding = sqliteTable('holding', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
company_name: text('company_name'),
shares: numeric('shares').notNull(),
avg_cost: numeric('avg_cost').notNull(),
current_price: numeric('current_price'),
@@ -520,6 +529,22 @@ export const portfolioInsight = sqliteTable('portfolio_insight', {
insightUserCreatedIndex: index('insight_user_created_idx').on(table.user_id, table.created_at)
}));
export const researchJournalEntry = sqliteTable('research_journal_entry', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
accession_number: text('accession_number'),
entry_type: text('entry_type').$type<ResearchJournalEntryType>().notNull(),
title: text('title'),
body_markdown: text('body_markdown').notNull(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchJournalTickerIndex: index('research_journal_ticker_idx').on(table.user_id, table.ticker, table.created_at),
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
}));
export const authSchema = {
user,
session,
@@ -543,7 +568,8 @@ export const appSchema = {
filingLink,
taskRun,
taskStageEvent,
portfolioInsight
portfolioInsight,
researchJournalEntry
};
export const schema = {

View File

@@ -707,6 +707,85 @@ function latestMetrics(snapshots: FilingTaxonomySnapshotRecord[]) {
};
}
function rowValue(row: { values: Record<string, number | null> }, periodId: string) {
return periodId in row.values ? row.values[periodId] : null;
}
function findStandardizedRow(rows: StandardizedStatementRow[], key: string) {
return rows.find((row) => row.key === key) ?? null;
}
function findFaithfulRowByLocalNames(rows: TaxonomyStatementRow[], localNames: string[]) {
const normalizedNames = localNames.map((name) => name.toLowerCase());
const exact = rows.find((row) => normalizedNames.includes(row.localName.toLowerCase()));
if (exact) {
return exact;
}
return rows.find((row) => {
const haystack = `${row.key} ${row.label} ${row.localName} ${row.qname}`.toLowerCase();
return normalizedNames.some((name) => haystack.includes(name));
}) ?? null;
}
function asOverviewLabel(period: FinancialStatementPeriod) {
const source = period.periodEnd ?? period.filingDate;
const parsed = Date.parse(source);
if (!Number.isFinite(parsed)) {
return source;
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
year: 'numeric'
}).format(new Date(parsed));
}
function buildOverviewMetrics(input: {
periods: FinancialStatementPeriod[];
faithfulRows: TaxonomyStatementRow[];
standardizedRows: StandardizedStatementRow[];
}): CompanyFinancialStatementsResponse['overviewMetrics'] {
const periods = [...input.periods].sort(periodSorter);
const revenueRow = findStandardizedRow(input.standardizedRows, 'revenue')
?? findFaithfulRowByLocalNames(input.faithfulRows, ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'Revenue']);
const netIncomeRow = findStandardizedRow(input.standardizedRows, 'net-income')
?? findFaithfulRowByLocalNames(input.faithfulRows, ['NetIncomeLoss', 'ProfitLoss']);
const assetsRow = findStandardizedRow(input.standardizedRows, 'total-assets')
?? findFaithfulRowByLocalNames(input.faithfulRows, ['Assets']);
const cashRow = findStandardizedRow(input.standardizedRows, 'cash-and-equivalents')
?? findFaithfulRowByLocalNames(input.faithfulRows, ['CashAndCashEquivalentsAtCarryingValue', 'CashAndShortTermInvestments', 'Cash']);
const debtRow = findStandardizedRow(input.standardizedRows, 'total-debt')
?? findFaithfulRowByLocalNames(input.faithfulRows, ['LongTermDebt', 'Debt', 'LongTermDebtNoncurrent']);
const series = periods.map((period) => ({
periodId: period.id,
filingDate: period.filingDate,
periodEnd: period.periodEnd,
label: asOverviewLabel(period),
revenue: revenueRow ? rowValue(revenueRow, period.id) : null,
netIncome: netIncomeRow ? rowValue(netIncomeRow, period.id) : null,
totalAssets: assetsRow ? rowValue(assetsRow, period.id) : null,
cash: cashRow ? rowValue(cashRow, period.id) : null,
debt: debtRow ? rowValue(debtRow, period.id) : null
}));
const latest = series[series.length - 1] ?? null;
return {
referencePeriodId: latest?.periodId ?? null,
referenceDate: latest?.filingDate ?? null,
latest: {
revenue: latest?.revenue ?? null,
netIncome: latest?.netIncome ?? null,
totalAssets: latest?.totalAssets ?? null,
cash: latest?.cash ?? null,
debt: latest?.debt ?? null
},
series
};
}
export function defaultFinancialSyncLimit(window: FinancialHistoryWindow) {
return window === 'all' ? 120 : 60;
}
@@ -806,6 +885,11 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
pendingFilings: Math.max(0, financialFilings.length - statuses.ready - statuses.partial - statuses.failed),
queuedSync: input.queuedSync
},
overviewMetrics: buildOverviewMetrics({
periods,
faithfulRows,
standardizedRows
}),
metrics,
dimensionBreakdown
};

View File

@@ -1,4 +1,4 @@
import { desc, eq } from 'drizzle-orm';
import { desc, eq, inArray, max } from 'drizzle-orm';
import type { Filing } from '@/lib/types';
import { db } from '@/lib/server/db';
import { filing, filingLink } from '@/lib/server/db/schema';
@@ -87,6 +87,35 @@ export async function getFilingByAccession(accessionNumber: string) {
return row ? toFiling(row) : null;
}
export async function listLatestFilingDatesByTickers(tickers: string[]) {
const normalizedTickers = [...new Set(
tickers
.map((ticker) => ticker.trim().toUpperCase())
.filter((ticker) => ticker.length > 0)
)];
if (normalizedTickers.length === 0) {
return new Map<string, string>();
}
const rows = await db
.select({
ticker: filing.ticker,
latest_filing_date: max(filing.filing_date)
})
.from(filing)
.where(inArray(filing.ticker, normalizedTickers))
.groupBy(filing.ticker);
return new Map(
rows
.filter((row): row is { ticker: string; latest_filing_date: string } => {
return typeof row.ticker === 'string' && typeof row.latest_filing_date === 'string';
})
.map((row) => [row.ticker, row.latest_filing_date])
);
}
export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
let inserted = 0;
let updated = 0;

View File

@@ -1,8 +1,8 @@
import { and, eq } from 'drizzle-orm';
import { and, desc, eq } from 'drizzle-orm';
import type { Holding } from '@/lib/types';
import { recalculateHolding } from '@/lib/server/portfolio';
import { db } from '@/lib/server/db';
import { holding } from '@/lib/server/db/schema';
import { filing, holding, watchlistItem } from '@/lib/server/db/schema';
type HoldingRow = typeof holding.$inferSelect;
@@ -11,6 +11,7 @@ function toHolding(row: HoldingRow): Holding {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
company_name: row.company_name ?? null,
shares: row.shares,
avg_cost: row.avg_cost,
current_price: row.current_price,
@@ -36,6 +37,41 @@ function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost:
};
}
async function resolveHoldingCompanyName(input: {
userId: string;
ticker: string;
companyName?: string;
existingCompanyName?: string | null;
}) {
const explicitCompanyName = input.companyName?.trim();
if (explicitCompanyName) {
return explicitCompanyName;
}
if (input.existingCompanyName?.trim()) {
return input.existingCompanyName.trim();
}
const [watchlistMatch] = await db
.select({ company_name: watchlistItem.company_name })
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, input.ticker)))
.limit(1);
if (watchlistMatch?.company_name?.trim()) {
return watchlistMatch.company_name.trim();
}
const [filingMatch] = await db
.select({ company_name: filing.company_name })
.from(filing)
.where(eq(filing.ticker, input.ticker))
.orderBy(desc(filing.filing_date), desc(filing.updated_at))
.limit(1);
return filingMatch?.company_name?.trim() ? filingMatch.company_name.trim() : null;
}
export async function listUserHoldings(userId: string) {
const rows = await db
.select()
@@ -45,12 +81,28 @@ export async function listUserHoldings(userId: string) {
return sortByMarketValueDesc(rows.map(toHolding));
}
export async function getHoldingByTicker(userId: string, ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [row] = await db
.select()
.from(holding)
.where(and(eq(holding.user_id, userId), eq(holding.ticker, normalizedTicker)))
.limit(1);
return row ? toHolding(row) : null;
}
export async function upsertHoldingRecord(input: {
userId: string;
ticker: string;
shares: number;
avgCost: number;
currentPrice?: number;
companyName?: string;
}) {
const ticker = input.ticker.trim().toUpperCase();
const now = new Date().toISOString();
@@ -64,6 +116,12 @@ export async function upsertHoldingRecord(input: {
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: input.avgCost;
const companyName = await resolveHoldingCompanyName({
userId: input.userId,
ticker,
companyName: input.companyName,
existingCompanyName: existing?.company_name ?? null
});
if (existing) {
const normalized = normalizeHoldingInput({
@@ -84,6 +142,7 @@ export async function upsertHoldingRecord(input: {
.update(holding)
.set({
ticker: next.ticker,
company_name: companyName,
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,
@@ -113,6 +172,7 @@ export async function upsertHoldingRecord(input: {
id: 0,
user_id: input.userId,
ticker: normalized.ticker,
company_name: companyName,
shares: normalized.shares,
avg_cost: normalized.avg_cost,
current_price: normalized.current_price,
@@ -131,6 +191,7 @@ export async function upsertHoldingRecord(input: {
.values({
user_id: created.user_id,
ticker: created.ticker,
company_name: created.company_name,
shares: created.shares,
avg_cost: created.avg_cost,
current_price: created.current_price,
@@ -155,6 +216,7 @@ export async function updateHoldingByIdRecord(input: {
shares?: number;
avgCost?: number;
currentPrice?: number;
companyName?: string;
}) {
const [existing] = await db
.select()
@@ -176,9 +238,16 @@ export async function updateHoldingByIdRecord(input: {
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: Number(current.current_price ?? current.avg_cost);
const companyName = await resolveHoldingCompanyName({
userId: input.userId,
ticker: current.ticker,
companyName: input.companyName,
existingCompanyName: current.company_name
});
const next = recalculateHolding({
...current,
company_name: companyName,
shares: shares.toFixed(6),
avg_cost: avgCost.toFixed(6),
current_price: currentPrice.toFixed(6),
@@ -189,6 +258,7 @@ export async function updateHoldingByIdRecord(input: {
const [updated] = await db
.update(holding)
.set({
company_name: companyName,
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,

View File

@@ -0,0 +1,148 @@
import { and, desc, eq } from 'drizzle-orm';
import type {
ResearchJournalEntry,
ResearchJournalEntryType
} from '@/lib/types';
import { db } from '@/lib/server/db';
import { researchJournalEntry } from '@/lib/server/db/schema';
type ResearchJournalRow = typeof researchJournalEntry.$inferSelect;
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function normalizeTitle(title?: string | null) {
const normalized = title?.trim();
return normalized ? normalized : null;
}
function normalizeAccessionNumber(accessionNumber?: string | null) {
const normalized = accessionNumber?.trim();
return normalized ? normalized : null;
}
function normalizeMetadata(metadata?: Record<string, unknown> | null) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
return null;
}
return metadata;
}
function toResearchJournalEntry(row: ResearchJournalRow): ResearchJournalEntry {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
accession_number: row.accession_number ?? null,
entry_type: row.entry_type,
title: row.title ?? null,
body_markdown: row.body_markdown,
metadata: row.metadata ?? null,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function listResearchJournalEntries(userId: string, ticker: string, limit = 100) {
const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) {
return [];
}
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 250);
const rows = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.ticker, normalizedTicker)))
.orderBy(desc(researchJournalEntry.created_at), desc(researchJournalEntry.id))
.limit(safeLimit);
return rows.map(toResearchJournalEntry);
}
export async function createResearchJournalEntryRecord(input: {
userId: string;
ticker: string;
accessionNumber?: string | null;
entryType: ResearchJournalEntryType;
title?: string | null;
bodyMarkdown: string;
metadata?: Record<string, unknown> | null;
}) {
const ticker = normalizeTicker(input.ticker);
const bodyMarkdown = input.bodyMarkdown.trim();
if (!ticker) {
throw new Error('ticker is required');
}
if (!bodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const now = new Date().toISOString();
const [created] = await db
.insert(researchJournalEntry)
.values({
user_id: input.userId,
ticker,
accession_number: normalizeAccessionNumber(input.accessionNumber),
entry_type: input.entryType,
title: normalizeTitle(input.title),
body_markdown: bodyMarkdown,
metadata: normalizeMetadata(input.metadata),
created_at: now,
updated_at: now
})
.returning();
return toResearchJournalEntry(created);
}
export async function updateResearchJournalEntryRecord(input: {
userId: string;
id: number;
title?: string | null;
bodyMarkdown?: string;
metadata?: Record<string, unknown> | null;
}) {
const [existing] = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.limit(1);
if (!existing) {
return null;
}
const nextBodyMarkdown = input.bodyMarkdown === undefined
? existing.body_markdown
: input.bodyMarkdown.trim();
if (!nextBodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const [updated] = await db
.update(researchJournalEntry)
.set({
title: input.title === undefined ? existing.title : normalizeTitle(input.title),
body_markdown: nextBodyMarkdown,
metadata: input.metadata === undefined ? existing.metadata ?? null : normalizeMetadata(input.metadata),
updated_at: new Date().toISOString()
})
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.returning();
return updated ? toResearchJournalEntry(updated) : null;
}
export async function deleteResearchJournalEntryRecord(userId: string, id: number) {
const rows = await db
.delete(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
.returning({ id: researchJournalEntry.id });
return rows.length > 0;
}

View File

@@ -1,9 +1,15 @@
import { and, desc, eq } from 'drizzle-orm';
import type { WatchlistItem } from '@/lib/types';
import type {
CoveragePriority,
CoverageStatus,
WatchlistItem
} from '@/lib/types';
import { db } from '@/lib/server/db';
import { watchlistItem } from '@/lib/server/db/schema';
type WatchlistRow = typeof watchlistItem.$inferSelect;
const DEFAULT_STATUS: CoverageStatus = 'backlog';
const DEFAULT_PRIORITY: CoveragePriority = 'medium';
function normalizeTags(tags?: string[]) {
if (!Array.isArray(tags)) {
@@ -32,7 +38,7 @@ function normalizeTags(tags?: string[]) {
return [...unique];
}
function toWatchlistItem(row: WatchlistRow): WatchlistItem {
function toWatchlistItem(row: WatchlistRow, latestFilingDate: string | null = null): WatchlistItem {
return {
id: row.id,
user_id: row.user_id,
@@ -43,7 +49,12 @@ function toWatchlistItem(row: WatchlistRow): WatchlistItem {
tags: Array.isArray(row.tags)
? row.tags.filter((entry): entry is string => typeof entry === 'string')
: [],
created_at: row.created_at
created_at: row.created_at,
status: row.status ?? DEFAULT_STATUS,
priority: row.priority ?? DEFAULT_PRIORITY,
updated_at: row.updated_at || row.created_at,
last_reviewed_at: row.last_reviewed_at ?? null,
latest_filing_date: latestFilingDate
};
}
@@ -52,9 +63,19 @@ export async function listWatchlistItems(userId: string) {
.select()
.from(watchlistItem)
.where(eq(watchlistItem.user_id, userId))
.orderBy(desc(watchlistItem.created_at));
.orderBy(desc(watchlistItem.updated_at), desc(watchlistItem.created_at));
return rows.map(toWatchlistItem);
return rows.map((row) => toWatchlistItem(row));
}
export async function getWatchlistItemById(userId: string, id: number) {
const [row] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.id, id)))
.limit(1);
return row ? toWatchlistItem(row) : null;
}
export async function getWatchlistItemByTicker(userId: string, ticker: string) {
@@ -79,11 +100,18 @@ export async function upsertWatchlistItemRecord(input: {
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string | null;
}) {
const normalizedTicker = input.ticker.trim().toUpperCase();
const normalizedSector = input.sector?.trim() ? input.sector.trim() : null;
const normalizedCategory = input.category?.trim() ? input.category.trim() : null;
const normalizedTags = normalizeTags(input.tags);
const normalizedCompanyName = input.companyName.trim();
const normalizedLastReviewedAt = input.lastReviewedAt?.trim()
? input.lastReviewedAt.trim()
: null;
const now = new Date().toISOString();
const [inserted] = await db
@@ -91,11 +119,15 @@ export async function upsertWatchlistItemRecord(input: {
.values({
user_id: input.userId,
ticker: normalizedTicker,
company_name: input.companyName,
company_name: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
tags: normalizedTags,
created_at: now
status: input.status ?? DEFAULT_STATUS,
priority: input.priority ?? DEFAULT_PRIORITY,
created_at: now,
updated_at: now,
last_reviewed_at: normalizedLastReviewedAt
})
.onConflictDoNothing({
target: [watchlistItem.user_id, watchlistItem.ticker],
@@ -109,13 +141,23 @@ export async function upsertWatchlistItemRecord(input: {
};
}
const [existing] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
.limit(1);
const [updated] = await db
.update(watchlistItem)
.set({
company_name: input.companyName,
company_name: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
tags: normalizedTags
tags: normalizedTags,
status: input.status ?? existing?.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing?.priority ?? DEFAULT_PRIORITY,
updated_at: now,
last_reviewed_at: normalizedLastReviewedAt ?? existing?.last_reviewed_at ?? null
})
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
.returning();
@@ -130,6 +172,93 @@ export async function upsertWatchlistItemRecord(input: {
};
}
export async function updateWatchlistItemRecord(input: {
userId: string;
id: number;
companyName?: string;
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string | null;
}) {
const [existing] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
.limit(1);
if (!existing) {
return null;
}
const nextCompanyName = input.companyName === undefined
? existing.company_name
: input.companyName.trim();
if (!nextCompanyName) {
throw new Error('companyName is required');
}
const nextSector = input.sector === undefined
? existing.sector
: input.sector.trim()
? input.sector.trim()
: null;
const nextCategory = input.category === undefined
? existing.category
: input.category.trim()
? input.category.trim()
: null;
const nextTags = input.tags === undefined
? existing.tags ?? null
: normalizeTags(input.tags);
const nextLastReviewedAt = input.lastReviewedAt === undefined
? existing.last_reviewed_at
: input.lastReviewedAt?.trim()
? input.lastReviewedAt.trim()
: null;
const [updated] = await db
.update(watchlistItem)
.set({
company_name: nextCompanyName,
sector: nextSector,
category: nextCategory,
tags: nextTags,
status: input.status ?? existing.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing.priority ?? DEFAULT_PRIORITY,
updated_at: new Date().toISOString(),
last_reviewed_at: nextLastReviewedAt
})
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
.returning();
return updated ? toWatchlistItem(updated) : null;
}
export async function updateWatchlistReviewByTicker(
userId: string,
ticker: string,
reviewedAt: string
) {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [updated] = await db
.update(watchlistItem)
.set({
last_reviewed_at: reviewedAt,
updated_at: reviewedAt
})
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.ticker, normalizedTicker)))
.returning();
return updated ? toWatchlistItem(updated) : null;
}
export async function deleteWatchlistItemRecord(userId: string, id: number) {
const removed = await db
.delete(watchlistItem)

View File

@@ -5,6 +5,10 @@ export type User = {
image: string | null;
};
export type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
export type CoveragePriority = 'low' | 'medium' | 'high';
export type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
export type WatchlistItem = {
id: number;
user_id: string;
@@ -14,12 +18,18 @@ export type WatchlistItem = {
category: string | null;
tags: string[];
created_at: string;
status: CoverageStatus;
priority: CoveragePriority;
updated_at: string;
last_reviewed_at: string | null;
latest_filing_date: string | null;
};
export type Holding = {
id: number;
user_id: string;
ticker: string;
company_name: string | null;
shares: string;
avg_cost: string;
current_price: string | null;
@@ -165,6 +175,19 @@ export type PortfolioInsight = {
created_at: string;
};
export type ResearchJournalEntry = {
id: number;
user_id: string;
ticker: string;
accession_number: string | null;
entry_type: ResearchJournalEntryType;
title: string | null;
body_markdown: string;
metadata: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
export type CompanyFinancialPoint = {
filingDate: string;
filingType: Filing['filing_type'];
@@ -332,6 +355,28 @@ export type CompanyFinancialStatementsResponse = {
pendingFilings: number;
queuedSync: boolean;
};
overviewMetrics: {
referencePeriodId: string | null;
referenceDate: string | null;
latest: {
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
};
series: Array<{
periodId: string;
filingDate: string;
periodEnd: string | null;
label: string;
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
}>;
};
metrics: {
taxonomy: Filing['metrics'];
validation: MetricValidationResult | null;
@@ -371,6 +416,27 @@ export type CompanyAnalysis = {
financials: CompanyFinancialPoint[];
filings: Filing[];
aiReports: CompanyAiReport[];
coverage: WatchlistItem | null;
journalPreview: ResearchJournalEntry[];
recentAiReports: CompanyAiReport[];
latestFilingSummary: {
accessionNumber: string;
filingDate: string;
filingType: Filing['filing_type'];
filingUrl: string | null;
submissionUrl: string | null;
summary: string | null;
hasAnalysis: boolean;
} | null;
keyMetrics: {
referenceDate: string | null;
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
netMargin: number | null;
};
};
export type NavGroup = 'overview' | 'research' | 'portfolio';

View File

@@ -8,7 +8,8 @@ const MIGRATION_FILES = [
'0002_workflow_task_projection_metadata.sql',
'0003_task_stage_event_timeline.sql',
'0004_watchlist_company_taxonomy.sql',
'0005_financial_taxonomy_v3.sql'
'0005_financial_taxonomy_v3.sql',
'0006_coverage_journal_tracking.sql'
] as const;
export const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');