Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
This commit is contained in:
@@ -13,7 +13,15 @@ import {
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import { BrainCircuit, ChartNoAxesCombined, RefreshCcw, Search } from 'lucide-react';
|
||||
import {
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
NotebookPen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SquarePen,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +29,11 @@ import { Input } from '@/components/ui/input';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
deleteResearchJournalEntry,
|
||||
updateResearchJournalEntry
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
asNumber,
|
||||
formatCurrency,
|
||||
@@ -29,8 +42,14 @@ import {
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import { companyAnalysisQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
researchJournalQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
CompanyAnalysis,
|
||||
ResearchJournalEntry
|
||||
} from '@/lib/types';
|
||||
|
||||
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
||||
|
||||
@@ -44,6 +63,18 @@ type FinancialSeriesPoint = {
|
||||
netMargin: number | null;
|
||||
};
|
||||
|
||||
type JournalFormState = {
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
accessionNumber: string;
|
||||
};
|
||||
|
||||
const EMPTY_JOURNAL_FORM: JournalFormState = {
|
||||
title: '',
|
||||
bodyMarkdown: '',
|
||||
accessionNumber: ''
|
||||
};
|
||||
|
||||
const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [
|
||||
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
||||
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
||||
@@ -70,6 +101,10 @@ function formatLongDate(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
if (numerator === null || denominator === null || denominator === 0) {
|
||||
return null;
|
||||
@@ -78,6 +113,11 @@ function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
return (numerator / denominator) * 100;
|
||||
}
|
||||
|
||||
function normalizeTickerInput(value: string | null) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isFinancialSnapshotForm(
|
||||
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
||||
): filingType is '10-K' | '10-Q' {
|
||||
@@ -120,22 +160,22 @@ function AnalysisPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
||||
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT';
|
||||
|
||||
const [tickerInput, setTickerInput] = useState('MSFT');
|
||||
const [ticker, setTicker] = useState('MSFT');
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [analysis, setAnalysis] = useState<CompanyAnalysis | null>(null);
|
||||
const [journalEntries, setJournalEntries] = useState<ResearchJournalEntry[]>([]);
|
||||
const [journalLoading, setJournalLoading] = useState(true);
|
||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('ticker');
|
||||
if (!fromQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = fromQuery.trim().toUpperCase();
|
||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +194,7 @@ function AnalysisPageContent() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setAnalysis(response.analysis);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
|
||||
@@ -164,11 +204,32 @@ function AnalysisPageContent() {
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const loadJournal = useCallback(async (symbol: string) => {
|
||||
const options = researchJournalQueryOptions(symbol);
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setJournalLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setJournalEntries(response.entries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research journal');
|
||||
setJournalEntries([]);
|
||||
} finally {
|
||||
setJournalLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadAnalysis(ticker);
|
||||
void Promise.all([
|
||||
loadAnalysis(ticker),
|
||||
loadJournal(ticker)
|
||||
]);
|
||||
}
|
||||
}, [isPending, isAuthenticated, ticker, loadAnalysis]);
|
||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||
@@ -207,6 +268,77 @@ function AnalysisPageContent() {
|
||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
||||
}, [financialValueScale]);
|
||||
|
||||
const resetJournalForm = useCallback(() => {
|
||||
setEditingJournalId(null);
|
||||
setJournalForm(EMPTY_JOURNAL_FORM);
|
||||
}, []);
|
||||
|
||||
const activeTicker = analysis?.company.ticker ?? ticker;
|
||||
|
||||
const saveJournalEntry = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingJournalId === null) {
|
||||
await createResearchJournalEntry({
|
||||
ticker: normalizedTicker,
|
||||
entryType: journalForm.accessionNumber.trim() ? 'filing_note' : 'note',
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown,
|
||||
accessionNumber: journalForm.accessionNumber.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
await updateResearchJournalEntry(editingJournalId, {
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
resetJournalForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
const beginEditJournalEntry = (entry: ResearchJournalEntry) => {
|
||||
setEditingJournalId(entry.id);
|
||||
setJournalForm({
|
||||
title: entry.title ?? '',
|
||||
bodyMarkdown: entry.body_markdown,
|
||||
accessionNumber: entry.accession_number ?? ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeJournalEntry = async (entry: ResearchJournalEntry) => {
|
||||
try {
|
||||
await deleteResearchJournalEntry(entry.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(entry.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(entry.ticker) });
|
||||
await Promise.all([
|
||||
loadAnalysis(entry.ticker),
|
||||
loadJournal(entry.ticker)
|
||||
]);
|
||||
if (editingJournalId === entry.id) {
|
||||
resetJournalForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>;
|
||||
}
|
||||
@@ -220,8 +352,13 @@ function AnalysisPageContent() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker.trim().toUpperCase()) });
|
||||
void loadAnalysis(ticker);
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
@@ -243,6 +380,7 @@ function AnalysisPageContent() {
|
||||
>
|
||||
<Input
|
||||
value={tickerInput}
|
||||
aria-label="Analysis ticker"
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
@@ -252,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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, BrainCircuit, RefreshCcw } from 'lucide-react';
|
||||
import { ArrowLeft, BrainCircuit, NotebookPen, RefreshCcw } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
@@ -14,6 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAiReportDetail } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { createResearchJournalEntry } from '@/lib/api';
|
||||
|
||||
function formatFilingDate(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -44,6 +45,8 @@ export default function AnalysisReportPage() {
|
||||
const [report, setReport] = useState<CompanyAiReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingToJournal, setSavingToJournal] = useState(false);
|
||||
const [journalNotice, setJournalNotice] = useState<string | null>(null);
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
if (!accessionNumber) {
|
||||
@@ -174,6 +177,49 @@ export default function AnalysisReportPage() {
|
||||
<BrainCircuit className="size-3.5" />
|
||||
Full text view
|
||||
</div>
|
||||
{journalNotice ? (
|
||||
<p className="mb-3 text-xs text-[color:var(--accent)]">{journalNotice}</p>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={savingToJournal}
|
||||
onClick={async () => {
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingToJournal(true);
|
||||
setJournalNotice(null);
|
||||
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
ticker: report.ticker,
|
||||
accessionNumber: report.accessionNumber,
|
||||
entryType: 'filing_note',
|
||||
title: `${report.filingType} AI memo`,
|
||||
bodyMarkdown: [
|
||||
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
|
||||
`Accession: ${report.accessionNumber}`,
|
||||
'',
|
||||
report.summary
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setJournalNotice('Saved to the company research journal.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to journal');
|
||||
} finally {
|
||||
setSavingToJournal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
{savingToJournal ? 'Saving...' : 'Add to journal'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{report.summary}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Bot, Download, ExternalLink, Search, TimerReset } from 'lucide-react';
|
||||
import { Bot, Download, ExternalLink, NotebookPen, Search, TimerReset } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
@@ -13,7 +13,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import { queueFilingAnalysis, queueFilingSync } from '@/lib/api';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
queueFilingAnalysis,
|
||||
queueFilingSync
|
||||
} from '@/lib/api';
|
||||
import type { Filing } from '@/lib/types';
|
||||
import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
@@ -131,6 +135,7 @@ function FilingsPageContent() {
|
||||
const [filterTickerInput, setFilterTickerInput] = useState('');
|
||||
const [searchTicker, setSearchTicker] = useState('');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ticker = searchParams.get('ticker');
|
||||
@@ -152,7 +157,7 @@ function FilingsPageContent() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setFilings(response.filings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to fetch filings');
|
||||
@@ -197,6 +202,30 @@ function FilingsPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const addToJournal = async (filing: Filing) => {
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
ticker: filing.ticker,
|
||||
accessionNumber: filing.accession_number,
|
||||
entryType: 'filing_note',
|
||||
title: `${filing.filing_type} filing note`,
|
||||
bodyMarkdown: [
|
||||
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
|
||||
`Filed: ${formatFilingDate(filing.filing_date)}`,
|
||||
`Accession: ${filing.accession_number}`,
|
||||
'',
|
||||
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
|
||||
}
|
||||
};
|
||||
|
||||
const groupedByTicker = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -321,6 +350,7 @@ function FilingsPageContent() {
|
||||
)}
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{actionNotice ? <p className="mt-2 text-sm text-[color:var(--accent)]">{actionNotice}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
|
||||
) : filings.length === 0 ? (
|
||||
@@ -379,6 +409,14 @@ function FilingsPageContent() {
|
||||
<Bot className="size-3" />
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Add to journal
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
||||
@@ -449,6 +487,14 @@ function FilingsPageContent() {
|
||||
<Bot className="size-3" />
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Journal
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
||||
|
||||
@@ -178,6 +178,50 @@ function mergeSurfaceRows<T extends { key: string; values: Record<string, number
|
||||
return [...rowMap.values()];
|
||||
}
|
||||
|
||||
function mergeOverviewMetrics(
|
||||
base: CompanyFinancialStatementsResponse['overviewMetrics'],
|
||||
next: CompanyFinancialStatementsResponse['overviewMetrics']
|
||||
): CompanyFinancialStatementsResponse['overviewMetrics'] {
|
||||
const mergedSeriesMap = new Map<string, CompanyFinancialStatementsResponse['overviewMetrics']['series'][number]>();
|
||||
|
||||
for (const source of [base.series, next.series]) {
|
||||
for (const point of source) {
|
||||
const existing = mergedSeriesMap.get(point.periodId);
|
||||
if (!existing) {
|
||||
mergedSeriesMap.set(point.periodId, { ...point });
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.filingDate = existing.filingDate || point.filingDate;
|
||||
existing.periodEnd = existing.periodEnd ?? point.periodEnd;
|
||||
existing.label = existing.label || point.label;
|
||||
existing.revenue = existing.revenue ?? point.revenue;
|
||||
existing.netIncome = existing.netIncome ?? point.netIncome;
|
||||
existing.totalAssets = existing.totalAssets ?? point.totalAssets;
|
||||
existing.cash = existing.cash ?? point.cash;
|
||||
existing.debt = existing.debt ?? point.debt;
|
||||
}
|
||||
}
|
||||
|
||||
const series = [...mergedSeriesMap.values()].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
const latest = series[series.length - 1] ?? null;
|
||||
|
||||
return {
|
||||
referencePeriodId: next.referencePeriodId ?? base.referencePeriodId ?? latest?.periodId ?? null,
|
||||
referenceDate: next.referenceDate ?? base.referenceDate ?? latest?.filingDate ?? null,
|
||||
latest: {
|
||||
revenue: next.latest.revenue ?? base.latest.revenue ?? latest?.revenue ?? null,
|
||||
netIncome: next.latest.netIncome ?? base.latest.netIncome ?? latest?.netIncome ?? null,
|
||||
totalAssets: next.latest.totalAssets ?? base.latest.totalAssets ?? latest?.totalAssets ?? null,
|
||||
cash: next.latest.cash ?? base.latest.cash ?? latest?.cash ?? null,
|
||||
debt: next.latest.debt ?? base.latest.debt ?? latest?.debt ?? null
|
||||
},
|
||||
series
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFinancialPages(
|
||||
base: CompanyFinancialStatementsResponse | null,
|
||||
next: CompanyFinancialStatementsResponse
|
||||
@@ -317,6 +361,7 @@ function mergeFinancialPages(
|
||||
...next.dataSourceStatus,
|
||||
queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync
|
||||
},
|
||||
overviewMetrics: mergeOverviewMetrics(base.overviewMetrics, next.overviewMetrics),
|
||||
dimensionBreakdown
|
||||
};
|
||||
}
|
||||
@@ -342,6 +387,33 @@ function buildOverviewSeries(
|
||||
incomeData: CompanyFinancialStatementsResponse | null,
|
||||
balanceData: CompanyFinancialStatementsResponse | null
|
||||
): OverviewPoint[] {
|
||||
const overviewSeriesMap = new Map<string, OverviewPoint>();
|
||||
|
||||
for (const source of [incomeData?.overviewMetrics.series ?? [], balanceData?.overviewMetrics.series ?? []]) {
|
||||
for (const point of source) {
|
||||
const existing = overviewSeriesMap.get(point.periodId);
|
||||
if (!existing) {
|
||||
overviewSeriesMap.set(point.periodId, { ...point });
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.filingDate = existing.filingDate || point.filingDate;
|
||||
existing.periodEnd = existing.periodEnd ?? point.periodEnd;
|
||||
existing.label = existing.label || point.label;
|
||||
existing.revenue = existing.revenue ?? point.revenue;
|
||||
existing.netIncome = existing.netIncome ?? point.netIncome;
|
||||
existing.totalAssets = existing.totalAssets ?? point.totalAssets;
|
||||
existing.cash = existing.cash ?? point.cash;
|
||||
existing.debt = existing.debt ?? point.debt;
|
||||
}
|
||||
}
|
||||
|
||||
if (overviewSeriesMap.size > 0) {
|
||||
return [...overviewSeriesMap.values()].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
}
|
||||
|
||||
const periodMap = new Map<string, { filingDate: string; periodEnd: string | null }>();
|
||||
|
||||
for (const source of [incomeData, balanceData]) {
|
||||
@@ -609,27 +681,36 @@ function FinancialsPageContent() {
|
||||
|
||||
const latestStandardizedIncome = overviewIncome?.surfaces.standardized.rows ?? [];
|
||||
const latestStandardizedBalance = overviewBalance?.surfaces.standardized.rows ?? [];
|
||||
const latestRevenue = latestOverview?.revenue
|
||||
const latestRevenue = overviewIncome?.overviewMetrics.latest.revenue
|
||||
?? latestOverview?.revenue
|
||||
?? findStandardizedRow(latestStandardizedIncome, 'revenue')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.revenue
|
||||
?? null;
|
||||
const latestNetIncome = latestOverview?.netIncome
|
||||
const latestNetIncome = overviewIncome?.overviewMetrics.latest.netIncome
|
||||
?? latestOverview?.netIncome
|
||||
?? findStandardizedRow(latestStandardizedIncome, 'net-income')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.netIncome
|
||||
?? null;
|
||||
const latestTotalAssets = latestOverview?.totalAssets
|
||||
const latestTotalAssets = overviewBalance?.overviewMetrics.latest.totalAssets
|
||||
?? latestOverview?.totalAssets
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'total-assets')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.totalAssets
|
||||
?? null;
|
||||
const latestCash = latestOverview?.cash
|
||||
const latestCash = overviewBalance?.overviewMetrics.latest.cash
|
||||
?? latestOverview?.cash
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'cash-and-equivalents')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.cash
|
||||
?? null;
|
||||
const latestDebt = latestOverview?.debt
|
||||
const latestDebt = overviewBalance?.overviewMetrics.latest.debt
|
||||
?? latestOverview?.debt
|
||||
?? findStandardizedRow(latestStandardizedBalance, 'total-debt')?.values[periods[periods.length - 1]?.id ?? '']
|
||||
?? latestTaxonomyMetrics?.debt
|
||||
?? null;
|
||||
const latestReferenceDate = latestOverview?.filingDate ?? periods[periods.length - 1]?.filingDate ?? null;
|
||||
const latestReferenceDate = overviewIncome?.overviewMetrics.referenceDate
|
||||
?? overviewBalance?.overviewMetrics.referenceDate
|
||||
?? latestOverview?.filingDate
|
||||
?? periods[periods.length - 1]?.filingDate
|
||||
?? null;
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selectedRowKey) {
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function CommandCenterPage() {
|
||||
positive={Number(state.summary.total_gain_loss) >= 0}
|
||||
/>
|
||||
<MetricCard label="Tracked Filings" value={String(state.filingsCount)} delta="Last 200 records" />
|
||||
<MetricCard label="Watchlist Nodes" value={String(state.watchlistCount)} delta={`${state.summary.positions} positions active`} />
|
||||
<MetricCard label="Coverage Names" value={String(state.watchlistCount)} delta={`${state.summary.positions} positions active`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
@@ -223,7 +223,7 @@ export default function CommandCenterPage() {
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Portfolio</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage holdings and mark to market in real time.</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage the active private portfolio and mark positions to market.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
@@ -231,8 +231,8 @@ export default function CommandCenterPage() {
|
||||
onMouseEnter={() => prefetchPortfolioSurfaces()}
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Watchlist</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track priority tickers for monitoring and ingestion.</p>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Coverage</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track research status, review cadence, and filing freshness per company.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
|
||||
import { BrainCircuit, Plus, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { BrainCircuit, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
deleteHolding,
|
||||
queuePortfolioInsights,
|
||||
queuePriceRefresh,
|
||||
updateHolding,
|
||||
upsertHolding
|
||||
} from '@/lib/api';
|
||||
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
|
||||
type FormState = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
shares: string;
|
||||
avgCost: string;
|
||||
currentPrice: string;
|
||||
@@ -49,13 +53,15 @@ const EMPTY_SUMMARY: PortfolioSummary = {
|
||||
export default function PortfolioPage() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
|
||||
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
const [editingHoldingId, setEditingHoldingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
|
||||
const loadPortfolio = useCallback(async () => {
|
||||
const holdingsOptions = holdingsQueryOptions();
|
||||
@@ -70,9 +76,9 @@ export default function PortfolioPage() {
|
||||
|
||||
try {
|
||||
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
||||
queryClient.ensureQueryData(holdingsOptions),
|
||||
queryClient.ensureQueryData(summaryOptions),
|
||||
queryClient.ensureQueryData(insightOptions)
|
||||
queryClient.fetchQuery(holdingsOptions),
|
||||
queryClient.fetchQuery(summaryOptions),
|
||||
queryClient.fetchQuery(insightOptions)
|
||||
]);
|
||||
|
||||
setHoldings(holdingsRes.holdings);
|
||||
@@ -107,18 +113,33 @@ export default function PortfolioPage() {
|
||||
[holdings]
|
||||
);
|
||||
|
||||
const resetHoldingForm = useCallback(() => {
|
||||
setEditingHoldingId(null);
|
||||
setForm({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
}, []);
|
||||
|
||||
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
41
drizzle/0006_coverage_journal_tracking.sql
Normal file
41
drizzle/0006_coverage_journal_tracking.sql
Normal 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`);
|
||||
@@ -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
189
e2e/research-mvp.spec.ts
Normal 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();
|
||||
});
|
||||
109
lib/api.ts
109
lib/api.ts
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
148
lib/server/repos/research-journal.ts
Normal file
148
lib/server/repos/research-journal.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
66
lib/types.ts
66
lib/types.ts
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user