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,
|
XAxis,
|
||||||
YAxis
|
YAxis
|
||||||
} from 'recharts';
|
} 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 { useSearchParams } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,6 +29,11 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||||
|
import {
|
||||||
|
createResearchJournalEntry,
|
||||||
|
deleteResearchJournalEntry,
|
||||||
|
updateResearchJournalEntry
|
||||||
|
} from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
asNumber,
|
asNumber,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -29,8 +42,14 @@ import {
|
|||||||
type NumberScaleUnit
|
type NumberScaleUnit
|
||||||
} from '@/lib/format';
|
} from '@/lib/format';
|
||||||
import { queryKeys } from '@/lib/query/keys';
|
import { queryKeys } from '@/lib/query/keys';
|
||||||
import { companyAnalysisQueryOptions } from '@/lib/query/options';
|
import {
|
||||||
import type { CompanyAnalysis } from '@/lib/types';
|
companyAnalysisQueryOptions,
|
||||||
|
researchJournalQueryOptions
|
||||||
|
} from '@/lib/query/options';
|
||||||
|
import type {
|
||||||
|
CompanyAnalysis,
|
||||||
|
ResearchJournalEntry
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
||||||
|
|
||||||
@@ -44,6 +63,18 @@ type FinancialSeriesPoint = {
|
|||||||
netMargin: number | null;
|
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 }> = [
|
const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [
|
||||||
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
||||||
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
||||||
@@ -70,6 +101,10 @@ function formatLongDate(value: string) {
|
|||||||
return format(new Date(value), 'MMM dd, yyyy');
|
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) {
|
function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||||
if (numerator === null || denominator === null || denominator === 0) {
|
if (numerator === null || denominator === null || denominator === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -78,6 +113,11 @@ function ratioPercent(numerator: number | null, denominator: number | null) {
|
|||||||
return (numerator / denominator) * 100;
|
return (numerator / denominator) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTickerInput(value: string | null) {
|
||||||
|
const normalized = value?.trim().toUpperCase() ?? '';
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
function isFinancialSnapshotForm(
|
function isFinancialSnapshotForm(
|
||||||
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
||||||
): filingType is '10-K' | '10-Q' {
|
): filingType is '10-K' | '10-Q' {
|
||||||
@@ -120,22 +160,22 @@ function AnalysisPageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
||||||
|
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT';
|
||||||
|
|
||||||
const [tickerInput, setTickerInput] = useState('MSFT');
|
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||||
const [ticker, setTicker] = useState('MSFT');
|
const [ticker, setTicker] = useState(initialTicker);
|
||||||
const [analysis, setAnalysis] = useState<CompanyAnalysis | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fromQuery = searchParams.get('ticker');
|
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||||
if (!fromQuery) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = fromQuery.trim().toUpperCase();
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -154,7 +194,7 @@ function AnalysisPageContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await queryClient.ensureQueryData(options);
|
const response = await queryClient.fetchQuery(options);
|
||||||
setAnalysis(response.analysis);
|
setAnalysis(response.analysis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
|
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
|
||||||
@@ -164,11 +204,32 @@ function AnalysisPageContent() {
|
|||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isPending && isAuthenticated) {
|
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(() => {
|
const priceSeries = useMemo(() => {
|
||||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||||
@@ -207,6 +268,77 @@ function AnalysisPageContent() {
|
|||||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
||||||
}, [financialValueScale]);
|
}, [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) {
|
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>;
|
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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker.trim().toUpperCase()) });
|
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||||
void loadAnalysis(ticker);
|
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" />
|
<RefreshCcw className="size-4" />
|
||||||
@@ -243,6 +380,7 @@ function AnalysisPageContent() {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={tickerInput}
|
value={tickerInput}
|
||||||
|
aria-label="Analysis ticker"
|
||||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||||
placeholder="Ticker (AAPL)"
|
placeholder="Ticker (AAPL)"
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
@@ -252,14 +390,24 @@ function AnalysisPageContent() {
|
|||||||
Analyze
|
Analyze
|
||||||
</Button>
|
</Button>
|
||||||
{analysis ? (
|
{analysis ? (
|
||||||
<Link
|
<>
|
||||||
href={`/filings?ticker=${analysis.company.ticker}`}
|
<Link
|
||||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
href={`/financials?ticker=${analysis.company.ticker}`}
|
||||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||||
>
|
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||||
Open filing stream
|
>
|
||||||
</Link>
|
Open financials
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/filings?ticker=${analysis.company.ticker}`}
|
||||||
|
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||||
|
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||||
|
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Open filing stream
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -310,6 +458,101 @@ function AnalysisPageContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||||
<Panel title="Price History" subtitle="Weekly close over the last year.">
|
<Panel title="Price History" subtitle="Weekly close over the last year.">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -480,11 +723,11 @@ function AnalysisPageContent() {
|
|||||||
<Panel title="AI Reports" subtitle="Generated filing analyses for this company.">
|
<Panel title="AI Reports" subtitle="Generated filing analyses for this company.">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading AI reports...</p>
|
<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>
|
<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">
|
<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">
|
<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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -513,10 +756,122 @@ function AnalysisPageContent() {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</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>
|
<Panel>
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||||
<ChartNoAxesCombined className="size-4" />
|
<ChartNoAxesCombined className="size-4" />
|
||||||
Analysis scope: price + filings + ai synthesis
|
Analysis scope: price + filings + ai synthesis + research journal
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
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 { useParams } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
@@ -14,6 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
|
|||||||
import type { CompanyAiReportDetail } from '@/lib/types';
|
import type { CompanyAiReportDetail } from '@/lib/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
|
import { createResearchJournalEntry } from '@/lib/api';
|
||||||
|
|
||||||
function formatFilingDate(value: string) {
|
function formatFilingDate(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -44,6 +45,8 @@ export default function AnalysisReportPage() {
|
|||||||
const [report, setReport] = useState<CompanyAiReportDetail | null>(null);
|
const [report, setReport] = useState<CompanyAiReportDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savingToJournal, setSavingToJournal] = useState(false);
|
||||||
|
const [journalNotice, setJournalNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadReport = useCallback(async () => {
|
const loadReport = useCallback(async () => {
|
||||||
if (!accessionNumber) {
|
if (!accessionNumber) {
|
||||||
@@ -174,6 +177,49 @@ export default function AnalysisReportPage() {
|
|||||||
<BrainCircuit className="size-3.5" />
|
<BrainCircuit className="size-3.5" />
|
||||||
Full text view
|
Full text view
|
||||||
</div>
|
</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)]">
|
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||||
{report.summary}
|
{report.summary}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { format } from 'date-fns';
|
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 { useSearchParams } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
@@ -13,7 +13,11 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
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 type { Filing } from '@/lib/types';
|
||||||
import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
|
import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
|
||||||
import { queryKeys } from '@/lib/query/keys';
|
import { queryKeys } from '@/lib/query/keys';
|
||||||
@@ -131,6 +135,7 @@ function FilingsPageContent() {
|
|||||||
const [filterTickerInput, setFilterTickerInput] = useState('');
|
const [filterTickerInput, setFilterTickerInput] = useState('');
|
||||||
const [searchTicker, setSearchTicker] = useState('');
|
const [searchTicker, setSearchTicker] = useState('');
|
||||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||||
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ticker = searchParams.get('ticker');
|
const ticker = searchParams.get('ticker');
|
||||||
@@ -152,7 +157,7 @@ function FilingsPageContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await queryClient.ensureQueryData(options);
|
const response = await queryClient.fetchQuery(options);
|
||||||
setFilings(response.filings);
|
setFilings(response.filings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unable to fetch filings');
|
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 groupedByTicker = useMemo(() => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
@@ -321,6 +350,7 @@ function FilingsPageContent() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
{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 ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
|
||||||
) : filings.length === 0 ? (
|
) : filings.length === 0 ? (
|
||||||
@@ -379,6 +409,14 @@ function FilingsPageContent() {
|
|||||||
<Bot className="size-3" />
|
<Bot className="size-3" />
|
||||||
Analyze
|
Analyze
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void addToJournal(filing)}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<NotebookPen className="size-3" />
|
||||||
|
Add to journal
|
||||||
|
</Button>
|
||||||
{hasAnalysis ? (
|
{hasAnalysis ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
||||||
@@ -449,6 +487,14 @@ function FilingsPageContent() {
|
|||||||
<Bot className="size-3" />
|
<Bot className="size-3" />
|
||||||
Analyze
|
Analyze
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void addToJournal(filing)}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<NotebookPen className="size-3" />
|
||||||
|
Journal
|
||||||
|
</Button>
|
||||||
{hasAnalysis ? (
|
{hasAnalysis ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/analysis/reports/${filing.ticker}/${encodeURIComponent(filing.accession_number)}`}
|
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()];
|
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(
|
function mergeFinancialPages(
|
||||||
base: CompanyFinancialStatementsResponse | null,
|
base: CompanyFinancialStatementsResponse | null,
|
||||||
next: CompanyFinancialStatementsResponse
|
next: CompanyFinancialStatementsResponse
|
||||||
@@ -317,6 +361,7 @@ function mergeFinancialPages(
|
|||||||
...next.dataSourceStatus,
|
...next.dataSourceStatus,
|
||||||
queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync
|
queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync
|
||||||
},
|
},
|
||||||
|
overviewMetrics: mergeOverviewMetrics(base.overviewMetrics, next.overviewMetrics),
|
||||||
dimensionBreakdown
|
dimensionBreakdown
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -342,6 +387,33 @@ function buildOverviewSeries(
|
|||||||
incomeData: CompanyFinancialStatementsResponse | null,
|
incomeData: CompanyFinancialStatementsResponse | null,
|
||||||
balanceData: CompanyFinancialStatementsResponse | null
|
balanceData: CompanyFinancialStatementsResponse | null
|
||||||
): OverviewPoint[] {
|
): 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 }>();
|
const periodMap = new Map<string, { filingDate: string; periodEnd: string | null }>();
|
||||||
|
|
||||||
for (const source of [incomeData, balanceData]) {
|
for (const source of [incomeData, balanceData]) {
|
||||||
@@ -609,27 +681,36 @@ function FinancialsPageContent() {
|
|||||||
|
|
||||||
const latestStandardizedIncome = overviewIncome?.surfaces.standardized.rows ?? [];
|
const latestStandardizedIncome = overviewIncome?.surfaces.standardized.rows ?? [];
|
||||||
const latestStandardizedBalance = overviewBalance?.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 ?? '']
|
?? findStandardizedRow(latestStandardizedIncome, 'revenue')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
?? latestTaxonomyMetrics?.revenue
|
?? latestTaxonomyMetrics?.revenue
|
||||||
?? null;
|
?? null;
|
||||||
const latestNetIncome = latestOverview?.netIncome
|
const latestNetIncome = overviewIncome?.overviewMetrics.latest.netIncome
|
||||||
|
?? latestOverview?.netIncome
|
||||||
?? findStandardizedRow(latestStandardizedIncome, 'net-income')?.values[periods[periods.length - 1]?.id ?? '']
|
?? findStandardizedRow(latestStandardizedIncome, 'net-income')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
?? latestTaxonomyMetrics?.netIncome
|
?? latestTaxonomyMetrics?.netIncome
|
||||||
?? null;
|
?? null;
|
||||||
const latestTotalAssets = latestOverview?.totalAssets
|
const latestTotalAssets = overviewBalance?.overviewMetrics.latest.totalAssets
|
||||||
|
?? latestOverview?.totalAssets
|
||||||
?? findStandardizedRow(latestStandardizedBalance, 'total-assets')?.values[periods[periods.length - 1]?.id ?? '']
|
?? findStandardizedRow(latestStandardizedBalance, 'total-assets')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
?? latestTaxonomyMetrics?.totalAssets
|
?? latestTaxonomyMetrics?.totalAssets
|
||||||
?? null;
|
?? null;
|
||||||
const latestCash = latestOverview?.cash
|
const latestCash = overviewBalance?.overviewMetrics.latest.cash
|
||||||
|
?? latestOverview?.cash
|
||||||
?? findStandardizedRow(latestStandardizedBalance, 'cash-and-equivalents')?.values[periods[periods.length - 1]?.id ?? '']
|
?? findStandardizedRow(latestStandardizedBalance, 'cash-and-equivalents')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
?? latestTaxonomyMetrics?.cash
|
?? latestTaxonomyMetrics?.cash
|
||||||
?? null;
|
?? null;
|
||||||
const latestDebt = latestOverview?.debt
|
const latestDebt = overviewBalance?.overviewMetrics.latest.debt
|
||||||
|
?? latestOverview?.debt
|
||||||
?? findStandardizedRow(latestStandardizedBalance, 'total-debt')?.values[periods[periods.length - 1]?.id ?? '']
|
?? findStandardizedRow(latestStandardizedBalance, 'total-debt')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
?? latestTaxonomyMetrics?.debt
|
?? latestTaxonomyMetrics?.debt
|
||||||
?? null;
|
?? 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(() => {
|
const selectedRow = useMemo(() => {
|
||||||
if (!selectedRowKey) {
|
if (!selectedRowKey) {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function CommandCenterPage() {
|
|||||||
positive={Number(state.summary.total_gain_loss) >= 0}
|
positive={Number(state.summary.total_gain_loss) >= 0}
|
||||||
/>
|
/>
|
||||||
<MetricCard label="Tracked Filings" value={String(state.filingsCount)} delta="Last 200 records" />
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
@@ -223,7 +223,7 @@ export default function CommandCenterPage() {
|
|||||||
onFocus={() => prefetchPortfolioSurfaces()}
|
onFocus={() => prefetchPortfolioSurfaces()}
|
||||||
>
|
>
|
||||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Portfolio</p>
|
<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>
|
||||||
<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)]"
|
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()}
|
onMouseEnter={() => prefetchPortfolioSurfaces()}
|
||||||
onFocus={() => prefetchPortfolioSurfaces()}
|
onFocus={() => prefetchPortfolioSurfaces()}
|
||||||
>
|
>
|
||||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Watchlist</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 priority tickers for monitoring and ingestion.</p>
|
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track research status, review cadence, and filing freshness per company.</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
|
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 { AppShell } from '@/components/shell/app-shell';
|
||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
|
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||||
import {
|
import {
|
||||||
deleteHolding,
|
deleteHolding,
|
||||||
queuePortfolioInsights,
|
queuePortfolioInsights,
|
||||||
queuePriceRefresh,
|
queuePriceRefresh,
|
||||||
|
updateHolding,
|
||||||
upsertHolding
|
upsertHolding
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
|
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
|
||||||
@@ -26,6 +29,7 @@ import {
|
|||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
|
companyName: string;
|
||||||
shares: string;
|
shares: string;
|
||||||
avgCost: string;
|
avgCost: string;
|
||||||
currentPrice: string;
|
currentPrice: string;
|
||||||
@@ -49,13 +53,15 @@ const EMPTY_SUMMARY: PortfolioSummary = {
|
|||||||
export default function PortfolioPage() {
|
export default function PortfolioPage() {
|
||||||
const { isPending, isAuthenticated } = useAuthGuard();
|
const { isPending, isAuthenticated } = useAuthGuard();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||||
|
|
||||||
const [holdings, setHoldings] = useState<Holding[]>([]);
|
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||||
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
|
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
|
||||||
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
|
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 loadPortfolio = useCallback(async () => {
|
||||||
const holdingsOptions = holdingsQueryOptions();
|
const holdingsOptions = holdingsQueryOptions();
|
||||||
@@ -70,9 +76,9 @@ export default function PortfolioPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
||||||
queryClient.ensureQueryData(holdingsOptions),
|
queryClient.fetchQuery(holdingsOptions),
|
||||||
queryClient.ensureQueryData(summaryOptions),
|
queryClient.fetchQuery(summaryOptions),
|
||||||
queryClient.ensureQueryData(insightOptions)
|
queryClient.fetchQuery(insightOptions)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setHoldings(holdingsRes.holdings);
|
setHoldings(holdingsRes.holdings);
|
||||||
@@ -107,18 +113,33 @@ export default function PortfolioPage() {
|
|||||||
[holdings]
|
[holdings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetHoldingForm = useCallback(() => {
|
||||||
|
setEditingHoldingId(null);
|
||||||
|
setForm({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await upsertHolding({
|
if (editingHoldingId === null) {
|
||||||
ticker: form.ticker.toUpperCase(),
|
await upsertHolding({
|
||||||
shares: Number(form.shares),
|
ticker: form.ticker.toUpperCase(),
|
||||||
avgCost: Number(form.avgCost),
|
companyName: form.companyName.trim() || undefined,
|
||||||
currentPrice: form.currentPrice ? Number(form.currentPrice) : 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.holdings() });
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||||
await loadPortfolio();
|
await loadPortfolio();
|
||||||
@@ -282,15 +303,17 @@ export default function PortfolioPage() {
|
|||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-full overflow-x-auto">
|
<div className="max-w-full overflow-x-auto">
|
||||||
<table className="data-table min-w-[780px]">
|
<table className="data-table min-w-[1020px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticker</th>
|
<th>Ticker</th>
|
||||||
|
<th>Company</th>
|
||||||
<th>Shares</th>
|
<th>Shares</th>
|
||||||
<th>Avg Cost</th>
|
<th>Avg Cost</th>
|
||||||
<th>Price</th>
|
<th>Price</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
<th>Gain/Loss</th>
|
<th>Gain/Loss</th>
|
||||||
|
<th>Research</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -298,6 +321,7 @@ export default function PortfolioPage() {
|
|||||||
{holdings.map((holding) => (
|
{holdings.map((holding) => (
|
||||||
<tr key={holding.id}>
|
<tr key={holding.id}>
|
||||||
<td>{holding.ticker}</td>
|
<td>{holding.ticker}</td>
|
||||||
|
<td>{holding.company_name ?? 'n/a'}</td>
|
||||||
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
||||||
<td>{formatCurrency(holding.avg_cost)}</td>
|
<td>{formatCurrency(holding.avg_cost)}</td>
|
||||||
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
||||||
@@ -306,23 +330,73 @@ export default function PortfolioPage() {
|
|||||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Button
|
<div className="flex flex-wrap gap-2">
|
||||||
variant="danger"
|
<Link
|
||||||
className="px-2 py-1 text-xs"
|
href={`/analysis?ticker=${holding.ticker}`}
|
||||||
onClick={async () => {
|
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||||
try {
|
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||||
await deleteHolding(holding.id);
|
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
>
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
Analysis
|
||||||
await loadPortfolio();
|
</Link>
|
||||||
} catch (err) {
|
<Link
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
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)]"
|
||||||
<Trash2 className="size-3" />
|
>
|
||||||
Delete
|
Financials
|
||||||
</Button>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/filings?ticker=${holding.ticker}`}
|
||||||
|
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||||
|
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||||
|
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Filings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingHoldingId(holding.id);
|
||||||
|
setForm({
|
||||||
|
ticker: holding.ticker,
|
||||||
|
companyName: holding.company_name ?? '',
|
||||||
|
shares: String(asNumber(holding.shares)),
|
||||||
|
avgCost: String(asNumber(holding.avg_cost)),
|
||||||
|
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquarePen className="size-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteHolding(holding.id);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||||
|
await loadPortfolio();
|
||||||
|
if (editingHoldingId === holding.id) {
|
||||||
|
resetHoldingForm();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -332,28 +406,50 @@ export default function PortfolioPage() {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Add / Update Holding">
|
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'}>
|
||||||
<form onSubmit={submitHolding} className="space-y-3">
|
<form onSubmit={submitHolding} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Shares</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Average Cost</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
|
||||||
<Input type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||||
|
</Button>
|
||||||
|
{editingHoldingId !== null ? (
|
||||||
|
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Save holding
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
|
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
|
||||||
|
|||||||
@@ -1,28 +1,65 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { format } from 'date-fns';
|
||||||
import { ArrowRight, Eye, Plus, Trash2 } from 'lucide-react';
|
import { ArrowRight, CalendarClock, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { Panel } from '@/components/ui/panel';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
import { deleteWatchlistItem, queueFilingSync, upsertWatchlistItem } from '@/lib/api';
|
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||||
import type { WatchlistItem } from '@/lib/types';
|
import {
|
||||||
|
deleteWatchlistItem,
|
||||||
|
queueFilingSync,
|
||||||
|
updateWatchlistItem,
|
||||||
|
upsertWatchlistItem
|
||||||
|
} from '@/lib/api';
|
||||||
import { queryKeys } from '@/lib/query/keys';
|
import { queryKeys } from '@/lib/query/keys';
|
||||||
import { watchlistQueryOptions } from '@/lib/query/options';
|
import { watchlistQueryOptions } from '@/lib/query/options';
|
||||||
|
import type {
|
||||||
|
CoveragePriority,
|
||||||
|
CoverageStatus,
|
||||||
|
WatchlistItem
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
sector: string;
|
sector: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
status: CoverageStatus;
|
||||||
|
priority: CoveragePriority;
|
||||||
tags: string;
|
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) {
|
function parseTagsInput(input: string) {
|
||||||
const unique = new Set<string>();
|
const unique = new Set<string>();
|
||||||
|
|
||||||
@@ -38,6 +75,36 @@ function parseTagsInput(input: string) {
|
|||||||
return [...unique];
|
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() {
|
export default function WatchlistPage() {
|
||||||
const { isPending, isAuthenticated } = useAuthGuard();
|
const { isPending, isAuthenticated } = useAuthGuard();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -45,16 +112,13 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
const [items, setItems] = useState<WatchlistItem[]>([]);
|
const [items, setItems] = useState<WatchlistItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<FormState>({
|
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||||
ticker: '',
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
companyName: '',
|
|
||||||
sector: '',
|
|
||||||
category: '',
|
|
||||||
tags: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadWatchlist = useCallback(async () => {
|
const loadCoverage = useCallback(async () => {
|
||||||
const options = watchlistQueryOptions();
|
const options = watchlistQueryOptions();
|
||||||
|
|
||||||
if (!queryClient.getQueryData(options.queryKey)) {
|
if (!queryClient.getQueryData(options.queryKey)) {
|
||||||
@@ -64,10 +128,10 @@ export default function WatchlistPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await queryClient.ensureQueryData(options);
|
const response = await queryClient.fetchQuery(options);
|
||||||
setItems(response.items);
|
setItems(response.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load watchlist');
|
setError(err instanceof Error ? err.message : 'Failed to load coverage');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -75,33 +139,110 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && isAuthenticated) {
|
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();
|
event.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await upsertWatchlistItem({
|
const payload = {
|
||||||
ticker: form.ticker.toUpperCase(),
|
companyName: form.companyName.trim(),
|
||||||
companyName: form.companyName,
|
sector: form.sector.trim() || undefined,
|
||||||
sector: form.sector || undefined,
|
category: form.category.trim() || undefined,
|
||||||
category: form.category || undefined,
|
status: form.status,
|
||||||
|
priority: form.priority,
|
||||||
tags: parseTagsInput(form.tags)
|
tags: parseTagsInput(form.tags)
|
||||||
});
|
};
|
||||||
|
|
||||||
setForm({
|
if (editingItemId === null) {
|
||||||
ticker: '',
|
await upsertWatchlistItem({
|
||||||
companyName: '',
|
ticker: form.ticker.trim().toUpperCase(),
|
||||||
sector: '',
|
...payload
|
||||||
category: '',
|
});
|
||||||
tags: ''
|
} else {
|
||||||
});
|
await updateWatchlistItem(editingItemId, payload);
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
}
|
||||||
await loadWatchlist();
|
|
||||||
|
invalidateCoverageQueries(form.ticker);
|
||||||
|
await loadCoverage();
|
||||||
|
resetForm();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save watchlist item');
|
setError(err instanceof Error ? err.message : 'Failed to save coverage item');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCoverageInline = async (
|
||||||
|
item: WatchlistItem,
|
||||||
|
patch: {
|
||||||
|
status?: CoverageStatus;
|
||||||
|
priority?: CoveragePriority;
|
||||||
|
lastReviewedAt?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await updateWatchlistItem(item.id, {
|
||||||
|
status: patch.status,
|
||||||
|
priority: patch.priority,
|
||||||
|
lastReviewedAt: patch.lastReviewedAt
|
||||||
|
});
|
||||||
|
invalidateCoverageQueries(item.ticker);
|
||||||
|
await loadCoverage();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : `Failed to update ${item.ticker}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,127 +255,303 @@ export default function WatchlistPage() {
|
|||||||
tags: item.tags.length > 0 ? item.tags : undefined
|
tags: item.tags.length > 0 ? item.tags : undefined
|
||||||
});
|
});
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['filings'] });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(item.ticker, 120) });
|
||||||
} catch (err) {
|
} 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) {
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Watchlist"
|
title="Coverage"
|
||||||
subtitle="Track symbols, company context, and trigger filing ingestion jobs from one surface."
|
subtitle="Track research status, priorities, filing recency, and handoff into company analysis."
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.8fr_1fr]">
|
||||||
<Panel title="Symbols" subtitle="Your monitored universe.">
|
<Panel
|
||||||
|
title="Coverage Board"
|
||||||
|
subtitle={`${items.length} tracked companies across backlog, active work, and archive.`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
aria-label="Search coverage"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Search ticker, company, tag, sector..."
|
||||||
|
className="min-w-[18rem]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||||
|
void loadCoverage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading watchlist...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading coverage...</p>
|
||||||
) : items.length === 0 ? (
|
) : filteredItems.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)]">No coverage items match the current search.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="overflow-x-auto">
|
||||||
{items.map((item) => (
|
<table className="data-table min-w-[1120px]">
|
||||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
<thead>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<tr>
|
||||||
<div>
|
<th>Company</th>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
|
<th>Status</th>
|
||||||
{item.sector ?? 'Unclassified'}
|
<th>Priority</th>
|
||||||
{item.category ? ` · ${item.category}` : ''}
|
<th>Tags</th>
|
||||||
</p>
|
<th>Last Filing</th>
|
||||||
<h3 className="mt-1 text-xl font-semibold text-[color:var(--terminal-bright)]">{item.ticker}</h3>
|
<th>Last Reviewed</th>
|
||||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{item.company_name}</p>
|
<th>Actions</th>
|
||||||
{item.tags.length > 0 ? (
|
</tr>
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
</thead>
|
||||||
{item.tags.map((tag) => (
|
<tbody>
|
||||||
<span
|
{filteredItems.map((item) => (
|
||||||
key={`${item.id}-${tag}`}
|
<tr key={item.id}>
|
||||||
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
<td>
|
||||||
>
|
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
|
||||||
{tag}
|
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
|
||||||
</span>
|
<div className="text-xs text-[color:var(--terminal-muted)]">
|
||||||
))}
|
{item.sector ?? 'Unclassified'}
|
||||||
|
{item.category ? ` · ${item.category}` : ''}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</td>
|
||||||
</div>
|
<td>
|
||||||
<Eye className="size-4 text-[color:var(--accent)]" />
|
<select
|
||||||
</div>
|
aria-label={`${item.ticker} status`}
|
||||||
|
className={SELECT_CLASS_NAME}
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
value={item.status}
|
||||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
onChange={(event) => {
|
||||||
Sync filings
|
void updateCoverageInline(item, {
|
||||||
</Button>
|
status: event.target.value as CoverageStatus
|
||||||
<Link
|
});
|
||||||
href={`/filings?ticker=${item.ticker}`}
|
}}
|
||||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
>
|
||||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
{STATUS_OPTIONS.map((option) => (
|
||||||
className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
<option key={option.value} value={option.value}>
|
||||||
>
|
{option.label}
|
||||||
Open stream
|
</option>
|
||||||
<ArrowRight className="size-3" />
|
))}
|
||||||
</Link>
|
</select>
|
||||||
<Link
|
</td>
|
||||||
href={`/analysis?ticker=${item.ticker}`}
|
<td>
|
||||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
<select
|
||||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
aria-label={`${item.ticker} priority`}
|
||||||
className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
className={SELECT_CLASS_NAME}
|
||||||
>
|
value={item.priority}
|
||||||
Analyze
|
onChange={(event) => {
|
||||||
<ArrowRight className="size-3" />
|
void updateCoverageInline(item, {
|
||||||
</Link>
|
priority: event.target.value as CoveragePriority
|
||||||
<Button
|
});
|
||||||
variant="danger"
|
}}
|
||||||
className="ml-auto px-2 py-1 text-xs"
|
>
|
||||||
onClick={async () => {
|
{PRIORITY_OPTIONS.map((option) => (
|
||||||
try {
|
<option key={option.value} value={option.value}>
|
||||||
await deleteWatchlistItem(item.id);
|
{option.label}
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
</option>
|
||||||
await loadWatchlist();
|
))}
|
||||||
} catch (err) {
|
</select>
|
||||||
setError(err instanceof Error ? err.message : 'Failed to remove symbol');
|
</td>
|
||||||
}
|
<td>
|
||||||
}}
|
{item.tags.length > 0 ? (
|
||||||
>
|
<div className="flex max-w-[18rem] flex-wrap gap-1">
|
||||||
<Trash2 className="size-3" />
|
{item.tags.map((tag) => (
|
||||||
Remove
|
<span
|
||||||
</Button>
|
key={`${item.id}-${tag}`}
|
||||||
</div>
|
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||||
</article>
|
>
|
||||||
))}
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{formatDateOnly(item.latest_filing_date)}</td>
|
||||||
|
<td>{formatDateTime(item.last_reviewed_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/analysis?ticker=${item.ticker}`}
|
||||||
|
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Analyze
|
||||||
|
<ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/financials?ticker=${item.ticker}`}
|
||||||
|
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Financials
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/filings?ticker=${item.ticker}`}
|
||||||
|
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Filings
|
||||||
|
</Link>
|
||||||
|
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
void updateCoverageInline(item, {
|
||||||
|
lastReviewedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarClock className="size-3" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => beginEdit(item)}
|
||||||
|
>
|
||||||
|
<SquarePen className="size-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteWatchlistItem(item.id);
|
||||||
|
invalidateCoverageQueries(item.ticker);
|
||||||
|
await loadCoverage();
|
||||||
|
if (editingItemId === item.id) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Add Symbol" subtitle="Create or update a watchlist item.">
|
<Panel
|
||||||
<form onSubmit={submit} className="space-y-3">
|
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>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Ticker</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Company Name</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Sector</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Category</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Tags</label>
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Tags</label>
|
||||||
<Input value={form.tags} onChange={(event) => setForm((prev) => ({ ...prev, tags: event.target.value }))} placeholder="Comma-separated tags" />
|
<Input
|
||||||
|
value={form.tags}
|
||||||
|
aria-label="Coverage tags"
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, tags: event.target.value }))}
|
||||||
|
placeholder="Comma-separated tags"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" className="flex-1" disabled={saving}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
|
||||||
|
</Button>
|
||||||
|
{editingItemId !== null ? (
|
||||||
|
<Button type="button" variant="ghost" onClick={resetForm}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Save symbol
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const NAV_ITEMS: NavConfigItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'watchlist',
|
id: 'watchlist',
|
||||||
href: '/watchlist',
|
href: '/watchlist',
|
||||||
label: 'Watchlist',
|
label: 'Coverage',
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
group: 'portfolio',
|
group: 'portfolio',
|
||||||
matchMode: 'exact',
|
matchMode: 'exact',
|
||||||
@@ -174,7 +174,7 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
|
|||||||
if (pathname.startsWith('/watchlist')) {
|
if (pathname.startsWith('/watchlist')) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Portfolio', href: '/portfolio' },
|
{ 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,
|
"when": 1772668800000,
|
||||||
"tag": "0005_financial_taxonomy_v3",
|
"tag": "0005_financial_taxonomy_v3",
|
||||||
"breakpoints": true
|
"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,
|
CompanyAiReportDetail,
|
||||||
CompanyAnalysis,
|
CompanyAnalysis,
|
||||||
CompanyFinancialStatementsResponse,
|
CompanyFinancialStatementsResponse,
|
||||||
|
CoveragePriority,
|
||||||
|
CoverageStatus,
|
||||||
Filing,
|
Filing,
|
||||||
Holding,
|
Holding,
|
||||||
FinancialHistoryWindow,
|
FinancialHistoryWindow,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
PortfolioInsight,
|
PortfolioInsight,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
|
ResearchJournalEntry,
|
||||||
|
ResearchJournalEntryType,
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TaskTimeline,
|
TaskTimeline,
|
||||||
@@ -99,6 +103,36 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
|
|||||||
return payload as T;
|
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() {
|
export async function getMe() {
|
||||||
const result = await client.api.me.get();
|
const result = await client.api.me.get();
|
||||||
return await unwrapData<{ user: User }>(result, 'Unable to fetch session');
|
return await unwrapData<{ user: User }>(result, 'Unable to fetch session');
|
||||||
@@ -115,16 +149,80 @@ export async function upsertWatchlistItem(input: {
|
|||||||
sector?: string;
|
sector?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
status?: CoverageStatus;
|
||||||
|
priority?: CoveragePriority;
|
||||||
|
lastReviewedAt?: string;
|
||||||
}) {
|
}) {
|
||||||
const result = await client.api.watchlist.post(input);
|
const result = await client.api.watchlist.post(input);
|
||||||
return await unwrapData<{ item: WatchlistItem }>(result, 'Unable to save watchlist item');
|
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) {
|
export async function deleteWatchlistItem(id: number) {
|
||||||
const result = await client.api.watchlist[id].delete();
|
const result = await client.api.watchlist[id].delete();
|
||||||
return await unwrapData<{ success: boolean }>(result, 'Unable to delete watchlist item');
|
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() {
|
export async function listHoldings() {
|
||||||
const result = await client.api.portfolio.holdings.get();
|
const result = await client.api.portfolio.holdings.get();
|
||||||
return await unwrapData<{ holdings: Holding[] }>(result, 'Unable to fetch holdings');
|
return await unwrapData<{ holdings: Holding[] }>(result, 'Unable to fetch holdings');
|
||||||
@@ -140,11 +238,22 @@ export async function upsertHolding(input: {
|
|||||||
shares: number;
|
shares: number;
|
||||||
avgCost: number;
|
avgCost: number;
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
|
companyName?: string;
|
||||||
}) {
|
}) {
|
||||||
const result = await client.api.portfolio.holdings.post(input);
|
const result = await client.api.portfolio.holdings.post(input);
|
||||||
return await unwrapData<{ holding: Holding }>(result, 'Unable to save holding');
|
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) {
|
export async function deleteHolding(id: number) {
|
||||||
const result = await client.api.portfolio.holdings[id].delete();
|
const result = await client.api.portfolio.holdings[id].delete();
|
||||||
return await unwrapData<{ success: boolean }>(result, 'Unable to delete holding');
|
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,
|
filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const,
|
||||||
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||||
watchlist: () => ['watchlist'] as const,
|
watchlist: () => ['watchlist'] as const,
|
||||||
|
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
||||||
holdings: () => ['portfolio', 'holdings'] as const,
|
holdings: () => ['portfolio', 'holdings'] as const,
|
||||||
portfolioSummary: () => ['portfolio', 'summary'] as const,
|
portfolioSummary: () => ['portfolio', 'summary'] as const,
|
||||||
latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const,
|
latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
listFilings,
|
listFilings,
|
||||||
listHoldings,
|
listHoldings,
|
||||||
listRecentTasks,
|
listRecentTasks,
|
||||||
|
listResearchJournal,
|
||||||
listWatchlist
|
listWatchlist
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { queryKeys } from '@/lib/query/keys';
|
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() {
|
export function holdingsQueryOptions() {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryKey: queryKeys.holdings(),
|
queryKey: queryKeys.holdings(),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { getWorld } from 'workflow/runtime';
|
import { getWorld } from 'workflow/runtime';
|
||||||
import type {
|
import type {
|
||||||
|
CoveragePriority,
|
||||||
|
CoverageStatus,
|
||||||
Filing,
|
Filing,
|
||||||
FinancialHistoryWindow,
|
FinancialHistoryWindow,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
|
ResearchJournalEntryType,
|
||||||
TaskStatus
|
TaskStatus
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
@@ -15,18 +18,32 @@ import {
|
|||||||
getCompanyFinancialTaxonomy
|
getCompanyFinancialTaxonomy
|
||||||
} from '@/lib/server/financial-taxonomy';
|
} from '@/lib/server/financial-taxonomy';
|
||||||
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
|
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 {
|
import {
|
||||||
deleteHoldingByIdRecord,
|
deleteHoldingByIdRecord,
|
||||||
|
getHoldingByTicker,
|
||||||
listUserHoldings,
|
listUserHoldings,
|
||||||
updateHoldingByIdRecord,
|
updateHoldingByIdRecord,
|
||||||
upsertHoldingRecord
|
upsertHoldingRecord
|
||||||
} from '@/lib/server/repos/holdings';
|
} from '@/lib/server/repos/holdings';
|
||||||
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
|
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
|
||||||
|
import {
|
||||||
|
createResearchJournalEntryRecord,
|
||||||
|
deleteResearchJournalEntryRecord,
|
||||||
|
listResearchJournalEntries,
|
||||||
|
updateResearchJournalEntryRecord
|
||||||
|
} from '@/lib/server/repos/research-journal';
|
||||||
import {
|
import {
|
||||||
deleteWatchlistItemRecord,
|
deleteWatchlistItemRecord,
|
||||||
|
getWatchlistItemById,
|
||||||
getWatchlistItemByTicker,
|
getWatchlistItemByTicker,
|
||||||
listWatchlistItems,
|
listWatchlistItems,
|
||||||
|
updateWatchlistItemRecord,
|
||||||
|
updateWatchlistReviewByTicker,
|
||||||
upsertWatchlistItemRecord
|
upsertWatchlistItemRecord
|
||||||
} from '@/lib/server/repos/watchlist';
|
} from '@/lib/server/repos/watchlist';
|
||||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||||
@@ -52,6 +69,9 @@ const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [
|
|||||||
'comprehensive_income'
|
'comprehensive_income'
|
||||||
];
|
];
|
||||||
const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all'];
|
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> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
@@ -94,6 +114,14 @@ function asOptionalString(value: unknown) {
|
|||||||
return normalized.length > 0 ? normalized : null;
|
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) {
|
function asTags(value: unknown) {
|
||||||
const source = Array.isArray(value)
|
const source = Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
@@ -130,6 +158,31 @@ function asHistoryWindow(value: unknown): FinancialHistoryWindow {
|
|||||||
: '10y';
|
: '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 {
|
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||||
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||||
return filing;
|
return filing;
|
||||||
@@ -266,7 +319,14 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const items = await listWatchlistItems(session.user.id);
|
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 }) => {
|
.post('/watchlist', async ({ body }) => {
|
||||||
const { session, response } = await requireAuthenticatedSession();
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
@@ -280,6 +340,9 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
const sector = asOptionalString(payload.sector) ?? '';
|
const sector = asOptionalString(payload.sector) ?? '';
|
||||||
const category = asOptionalString(payload.category) ?? '';
|
const category = asOptionalString(payload.category) ?? '';
|
||||||
const tags = asTags(payload.tags);
|
const tags = asTags(payload.tags);
|
||||||
|
const status = asCoverageStatus(payload.status);
|
||||||
|
const priority = asCoveragePriority(payload.priority);
|
||||||
|
const lastReviewedAt = asOptionalString(payload.lastReviewedAt);
|
||||||
|
|
||||||
if (!ticker) {
|
if (!ticker) {
|
||||||
return jsonError('ticker is required');
|
return jsonError('ticker is required');
|
||||||
@@ -296,7 +359,10 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
companyName,
|
companyName,
|
||||||
sector,
|
sector,
|
||||||
category,
|
category,
|
||||||
tags
|
tags,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
lastReviewedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
const autoFilingSyncQueued = created
|
const autoFilingSyncQueued = created
|
||||||
@@ -316,9 +382,94 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
companyName: t.String({ minLength: 1 }),
|
companyName: t.String({ minLength: 1 }),
|
||||||
sector: t.Optional(t.String()),
|
sector: t.Optional(t.String()),
|
||||||
category: 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 }) => {
|
.delete('/watchlist/:id', async ({ params }) => {
|
||||||
const { session, response } = await requireAuthenticatedSession();
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -377,13 +528,15 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
|
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
|
||||||
|
const companyName = asOptionalString(payload.companyName) ?? undefined;
|
||||||
|
|
||||||
const { holding, created } = await upsertHoldingRecord({
|
const { holding, created } = await upsertHoldingRecord({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
ticker,
|
ticker,
|
||||||
shares,
|
shares,
|
||||||
avgCost,
|
avgCost,
|
||||||
currentPrice
|
currentPrice,
|
||||||
|
companyName
|
||||||
});
|
});
|
||||||
|
|
||||||
const autoFilingSyncQueued = created
|
const autoFilingSyncQueued = created
|
||||||
@@ -399,7 +552,8 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
ticker: t.String({ minLength: 1 }),
|
ticker: t.String({ minLength: 1 }),
|
||||||
shares: t.Number({ exclusiveMinimum: 0 }),
|
shares: t.Number({ exclusiveMinimum: 0 }),
|
||||||
avgCost: 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 }) => {
|
.patch('/portfolio/holdings/:id', async ({ params, body }) => {
|
||||||
@@ -420,7 +574,8 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
id: numericId,
|
id: numericId,
|
||||||
shares: asPositiveNumber(payload.shares) ?? undefined,
|
shares: asPositiveNumber(payload.shares) ?? undefined,
|
||||||
avgCost: asPositiveNumber(payload.avgCost) ?? 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) {
|
if (!updated) {
|
||||||
@@ -435,7 +590,8 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
body: t.Object({
|
body: t.Object({
|
||||||
shares: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
shares: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
||||||
avgCost: 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 }) => {
|
.delete('/portfolio/holdings/:id', async ({ params }) => {
|
||||||
@@ -522,6 +678,127 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
|
|
||||||
return Response.json({ insight });
|
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 }) => {
|
.get('/analysis/company', async ({ query }) => {
|
||||||
const { session, response } = await requireAuthenticatedSession();
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -533,22 +810,21 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
return jsonError('ticker is required');
|
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 }),
|
listFilingsRecords({ ticker, limit: 40 }),
|
||||||
listUserHoldings(session.user.id),
|
getHoldingByTicker(session.user.id, ticker),
|
||||||
listWatchlistItems(session.user.id),
|
getWatchlistItemByTicker(session.user.id, ticker),
|
||||||
getQuote(ticker),
|
getQuote(ticker),
|
||||||
getPriceHistory(ticker)
|
getPriceHistory(ticker),
|
||||||
|
listResearchJournalEntries(session.user.id, ticker, 6)
|
||||||
]);
|
]);
|
||||||
const redactedFilings = filings
|
const redactedFilings = filings
|
||||||
.map(redactInternalFilingAnalysisFields)
|
.map(redactInternalFilingAnalysisFields)
|
||||||
.map(withFinancialMetricsPolicy);
|
.map(withFinancialMetricsPolicy);
|
||||||
|
|
||||||
const latestFiling = redactedFilings[0] ?? null;
|
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
|
const companyName = latestFiling?.company_name
|
||||||
|
?? holding?.company_name
|
||||||
?? watchlistItem?.company_name
|
?? watchlistItem?.company_name
|
||||||
?? ticker;
|
?? ticker;
|
||||||
|
|
||||||
@@ -575,6 +851,30 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
model: entry.analysis?.model ?? 'unknown',
|
model: entry.analysis?.model ?? 'unknown',
|
||||||
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
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({
|
return Response.json({
|
||||||
analysis: {
|
analysis: {
|
||||||
@@ -591,7 +891,17 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
priceHistory,
|
priceHistory,
|
||||||
financials,
|
financials,
|
||||||
filings: redactedFilings.slice(0, 20),
|
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 { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
import type { WorkflowRunStatus } from '@workflow/world';
|
import type { WorkflowRunStatus } from '@workflow/world';
|
||||||
|
|
||||||
const TEST_USER_ID = 'e2e-user';
|
const TEST_USER_ID = 'e2e-user';
|
||||||
@@ -21,7 +22,7 @@ let runCounter = 0;
|
|||||||
let workflowBackendHealthy = true;
|
let workflowBackendHealthy = true;
|
||||||
|
|
||||||
let tempDir: string | null = null;
|
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;
|
let app: { handle: (request: Request) => Promise<Response> } | null = null;
|
||||||
|
|
||||||
mock.module('workflow/api', () => ({
|
mock.module('workflow/api', () => ({
|
||||||
@@ -87,7 +88,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
|
|||||||
'0002_workflow_task_projection_metadata.sql',
|
'0002_workflow_task_projection_metadata.sql',
|
||||||
'0003_task_stage_event_timeline.sql',
|
'0003_task_stage_event_timeline.sql',
|
||||||
'0004_watchlist_company_taxonomy.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) {
|
for (const file of migrationFiles) {
|
||||||
@@ -121,10 +123,72 @@ function ensureTestUser(client: { exec: (query: string) => void }) {
|
|||||||
function clearProjectionTables(client: { exec: (query: string) => void }) {
|
function clearProjectionTables(client: { exec: (query: string) => void }) {
|
||||||
client.exec('DELETE FROM task_stage_event;');
|
client.exec('DELETE FROM task_stage_event;');
|
||||||
client.exec('DELETE FROM task_run;');
|
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(
|
async function jsonRequest(
|
||||||
method: 'GET' | 'POST' | 'PATCH',
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
path: string,
|
path: string,
|
||||||
body?: Record<string, unknown>
|
body?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
@@ -154,8 +218,8 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
|
|
||||||
resetDbSingletons();
|
resetDbSingletons();
|
||||||
|
|
||||||
const dbModule = await import('@/lib/server/db');
|
sqliteClient = new Database(join(tempDir, 'e2e.sqlite'), { create: true });
|
||||||
sqliteClient = dbModule.getSqliteClient();
|
sqliteClient.exec('PRAGMA foreign_keys = ON;');
|
||||||
applySqlMigrations(sqliteClient);
|
applySqlMigrations(sqliteClient);
|
||||||
ensureTestUser(sqliteClient);
|
ensureTestUser(sqliteClient);
|
||||||
|
|
||||||
@@ -164,6 +228,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
sqliteClient?.close();
|
||||||
resetDbSingletons();
|
resetDbSingletons();
|
||||||
|
|
||||||
if (tempDir) {
|
if (tempDir) {
|
||||||
@@ -291,6 +356,199 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
expect(task.payload.tags).toEqual(['semis', 'ai']);
|
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 () => {
|
it('updates notification read and silenced state via patch endpoint', async () => {
|
||||||
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000010/analyze');
|
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000010/analyze');
|
||||||
const taskId = (created.json as { task: { id: string } }).task.id;
|
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');
|
applyMigration(client, '0003_task_stage_event_timeline.sql');
|
||||||
|
|
||||||
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(false);
|
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, 'filing_taxonomy_snapshot')).toBe(false);
|
||||||
|
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
|
||||||
|
|
||||||
__dbInternals.ensureLocalSqliteSchema(client);
|
__dbInternals.ensureLocalSqliteSchema(client);
|
||||||
|
|
||||||
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(true);
|
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(true);
|
||||||
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'tags')).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_snapshot')).toBe(true);
|
||||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||||
|
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
|
||||||
|
|
||||||
client.close();
|
client.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ function ensureLocalSqliteSchema(client: Database) {
|
|||||||
if (hasTable(client, 'watchlist_item')) {
|
if (hasTable(client, 'watchlist_item')) {
|
||||||
const missingWatchlistColumns: Array<{ name: string; sql: string }> = [
|
const missingWatchlistColumns: Array<{ name: string; sql: string }> = [
|
||||||
{ name: 'category', sql: 'ALTER TABLE `watchlist_item` ADD `category` text;' },
|
{ 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) {
|
for (const column of missingWatchlistColumns) {
|
||||||
@@ -86,11 +90,54 @@ function ensureLocalSqliteSchema(client: Database) {
|
|||||||
client.exec(column.sql);
|
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')) {
|
if (!hasTable(client, 'filing_taxonomy_snapshot')) {
|
||||||
applySqlFile(client, '0005_financial_taxonomy_v3.sql');
|
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() {
|
export function getSqliteClient() {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type TaxonomyAssetType =
|
|||||||
|
|
||||||
type TaxonomyParseStatus = 'ready' | 'partial' | 'failed';
|
type TaxonomyParseStatus = 'ready' | 'partial' | 'failed';
|
||||||
type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'error';
|
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 = {
|
type FilingAnalysis = {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -269,16 +272,22 @@ export const watchlistItem = sqliteTable('watchlist_item', {
|
|||||||
sector: text('sector'),
|
sector: text('sector'),
|
||||||
category: text('category'),
|
category: text('category'),
|
||||||
tags: text('tags', { mode: 'json' }).$type<string[]>(),
|
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) => ({
|
}, (table) => ({
|
||||||
watchlistUserTickerUnique: uniqueIndex('watchlist_user_ticker_uidx').on(table.user_id, table.ticker),
|
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', {
|
export const holding = sqliteTable('holding', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
ticker: text('ticker').notNull(),
|
ticker: text('ticker').notNull(),
|
||||||
|
company_name: text('company_name'),
|
||||||
shares: numeric('shares').notNull(),
|
shares: numeric('shares').notNull(),
|
||||||
avg_cost: numeric('avg_cost').notNull(),
|
avg_cost: numeric('avg_cost').notNull(),
|
||||||
current_price: numeric('current_price'),
|
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)
|
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 = {
|
export const authSchema = {
|
||||||
user,
|
user,
|
||||||
session,
|
session,
|
||||||
@@ -543,7 +568,8 @@ export const appSchema = {
|
|||||||
filingLink,
|
filingLink,
|
||||||
taskRun,
|
taskRun,
|
||||||
taskStageEvent,
|
taskStageEvent,
|
||||||
portfolioInsight
|
portfolioInsight,
|
||||||
|
researchJournalEntry
|
||||||
};
|
};
|
||||||
|
|
||||||
export const schema = {
|
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) {
|
export function defaultFinancialSyncLimit(window: FinancialHistoryWindow) {
|
||||||
return window === 'all' ? 120 : 60;
|
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),
|
pendingFilings: Math.max(0, financialFilings.length - statuses.ready - statuses.partial - statuses.failed),
|
||||||
queuedSync: input.queuedSync
|
queuedSync: input.queuedSync
|
||||||
},
|
},
|
||||||
|
overviewMetrics: buildOverviewMetrics({
|
||||||
|
periods,
|
||||||
|
faithfulRows,
|
||||||
|
standardizedRows
|
||||||
|
}),
|
||||||
metrics,
|
metrics,
|
||||||
dimensionBreakdown
|
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 type { Filing } from '@/lib/types';
|
||||||
import { db } from '@/lib/server/db';
|
import { db } from '@/lib/server/db';
|
||||||
import { filing, filingLink } from '@/lib/server/db/schema';
|
import { filing, filingLink } from '@/lib/server/db/schema';
|
||||||
@@ -87,6 +87,35 @@ export async function getFilingByAccession(accessionNumber: string) {
|
|||||||
return row ? toFiling(row) : null;
|
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[]) {
|
export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
let updated = 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 type { Holding } from '@/lib/types';
|
||||||
import { recalculateHolding } from '@/lib/server/portfolio';
|
import { recalculateHolding } from '@/lib/server/portfolio';
|
||||||
import { db } from '@/lib/server/db';
|
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;
|
type HoldingRow = typeof holding.$inferSelect;
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ function toHolding(row: HoldingRow): Holding {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
ticker: row.ticker,
|
ticker: row.ticker,
|
||||||
|
company_name: row.company_name ?? null,
|
||||||
shares: row.shares,
|
shares: row.shares,
|
||||||
avg_cost: row.avg_cost,
|
avg_cost: row.avg_cost,
|
||||||
current_price: row.current_price,
|
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) {
|
export async function listUserHoldings(userId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -45,12 +81,28 @@ export async function listUserHoldings(userId: string) {
|
|||||||
return sortByMarketValueDesc(rows.map(toHolding));
|
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: {
|
export async function upsertHoldingRecord(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
shares: number;
|
shares: number;
|
||||||
avgCost: number;
|
avgCost: number;
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
|
companyName?: string;
|
||||||
}) {
|
}) {
|
||||||
const ticker = input.ticker.trim().toUpperCase();
|
const ticker = input.ticker.trim().toUpperCase();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -64,6 +116,12 @@ export async function upsertHoldingRecord(input: {
|
|||||||
const currentPrice = Number.isFinite(input.currentPrice)
|
const currentPrice = Number.isFinite(input.currentPrice)
|
||||||
? Number(input.currentPrice)
|
? Number(input.currentPrice)
|
||||||
: input.avgCost;
|
: input.avgCost;
|
||||||
|
const companyName = await resolveHoldingCompanyName({
|
||||||
|
userId: input.userId,
|
||||||
|
ticker,
|
||||||
|
companyName: input.companyName,
|
||||||
|
existingCompanyName: existing?.company_name ?? null
|
||||||
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const normalized = normalizeHoldingInput({
|
const normalized = normalizeHoldingInput({
|
||||||
@@ -84,6 +142,7 @@ export async function upsertHoldingRecord(input: {
|
|||||||
.update(holding)
|
.update(holding)
|
||||||
.set({
|
.set({
|
||||||
ticker: next.ticker,
|
ticker: next.ticker,
|
||||||
|
company_name: companyName,
|
||||||
shares: next.shares,
|
shares: next.shares,
|
||||||
avg_cost: next.avg_cost,
|
avg_cost: next.avg_cost,
|
||||||
current_price: next.current_price,
|
current_price: next.current_price,
|
||||||
@@ -113,6 +172,7 @@ export async function upsertHoldingRecord(input: {
|
|||||||
id: 0,
|
id: 0,
|
||||||
user_id: input.userId,
|
user_id: input.userId,
|
||||||
ticker: normalized.ticker,
|
ticker: normalized.ticker,
|
||||||
|
company_name: companyName,
|
||||||
shares: normalized.shares,
|
shares: normalized.shares,
|
||||||
avg_cost: normalized.avg_cost,
|
avg_cost: normalized.avg_cost,
|
||||||
current_price: normalized.current_price,
|
current_price: normalized.current_price,
|
||||||
@@ -131,6 +191,7 @@ export async function upsertHoldingRecord(input: {
|
|||||||
.values({
|
.values({
|
||||||
user_id: created.user_id,
|
user_id: created.user_id,
|
||||||
ticker: created.ticker,
|
ticker: created.ticker,
|
||||||
|
company_name: created.company_name,
|
||||||
shares: created.shares,
|
shares: created.shares,
|
||||||
avg_cost: created.avg_cost,
|
avg_cost: created.avg_cost,
|
||||||
current_price: created.current_price,
|
current_price: created.current_price,
|
||||||
@@ -155,6 +216,7 @@ export async function updateHoldingByIdRecord(input: {
|
|||||||
shares?: number;
|
shares?: number;
|
||||||
avgCost?: number;
|
avgCost?: number;
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
|
companyName?: string;
|
||||||
}) {
|
}) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -176,9 +238,16 @@ export async function updateHoldingByIdRecord(input: {
|
|||||||
const currentPrice = Number.isFinite(input.currentPrice)
|
const currentPrice = Number.isFinite(input.currentPrice)
|
||||||
? Number(input.currentPrice)
|
? Number(input.currentPrice)
|
||||||
: Number(current.current_price ?? current.avg_cost);
|
: 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({
|
const next = recalculateHolding({
|
||||||
...current,
|
...current,
|
||||||
|
company_name: companyName,
|
||||||
shares: shares.toFixed(6),
|
shares: shares.toFixed(6),
|
||||||
avg_cost: avgCost.toFixed(6),
|
avg_cost: avgCost.toFixed(6),
|
||||||
current_price: currentPrice.toFixed(6),
|
current_price: currentPrice.toFixed(6),
|
||||||
@@ -189,6 +258,7 @@ export async function updateHoldingByIdRecord(input: {
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(holding)
|
.update(holding)
|
||||||
.set({
|
.set({
|
||||||
|
company_name: companyName,
|
||||||
shares: next.shares,
|
shares: next.shares,
|
||||||
avg_cost: next.avg_cost,
|
avg_cost: next.avg_cost,
|
||||||
current_price: next.current_price,
|
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 { 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 { db } from '@/lib/server/db';
|
||||||
import { watchlistItem } from '@/lib/server/db/schema';
|
import { watchlistItem } from '@/lib/server/db/schema';
|
||||||
|
|
||||||
type WatchlistRow = typeof watchlistItem.$inferSelect;
|
type WatchlistRow = typeof watchlistItem.$inferSelect;
|
||||||
|
const DEFAULT_STATUS: CoverageStatus = 'backlog';
|
||||||
|
const DEFAULT_PRIORITY: CoveragePriority = 'medium';
|
||||||
|
|
||||||
function normalizeTags(tags?: string[]) {
|
function normalizeTags(tags?: string[]) {
|
||||||
if (!Array.isArray(tags)) {
|
if (!Array.isArray(tags)) {
|
||||||
@@ -32,7 +38,7 @@ function normalizeTags(tags?: string[]) {
|
|||||||
return [...unique];
|
return [...unique];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWatchlistItem(row: WatchlistRow): WatchlistItem {
|
function toWatchlistItem(row: WatchlistRow, latestFilingDate: string | null = null): WatchlistItem {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
@@ -43,7 +49,12 @@ function toWatchlistItem(row: WatchlistRow): WatchlistItem {
|
|||||||
tags: Array.isArray(row.tags)
|
tags: Array.isArray(row.tags)
|
||||||
? row.tags.filter((entry): entry is string => typeof entry === 'string')
|
? 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()
|
.select()
|
||||||
.from(watchlistItem)
|
.from(watchlistItem)
|
||||||
.where(eq(watchlistItem.user_id, userId))
|
.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) {
|
export async function getWatchlistItemByTicker(userId: string, ticker: string) {
|
||||||
@@ -79,11 +100,18 @@ export async function upsertWatchlistItemRecord(input: {
|
|||||||
sector?: string;
|
sector?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
status?: CoverageStatus;
|
||||||
|
priority?: CoveragePriority;
|
||||||
|
lastReviewedAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||||
const normalizedSector = input.sector?.trim() ? input.sector.trim() : null;
|
const normalizedSector = input.sector?.trim() ? input.sector.trim() : null;
|
||||||
const normalizedCategory = input.category?.trim() ? input.category.trim() : null;
|
const normalizedCategory = input.category?.trim() ? input.category.trim() : null;
|
||||||
const normalizedTags = normalizeTags(input.tags);
|
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 now = new Date().toISOString();
|
||||||
|
|
||||||
const [inserted] = await db
|
const [inserted] = await db
|
||||||
@@ -91,11 +119,15 @@ export async function upsertWatchlistItemRecord(input: {
|
|||||||
.values({
|
.values({
|
||||||
user_id: input.userId,
|
user_id: input.userId,
|
||||||
ticker: normalizedTicker,
|
ticker: normalizedTicker,
|
||||||
company_name: input.companyName,
|
company_name: normalizedCompanyName,
|
||||||
sector: normalizedSector,
|
sector: normalizedSector,
|
||||||
category: normalizedCategory,
|
category: normalizedCategory,
|
||||||
tags: normalizedTags,
|
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({
|
.onConflictDoNothing({
|
||||||
target: [watchlistItem.user_id, watchlistItem.ticker],
|
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
|
const [updated] = await db
|
||||||
.update(watchlistItem)
|
.update(watchlistItem)
|
||||||
.set({
|
.set({
|
||||||
company_name: input.companyName,
|
company_name: normalizedCompanyName,
|
||||||
sector: normalizedSector,
|
sector: normalizedSector,
|
||||||
category: normalizedCategory,
|
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)))
|
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
|
||||||
.returning();
|
.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) {
|
export async function deleteWatchlistItemRecord(userId: string, id: number) {
|
||||||
const removed = await db
|
const removed = await db
|
||||||
.delete(watchlistItem)
|
.delete(watchlistItem)
|
||||||
|
|||||||
66
lib/types.ts
66
lib/types.ts
@@ -5,6 +5,10 @@ export type User = {
|
|||||||
image: string | null;
|
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 = {
|
export type WatchlistItem = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -14,12 +18,18 @@ export type WatchlistItem = {
|
|||||||
category: string | null;
|
category: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
status: CoverageStatus;
|
||||||
|
priority: CoveragePriority;
|
||||||
|
updated_at: string;
|
||||||
|
last_reviewed_at: string | null;
|
||||||
|
latest_filing_date: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Holding = {
|
export type Holding = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
|
company_name: string | null;
|
||||||
shares: string;
|
shares: string;
|
||||||
avg_cost: string;
|
avg_cost: string;
|
||||||
current_price: string | null;
|
current_price: string | null;
|
||||||
@@ -165,6 +175,19 @@ export type PortfolioInsight = {
|
|||||||
created_at: string;
|
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 = {
|
export type CompanyFinancialPoint = {
|
||||||
filingDate: string;
|
filingDate: string;
|
||||||
filingType: Filing['filing_type'];
|
filingType: Filing['filing_type'];
|
||||||
@@ -332,6 +355,28 @@ export type CompanyFinancialStatementsResponse = {
|
|||||||
pendingFilings: number;
|
pendingFilings: number;
|
||||||
queuedSync: boolean;
|
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: {
|
metrics: {
|
||||||
taxonomy: Filing['metrics'];
|
taxonomy: Filing['metrics'];
|
||||||
validation: MetricValidationResult | null;
|
validation: MetricValidationResult | null;
|
||||||
@@ -371,6 +416,27 @@ export type CompanyAnalysis = {
|
|||||||
financials: CompanyFinancialPoint[];
|
financials: CompanyFinancialPoint[];
|
||||||
filings: Filing[];
|
filings: Filing[];
|
||||||
aiReports: CompanyAiReport[];
|
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';
|
export type NavGroup = 'overview' | 'research' | 'portfolio';
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const MIGRATION_FILES = [
|
|||||||
'0002_workflow_task_projection_metadata.sql',
|
'0002_workflow_task_projection_metadata.sql',
|
||||||
'0003_task_stage_event_timeline.sql',
|
'0003_task_stage_event_timeline.sql',
|
||||||
'0004_watchlist_company_taxonomy.sql',
|
'0004_watchlist_company_taxonomy.sql',
|
||||||
'0005_financial_taxonomy_v3.sql'
|
'0005_financial_taxonomy_v3.sql',
|
||||||
|
'0006_coverage_journal_tracking.sql'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');
|
export const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');
|
||||||
|
|||||||
Reference in New Issue
Block a user